From 55bf4795dd798e522e95bce1b593b4865de06361 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 3 Sep 2026 17:09:30 +1000 Subject: [PATCH 001/106] Spike: AppHost-owned terminal as an interaction service input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `InputType.Terminal`, an interaction input whose process is owned by the AppHost itself rather than orchestrated by Aspire. The session is tunneled to the browser over the dashboard's existing gRPC connection: xterm.js --ws--> dashboard --grpc--> AppHost --> Hex1b PTY This is the counterpart to `WithTerminal()`. There the terminal attaches to a resource DCP already runs and the PTY lives in a separate Aspire.TerminalHost process. Here the AppHost spawns and owns the process, which makes it possible to drive interactive flows Aspire does not orchestrate — auth prompts, or `docker exec -it` into a container in the app model. The load-bearing seam is that `ITerminalConnectionResolver.ConnectAsync` returns a plain `Stream` and `TerminalWebSocketProxy.BridgeAsync` is a transport-agnostic byte pump. Surfacing the gRPC tunnel as a `Stream` makes the entire existing dashboard -> browser stack (websocket proxy, hmp1-client.js, xterm.js) reusable verbatim; the browser needs no protocol changes. AppHost: - `AttachTerminal` bidi RPC plus `INPUT_TYPE_TERMINAL` in the dashboard service proto. - `GrpcTerminalStream` adapts the bidi call to a duplex `Stream`. - `InteractionTerminalSessionStore` owns session lifetime: lazy start on first attach, multiple viewers, teardown when the interaction completes or is cancelled. Dashboard: - `GrpcTerminalClientStream` + `IDashboardClient.AttachInteractionTerminalAsync`. - New `/api/interaction-terminal` websocket endpoint. - `TerminalView` is now keyed by resolved endpoint rather than resource/replica, so it can serve both endpoints. Playground (Terminals): an AppHost-owned shell on `repl`, a `docker exec` shell on `shellbox`, and a Node REPL on `noderepl`. SPIKE CAVEAT: `InteractionInput.Terminal` takes a `Hex1bTerminalBuilder`, leaking Hex1b into the Aspire.Hosting API surface. A builder rather than a built terminal because the HMP1 server transport must be attached before `Build()`. This shape is deliberately temporary and needs to be reworked before this is anything but a spike. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Terminals/Terminals.AppHost/AppHost.cs | 23 +- .../TerminalInteractionCommands.cs | 160 ++++++++++++ .../Components/Controls/TerminalView.razor.cs | 121 +++++----- .../Dialogs/InteractionsInputDialog.razor | 14 ++ .../Dialogs/InteractionsInputDialog.razor.cs | 10 + .../Dialogs/InteractionsInputDialog.razor.css | 15 ++ .../Interactions/InteractionsProvider.cs | 6 +- .../ServiceClient/DashboardClient.cs | 25 ++ .../ServiceClient/GrpcTerminalClientStream.cs | 170 +++++++++++++ .../ServiceClient/IDashboardClient.cs | 9 + .../ServiceClient/SelectedDashboardClient.cs | 6 + .../Terminal/TerminalWebSocketProxy.cs | 148 +++++++++++- src/Aspire.Hosting/Aspire.Hosting.csproj | 5 + .../Dashboard/DashboardService.cs | 52 +++- .../Dashboard/DashboardServiceHost.cs | 4 +- .../Dashboard/GrpcTerminalStream.cs | 132 ++++++++++ .../IInteractionTerminalSessionStore.cs | 41 ++++ .../InteractionTerminalSessionStore.cs | 227 ++++++++++++++++++ .../Dashboard/proto/dashboard_service.proto | 27 +++ .../DistributedApplicationBuilder.cs | 1 + src/Aspire.Hosting/IInteractionService.cs | 32 ++- src/Aspire.Hosting/InteractionService.cs | 33 ++- .../Infrastructure/MockDashboardClient.cs | 1 + .../ResourceOutgoingPeerResolverTests.cs | 1 + .../DefaultTerminalConnectionResolverTests.cs | 1 + .../Aspire.Hosting.Tests.csproj | 1 + .../DashboardServiceDataTerminalTests.cs | 3 +- .../Dashboard/DashboardServiceTests.cs | 33 ++- .../Dashboard/GrpcTerminalStreamTests.cs | 97 ++++++++ .../InteractionTerminalSessionStoreTests.cs | 209 ++++++++++++++++ .../InteractionServiceTests.cs | 12 +- .../ApplicationOrchestratorTests.cs | 3 +- .../Orchestrator/ParameterProcessorTests.cs | 3 +- .../PipelineActivityReporterTests.cs | 2 +- tests/Shared/TestDashboardClient.cs | 5 + .../TestInteractionTerminalSessionStore.cs | 35 +++ 36 files changed, 1583 insertions(+), 84 deletions(-) create mode 100644 playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs create mode 100644 src/Aspire.Hosting/Dashboard/GrpcTerminalStream.cs create mode 100644 src/Aspire.Hosting/Dashboard/IInteractionTerminalSessionStore.cs create mode 100644 src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs create mode 100644 tests/Aspire.Hosting.Tests/Dashboard/GrpcTerminalStreamTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Dashboard/InteractionTerminalSessionStoreTests.cs create mode 100644 tests/Shared/TestInteractionTerminalSessionStore.cs diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index 467558ac499..2643b380e4f 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -5,6 +5,8 @@ // because this playground project intentionally exercises the experimental API. #pragma warning disable ASPIRETERMINAL001 +using Terminals.AppHost; + var builder = DistributedApplication.CreateBuilder(args); // A multi-replica project that calls `WithTerminal()` so each replica gets its @@ -19,7 +21,26 @@ options.Columns = 120; options.Rows = 32; options.ShowTerminalHost = true; - }); + }) + // Opens a shell owned by the AppHost rather than orchestrated by Aspire. It has nothing to do with `repl`; + // commands just need a host resource to hang off. + .WithAppHostShellCommand(); + +// Long-running container that the "Shell into container" interaction command execs into. Aspire is not orchestrating +// the exec — the AppHost shells out to `docker exec` — so the container needs a stable, predictable name. +builder.AddContainer("shellbox", "alpine") + .WithContainerName("terminals-playground-shellbox") + .WithArgs("sleep", "infinity") + .WithContainerShellCommand(); + +// Latest Node.js image, kept alive so the "Node REPL" interaction command can exec into it. The Node REPL is a +// readline app, so it exercises cursor addressing, history, and tab completion across the tunnel in a way a plain +// shell prompt does not. `sleep infinity` replaces the image's default CMD ("node") — an interactive REPL with no TTY +// attached would exit immediately. The entrypoint (docker-entrypoint.sh) is left alone so PATH is set up normally. +builder.AddContainer("noderepl", "node", "latest") + .WithContainerName("terminals-playground-noderepl") + .WithArgs("sleep", "infinity") + .WithNodeReplCommand(); if (OperatingSystem.IsWindows()) { diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs new file mode 100644 index 00000000000..99b3aff15ab --- /dev/null +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -0,0 +1,160 @@ +// 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 Microsoft.Extensions.DependencyInjection; + +// InputType.Terminal is an experimental spike. PromptInputsAsync is also experimental. +#pragma warning disable ASPIREINTERACTION001 + +namespace Terminals.AppHost; + +/// +/// Commands that exercise — an interaction input whose process is owned by the +/// AppHost itself rather than orchestrated by Aspire. +/// +/// +/// This is the counterpart to WithTerminal(). With WithTerminal() the terminal is attached to a resource +/// DCP already runs, and the PTY lives in a separate Aspire.TerminalHost process. Here the AppHost spawns and owns the +/// process, and the session is tunneled to the browser over the dashboard's existing gRPC connection. That makes it +/// possible to shell into things Aspire does not orchestrate — the docker exec commands below are the +/// motivating example. +/// +internal static class TerminalInteractionCommands +{ + /// + /// Adds a command that opens an interactive shell running as a child process of the AppHost. + /// + /// + /// Nothing about this shell is tied to ; commands just need a host resource to hang off. + /// + [AspireExportIgnore(Reason = "Uses interaction service callbacks and command handlers that are not ATS-compatible.")] + public static IResourceBuilder WithAppHostShellCommand(this IResourceBuilder resource) where T : IResource + { + return resource.WithCommand( + "terminal-interaction-shell", + "Open shell (interaction terminal)", + executeCommand: async commandContext => + { + var interactionService = commandContext.Services.GetRequiredService(); + + // The input takes a *builder*, not a built terminal: Aspire attaches the HMP1 server transport that + // carries the session over gRPC, and that has to happen before Build(). The caller only describes the + // workload. + var terminal = Hex1bTerminal.CreateBuilder() + .WithDimensions(120, 32) + .WithPtyProcess(OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/bash", OperatingSystem.IsWindows() ? [] : ["-i", "-l"]); + + var result = await interactionService.PromptInputsAsync( + "AppHost shell", + "This shell is a child process of the AppHost. Closing the dialog terminates it.", + [ + new InteractionInput + { + Name = "shell", + Label = "Shell", + InputType = InputType.Terminal, + Terminal = terminal + } + ], + cancellationToken: commandContext.CancellationToken); + + return result.Canceled + ? CommandResults.Failure("Canceled") + : CommandResults.Success(); + }); + } + + /// + /// Adds a command that shells into this container with docker exec -it <container> /bin/sh. + /// + [AspireExportIgnore(Reason = "Uses interaction service callbacks and command handlers that are not ATS-compatible.")] + public static IResourceBuilder WithContainerShellCommand(this IResourceBuilder container) + { + return container.WithCommand( + "terminal-interaction-docker", + "Shell into container (docker exec)", + executeCommand: commandContext => ExecIntoContainerAsync( + commandContext, + ResolveContainerName(container.Resource), + ["/bin/sh"], + title: $"Shell into '{container.Resource.Name}'", + message: $"Runs `docker exec -it {ResolveContainerName(container.Resource)} /bin/sh` from the AppHost process.")); + } + + /// + /// Adds a command that opens a Node REPL inside this container with docker exec -it <container> node. + /// + /// + /// The Node REPL is a readline app, so it exercises cursor addressing, history, and tab completion across the + /// tunnel in a way a plain shell prompt does not. + /// + [AspireExportIgnore(Reason = "Uses interaction service callbacks and command handlers that are not ATS-compatible.")] + public static IResourceBuilder WithNodeReplCommand(this IResourceBuilder container) + { + return container.WithCommand( + "terminal-interaction-node", + "Node REPL (docker exec)", + executeCommand: commandContext => ExecIntoContainerAsync( + commandContext, + ResolveContainerName(container.Resource), + ["node"], + title: "Node REPL", + message: $"Runs `docker exec -it {ResolveContainerName(container.Resource)} node` from the AppHost process.")); + } + + /// + /// Opens an interaction terminal whose process is docker exec -it into . + /// + /// + /// This is the motivating scenario for AppHost-owned terminals: shelling into a container in the app model without + /// Aspire orchestrating the exec itself. -it is required so docker allocates a TTY on the container side; + /// Hex1b supplies the PTY on this side. + /// + private static async Task ExecIntoContainerAsync( + ExecuteCommandContext commandContext, + string containerName, + string[] command, + string title, + string message) + { + var interactionService = commandContext.Services.GetRequiredService(); + + var terminal = Hex1bTerminal.CreateBuilder() + .WithDimensions(120, 32) + .WithPtyProcess("docker", ["exec", "-it", containerName, .. command]); + + var result = await interactionService.PromptInputsAsync( + title, + message, + [ + new InteractionInput + { + Name = "shell", + Label = "Container shell", + InputType = InputType.Terminal, + Terminal = terminal + } + ], + cancellationToken: commandContext.CancellationToken); + + return result.Canceled + ? CommandResults.Failure("Canceled") + : CommandResults.Success(); + } + + /// + /// Resolves the name docker knows this container by. + /// + /// + /// Without WithContainerName, DCP appends a random suffix to the resource name, so the resource name alone + /// would not be a valid docker exec target. These playground containers set an explicit name; the fallback + /// only exists so a misconfigured resource surfaces a docker error rather than throwing here. + /// + private static string ResolveContainerName(ContainerResource container) + { + return container.TryGetLastAnnotation(out var annotation) + ? annotation.Name + : container.Name; + } +} diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 5bfaa2de180..c7d35cc4343 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -18,8 +18,10 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable private IJSObjectReference? _jsModule; private DotNetObjectReference? _selfRef; private int _terminalId; - private string? _connectedResourceName; - private int _connectedReplicaIndex = -1; + // The endpoint (path + query) the JS terminal is currently bound to. A single string is used as the identity for + // rebind detection because a TerminalView can be addressed either by resource/replica or by an explicit endpoint + // (terminal-typed interaction inputs), and both collapse to one URL. + private string? _connectedEndpoint; // Highest reconnect generation we've observed from JS via a toolbar // snapshot. The JS side bumps `state.reconnect.generation` on every // initTerminal / reconnectTerminal / auto-reconnect. `reconnectTerminal` @@ -56,6 +58,17 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Parameter] public int ReplicaIndex { get; set; } + /// + /// Gets or sets an explicit endpoint (path and query) to connect to, overriding + /// and . + /// + /// + /// Used for terminals that are not backed by a resource replica — currently terminal-typed interaction inputs, + /// whose process is owned by the AppHost and reached via /api/interaction-terminal. + /// + [Parameter] + public string? EndpointPathAndQuery { get; set; } + /// /// Raised when the JS side pushes a fresh toolbar state snapshot (role, /// dims, font size, etc.). The host page subscribes so the chrome that @@ -74,7 +87,8 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable protected override async Task OnAfterRenderAsync(bool firstRender) { - if (string.IsNullOrEmpty(ResourceName)) + var endpoint = ResolveEndpoint(); + if (endpoint is null) { return; } @@ -82,35 +96,26 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { _initStarted = true; - // Snapshot the resource/replica values BEFORE the JS init await: - // parameter push from the parent can change ResourceName/ - // ReplicaIndex while initTerminal is in flight. Recording the - // *post-await* field values would falsely mark the terminal as - // connected to the new resource, so the rebind branch below - // would never fire and the JS terminal would keep streaming - // the previous resource. - var initResource = ResourceName; - var initReplica = ReplicaIndex; - await InitializeTerminalAsync(initResource!, initReplica); - // Only record the connected resource/replica when JS init actually - // produced a terminal. If _terminalId is still 0, InitializeTerminalAsync - // caught an exception; leaving _connectedResourceName null lets the - // rebind branch below (and future renders) notice and retry rather - // than silently masking the failure. + // Snapshot the endpoint BEFORE the JS init await: a parameter push from the parent can change it while + // initTerminal is in flight. Recording the *post-await* value would falsely mark the terminal as connected + // to the new endpoint, so the rebind branch below would never fire and the JS terminal would keep + // streaming the previous session. + var initEndpoint = endpoint; + await InitializeTerminalAsync(initEndpoint); + // Only record the connected endpoint when JS init actually produced a terminal. If _terminalId is still 0, + // InitializeTerminalAsync caught an exception; leaving _connectedEndpoint null lets the rebind branch below + // (and future renders) notice and retry rather than silently masking the failure. if (_terminalId != 0) { - _connectedResourceName = initResource; - _connectedReplicaIndex = initReplica; + _connectedEndpoint = initEndpoint; } - if (!string.Equals(ResourceName, _connectedResourceName, StringComparison.Ordinal) || - ReplicaIndex != _connectedReplicaIndex) + var currentEndpoint = ResolveEndpoint(); + if (!string.Equals(currentEndpoint, _connectedEndpoint, StringComparison.Ordinal)) { - var newResource = ResourceName; - var newReplica = ReplicaIndex; try { - await ReconnectAsync(newResource, newReplica); + await ReconnectAsync(currentEndpoint); } catch (JSDisconnectedException) { @@ -121,19 +126,17 @@ protected override async Task OnAfterRenderAsync(bool firstRender) return; } - _connectedResourceName = newResource; - _connectedReplicaIndex = newReplica; + _connectedEndpoint = currentEndpoint; } return; } // If a re-render fires while the very first initTerminal call is still // in flight, do nothing here. Once that call completes the firstRender - // path will set _connectedResourceName / _connectedReplicaIndex and - // any future rebind needed will be caught on the next render after - // that. Without this guard the rebind branch below would re-enter - // initialization and stack a second xterm onto the same container — - // see the comment on _initStarted. + // path will set _connectedEndpoint and any future rebind needed will be + // caught on the next render after that. Without this guard the rebind + // branch below would re-enter initialization and stack a second xterm + // onto the same container — see the comment on _initStarted. if (_initStarted && _terminalId == 0) { return; @@ -150,14 +153,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // the SignalR circuit and tear down the entire dashboard tab. Failing // to switch terminals is a localized, recoverable issue (the JS side // will keep retrying or the user can reload); a circuit failure is not. - if (!string.Equals(ResourceName, _connectedResourceName, StringComparison.Ordinal) || - ReplicaIndex != _connectedReplicaIndex) + if (!string.Equals(endpoint, _connectedEndpoint, StringComparison.Ordinal)) { - var newResource = ResourceName; - var newReplica = ReplicaIndex; try { - await ReconnectAsync(newResource, newReplica); + await ReconnectAsync(endpoint); } catch (JSDisconnectedException) { @@ -171,12 +171,30 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // side keeps retrying so a transient hiccup heals itself. return; } - _connectedResourceName = newResource; - _connectedReplicaIndex = newReplica; + _connectedEndpoint = endpoint; } } - private async Task InitializeTerminalAsync(string resourceName, int replicaIndex) + /// + /// Resolves the endpoint this terminal should be bound to, or when the component has not + /// been given enough information to connect yet. + /// + private string? ResolveEndpoint() + { + if (!string.IsNullOrEmpty(EndpointPathAndQuery)) + { + return EndpointPathAndQuery; + } + + if (string.IsNullOrEmpty(ResourceName)) + { + return null; + } + + return $"/api/terminal?resource={Uri.EscapeDataString(ResourceName)}&replica={ReplicaIndex}"; + } + + private async Task InitializeTerminalAsync(string endpoint) { try { @@ -187,7 +205,7 @@ private async Task InitializeTerminalAsync(string resourceName, int replicaIndex _connectedGeneration = -1; _terminalId = await _jsModule.InvokeAsync( - "initTerminal", _terminalElement, BuildWebSocketUrl(resourceName, replicaIndex), _selfRef); + "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef); } catch (JSDisconnectedException) { @@ -213,25 +231,22 @@ private async Task InitializeTerminalAsync(string resourceName, int replicaIndex } /// - /// Reconnects the terminal to a different resource/replica. When both - /// arguments match the current values this is a no-op. + /// Reconnects the terminal to a different endpoint. When the endpoint matches the current value this is a no-op. /// - public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) + public async Task ReconnectAsync(string? newEndpoint) { if (_jsModule is null || _terminalId == 0) { - ResourceName = newResourceName; - ReplicaIndex = newReplicaIndex; - if (!string.IsNullOrEmpty(newResourceName)) + if (!string.IsNullOrEmpty(newEndpoint)) { - await InitializeTerminalAsync(newResourceName, newReplicaIndex); + await InitializeTerminalAsync(newEndpoint); } return; } try { - if (string.IsNullOrEmpty(newResourceName)) + if (string.IsNullOrEmpty(newEndpoint)) { await _jsModule.InvokeVoidAsync("disposeTerminal", _terminalId); _terminalId = 0; @@ -239,12 +254,10 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) return; } - ResourceName = newResourceName; - ReplicaIndex = newReplicaIndex; var generation = await _jsModule.InvokeAsync( "reconnectTerminal", _terminalId, - BuildWebSocketUrl(newResourceName, newReplicaIndex)); + BuildWebSocketUrl(newEndpoint)); if (generation > 0) { _connectedGeneration = generation; @@ -401,11 +414,11 @@ public async Task RefreshLayoutAsync() } } - private string BuildWebSocketUrl(string resource, int replica) + private string BuildWebSocketUrl(string pathAndQuery) { var baseUri = new Uri(NavigationManager.BaseUri); var wsScheme = baseUri.Scheme == "https" ? "wss" : "ws"; - return $"{wsScheme}://{baseUri.Authority}/api/terminal?resource={Uri.EscapeDataString(resource)}&replica={replica}"; + return $"{wsScheme}://{baseUri.Authority}{pathAndQuery}"; } public async ValueTask DisposeAsync() diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor index ad165f160c6..f6dd7dbefa2 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor @@ -197,6 +197,20 @@ } break; + case InputType.Terminal: + @* The terminal's process is owned by the AppHost, not by an orchestrated resource, so the + * session is tunneled over the dashboard gRPC connection instead of the terminal host UDS. + * TerminalView is otherwise identical to the resource console experience. *@ + var terminalId = $"{localItem.InputKey}-Terminal"; + +
+ +
+
+ break; default: @* Ignore unexpected InputTypes *@ break; diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs index c48e2fe1dad..59303aeda51 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs @@ -272,6 +272,16 @@ private async Task ToggleSecretTextVisibilityAsync(InputViewModel inputModel) } } + /// + /// Builds the WebSocket endpoint that a terminal-typed input's TerminalView connects to. The AppHost keys + /// terminal sessions by interaction id and input name, so both travel in the query string; the dashboard resolves + /// them into an AttachTerminal gRPC call server-side. + /// + private string BuildInteractionTerminalEndpoint(InputViewModel inputModel) + { + return $"/api/interaction-terminal?interactionId={Content.Interaction.InteractionId.ToString(CultureInfo.InvariantCulture)}&input={Uri.EscapeDataString(inputModel.Input.Name)}"; + } + private static Icon GetSecretTextIcon(InputViewModel inputModel) { return inputModel.IsSecretTextVisible diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css index 14c015e48ca..00249b93515 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css @@ -55,3 +55,18 @@ .interaction-input-dialog .interaction-input ::deep fluent-text-field::part(end) { margin-inline-end: 0; } + +/* Terminals are the only input that needs an explicit box: xterm.js measures its container, so a zero-height + container renders nothing at all. The container also has to opt out of the 75%/500px width cap that keeps + ordinary form fields from stretching across the dialog. */ +.interaction-input-dialog .interaction-input ::deep .interaction-terminal-container { + width: 100%; + max-width: none; + height: 420px; + /* Flex children default to align-items: center from .input-line-container, which would collapse the terminal + to its content height. */ + align-self: stretch; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} diff --git a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs index 998646468de..dc3e32a97d1 100644 --- a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs +++ b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs @@ -250,7 +250,11 @@ private async Task InteractionsDisplayAsync() var dialogParameters = CreateDialogParameters(item, intent: null); dialogParameters.Id = "interactions-input-dialog"; - dialogParameters.Width = $"min(650px, {width})"; + // Terminals need far more room than form fields: at the default 650px cap a terminal fits roughly + // 80 columns, so anything wider than that gets reflowed. Give terminal dialogs the full desktop + // width budget instead. + var hasTerminalInput = inputs.InputItems.Any(i => i.InputType == InputType.Terminal); + dialogParameters.Width = hasTerminalInput ? width : $"min(650px, {width})"; dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => { // Only send notification of completion if the dialog was cancelled. diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 5417d7a1ad2..1165aae68d0 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -1150,6 +1150,31 @@ public async Task UploadFileAsync(Stream fileStream, string fileName, lo return response.FileId; } + public async Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) + { + EnsureInitialized(); + + // The call outlives this method, so the linked CTS cannot be scoped with `using` here. Link to the client + // token anyway so a dashboard-wide disconnect tears the tunnel down instead of leaking it. + var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); + var call = _client!.AttachTerminal(headers: _headers, cancellationToken: combinedTokens.Token); + var stream = new GrpcTerminalClientStream(call, interactionId, inputName, combinedTokens); + + try + { + // The AppHost blocks on the selector frame before wiring the call to Hex1b, so send it eagerly rather than + // waiting for the browser's first HMP1 frame — otherwise nothing streams until the user types. + await stream.SendSelectorAsync(combinedTokens.Token).ConfigureAwait(false); + } + catch + { + stream.Dispose(); + throw; + } + + return stream; + } + public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _state, StateDisposed) is not StateDisposed) diff --git a/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs b/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs new file mode 100644 index 00000000000..188188ed264 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.DashboardService.Proto.V1; +using Google.Protobuf; +using Grpc.Core; + +namespace Aspire.Dashboard.ServiceClient; + +/// +/// Client-side counterpart of the AppHost's GrpcTerminalStream: presents an AttachTerminal call as a +/// duplex carrying opaque HMP1 bytes. +/// +/// +/// The dashboard is a byte-level relay between the browser WebSocket and the AppHost, so it never interprets HMP1 +/// framing. gRPC message boundaries are unrelated to HMP1 frame boundaries: reads hand back whatever bytes are +/// available and keep the unread remainder of a message for the next read. +/// +internal sealed class GrpcTerminalClientStream : Stream +{ + private readonly AsyncDuplexStreamingCall _call; + private readonly int _interactionId; + private readonly string _inputName; + // The linked CTS that scopes the call outlives the method that created it, so the stream owns its disposal. + private readonly IDisposable? _callScope; + // gRPC request streams do not support concurrent writes, and the WebSocket pump is not guaranteed to be the only + // writer, so serialize here rather than relying on the caller. + private readonly SemaphoreSlim _writeLock = new(1, 1); + private ReadOnlyMemory _remainder; + private bool _completed; + private bool _disposed; + + public GrpcTerminalClientStream( + AsyncDuplexStreamingCall call, + int interactionId, + string inputName, + IDisposable? callScope = null) + { + _call = call; + _interactionId = interactionId; + _inputName = inputName; + _callScope = callScope; + } + + /// + /// Sends the selector frame that tells the AppHost which interaction input this call is attaching to. The AppHost + /// reads exactly one such frame before handing the call to Hex1b, so this must happen before any payload. + /// + public Task SendSelectorAsync(CancellationToken cancellationToken) + { + var frame = new TerminalClientFrame + { + InteractionId = _interactionId, + InputName = _inputName + }; + + return _call.RequestStream.WriteAsync(frame, cancellationToken); + } + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (buffer.IsEmpty) + { + return 0; + } + + while (_remainder.IsEmpty) + { + if (_completed) + { + return 0; + } + + if (!await _call.ResponseStream.MoveNext(cancellationToken).ConfigureAwait(false)) + { + _completed = true; + return 0; + } + + // A zero-length payload is not end of stream; keep waiting for real bytes. + _remainder = _call.ResponseStream.Current.Data.Memory; + } + + var count = Math.Min(buffer.Length, _remainder.Length); + _remainder[..count].CopyTo(buffer); + _remainder = _remainder[count..]; + return count; + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (buffer.IsEmpty) + { + return; + } + + // Copy rather than UnsafeWrap: the caller owns the buffer and may reuse it as soon as this method returns, + // and gRPC does not guarantee the payload is serialized before the write task completes. + // + // The interaction id and input name are only set on the selector frame; the AppHost ignores them afterwards. + var frame = new TerminalClientFrame { Data = ByteString.CopyFrom(buffer.Span) }; + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _call.RequestStream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + public override int Read(byte[] buffer, int offset, int count) + => ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + // gRPC flushes per message, so there is nothing to flush here. + public override void Flush() + { + } + + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + + // Disposing the call is what tears the tunnel down: the AppHost sees the request stream end and releases + // the terminal session's attachment. Best effort because the call may already be faulted or cancelled. + try + { + _call.Dispose(); + } + catch + { + // Nothing useful to do; the connection is going away regardless. + } + + _writeLock.Dispose(); + _callScope?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs index bf08488873f..5820e7b8668 100644 --- a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs @@ -65,6 +65,15 @@ public interface IDashboardClient : IResourceRepository, IAsyncDisposable Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken); Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken); + + /// + /// Opens a duplex byte stream to a terminal-typed interaction input hosted by the AppHost. + /// + /// + /// The returned stream carries opaque HMP1 frames in both directions. The dashboard relays them verbatim between + /// the browser's WebSocket and the AppHost, exactly as it does for resource terminals. + /// + Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken); } /// diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs index 995d044af69..ebeea9e673e 100644 --- a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -70,6 +70,12 @@ public Task UploadFileAsync(Stream fileStream, string fileName, long exp return currentClient.UploadFileAsync(fileStream, fileName, expectedSize, interactionId, inputName, cancellationToken); } + public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) + { + EnsureWritable(); + return currentClient.AttachInteractionTerminalAsync(interactionId, inputName, cancellationToken); + } + private void EnsureWritable() { if (IsReadOnly) diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index 7ed4ccdcbd7..4b1e2813598 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -87,6 +87,138 @@ public static void MapTerminalWebSocket(this WebApplication app) } } }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName); + + // Terminal-typed interaction inputs. Unlike /api/terminal — where the process is orchestrated by Aspire and + // hosted out-of-process by Aspire.TerminalHost behind a Unix domain socket — the process here is owned by the + // AppHost itself, so the session is tunneled over the existing dashboard gRPC connection. Everything below the + // stream (this pump, the browser's HMP1 client, xterm.js) is identical; only the transport differs. + app.Map("/api/interaction-terminal", async (HttpContext context, + IDashboardClient dashboardClient, + ILoggerFactory loggerFactory) => + { + var logger = loggerFactory.CreateLogger("Aspire.Dashboard.Terminal.TerminalWebSocketProxy"); + var connectionId = Guid.NewGuid().ToString("n").Substring(0, 8); + + try + { + await HandleInteractionAsync(context, dashboardClient, logger, connectionId).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Interaction terminal WebSocket handler {ConnectionId} crashed.", connectionId); + + if (!context.Response.HasStarted) + { + try + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + } + catch + { + // Response could be partially flushed by Kestrel; nothing more to do. + } + } + } + }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName); + } + + internal static async Task HandleInteractionAsync(HttpContext context, + IDashboardClient dashboardClient, + ILogger logger, + string connectionId) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Expected a WebSocket upgrade request.").ConfigureAwait(false); + return; + } + + // Same Cross-Site WebSocket Hijacking defense as /api/terminal — see the commentary there. This endpoint is + // arguably more sensitive because the terminal runs as the AppHost process itself. + if (!WebSocketOriginValidator.IsSameOrigin(context, out var originLogValue)) + { + logger.LogWarning( + "Rejecting interaction terminal WebSocket upgrade {ConnectionId} with disallowed Origin '{Origin}'.", + connectionId, + originLogValue); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync("Origin not allowed.").ConfigureAwait(false); + return; + } + + var interactionIdText = context.Request.Query["interactionId"].ToString(); + var inputName = context.Request.Query["input"].ToString(); + + if (!int.TryParse(interactionIdText, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var interactionId)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Missing or invalid 'interactionId' query parameter.").ConfigureAwait(false); + return; + } + + if (string.IsNullOrWhiteSpace(inputName)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Missing 'input' query parameter.").ConfigureAwait(false); + return; + } + + // Open the tunnel before accepting the WebSocket so an unknown interaction/input surfaces as a real HTTP error + // instead of a WebSocket that closes immediately for no visible reason. + Stream upstream; + try + { + upstream = await dashboardClient.AttachInteractionTerminalAsync(interactionId, inputName, context.RequestAborted).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Failed to attach interaction terminal for {InteractionId}/{InputName}.", interactionId, inputName); + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsync("Terminal is unavailable.").ConfigureAwait(false); + return; + } + + WebSocket ws; + try + { + ws = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to accept interaction terminal WebSocket for {InteractionId}/{InputName}.", interactionId, inputName); + try { upstream.Dispose(); } catch { /* swallow */ } + return; + } + + logger.LogInformation("Interaction terminal WS opened for {InteractionId}/{InputName} ({ConnectionId}).", + interactionId, inputName, connectionId); + + try + { + await BridgeAsync(ws, upstream, logger, connectionId, context.RequestAborted, upstreamEofIsNormal: true).ConfigureAwait(false); + } + finally + { + // Disposing ends the gRPC call, which is how the AppHost learns this viewer is gone. + try { upstream.Dispose(); } catch { /* swallow */ } + logger.LogInformation("Interaction terminal WS closed for {InteractionId}/{InputName} ({ConnectionId}).", + interactionId, inputName, connectionId); + } + + if (ws.State == WebSocketState.Open) + { + try + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, + "terminal closed", + CancellationToken.None).ConfigureAwait(false); + } + catch + { + // best effort + } + } } internal static async Task HandleAsync(HttpContext context, @@ -259,7 +391,8 @@ private static async Task BridgeAsync(WebSocket ws, Stream upstream, ILogger logger, string connectionId, - CancellationToken ct) + CancellationToken ct, + bool upstreamEofIsNormal = false) { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var token = linkedCts.Token; @@ -323,7 +456,7 @@ private static async Task BridgeAsync(WebSocket ws, finally { ArrayPool.Shared.Return(buffer); - LogPumpEnd(logger, connectionId, "inbound", endReason, endException, bytesIn, sends: 0, slowSends: 0, maxSendMs: 0); + LogPumpEnd(logger, connectionId, "inbound", endReason, endException, bytesIn, sends: 0, slowSends: 0, maxSendMs: 0, upstreamEofIsNormal); } }, token); @@ -427,7 +560,7 @@ await ws.SendAsync(new ArraySegment(buffer, 0, read), finally { ArrayPool.Shared.Return(buffer); - LogPumpEnd(logger, connectionId, "outbound", endReason, endException, bytesOut, sends, slowSends, maxSendMs); + LogPumpEnd(logger, connectionId, "outbound", endReason, endException, bytesOut, sends, slowSends, maxSendMs, upstreamEofIsNormal); } }, token); @@ -442,7 +575,8 @@ await ws.SendAsync(new ArraySegment(buffer, 0, read), } private static void LogPumpEnd(ILogger logger, string connectionId, string direction, string reason, - Exception? exception, long bytes, long sends, long slowSends, long maxSendMs) + Exception? exception, long bytes, long sends, long slowSends, long maxSendMs, + bool upstreamEofIsNormal) { // Log abnormal terminations at Warning so they show up in default // AppHost output, normal terminations at Information. The reason @@ -450,9 +584,13 @@ private static void LogPumpEnd(ILogger logger, string connectionId, string direc // reconnects: "upstream-eof" points at the terminal host / // slow-peer policy; "exception" + the type points at a transport // failure; "browser-close" is a clean browser-initiated close. + // + // Interaction terminals invert the meaning of "upstream-eof": the AppHost owns the process and ends the + // gRPC stream as soon as the interaction is completed or cancelled, so EOF is the expected close path there. var slow = direction == "outbound" ? $" sends={sends} slowSends={slowSends} maxSendMs={maxSendMs}" : ""; var exType = exception?.GetType().FullName ?? "(none)"; - var level = (reason is "exception" or "upstream-eof") ? LogLevel.Warning : LogLevel.Information; + var isAbnormal = reason is "exception" || (reason is "upstream-eof" && !upstreamEofIsNormal); + var level = isAbnormal ? LogLevel.Warning : LogLevel.Information; logger.Log(level, exception, "Terminal WS {Direction} pump ended for {ConnectionId}: reason={Reason} bytes={Bytes}{SlowInfo} exceptionType={ExceptionType}.", direction, connectionId, reason, bytes, slow, exType); diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 02cc4d5d447..e02db465bf1 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -73,6 +73,11 @@ + + diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 19324c89af0..033126d304f 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -23,7 +23,7 @@ namespace Aspire.Hosting.Dashboard; /// required beyond a single request. Longer-scoped data is stored in . /// [Authorize(Policy = ResourceServiceApiKeyAuthorization.PolicyName)] -internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime, IConfiguration configuration, ILogger logger, IInteractionFileUploadStore fileUploadStore) +internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime, IConfiguration configuration, ILogger logger, IInteractionFileUploadStore fileUploadStore, IInteractionTerminalSessionStore terminalSessionStore) : Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceBase { // gRPC has a maximum receive size of 4MB. Force logs into batches to avoid exceeding receive size. @@ -268,6 +268,7 @@ internal static Aspire.DashboardService.Proto.V1.InputType MapInputType(Aspire.H Aspire.Hosting.InputType.Boolean => Aspire.DashboardService.Proto.V1.InputType.Boolean, Aspire.Hosting.InputType.Number => Aspire.DashboardService.Proto.V1.InputType.Number, Aspire.Hosting.InputType.File => Aspire.DashboardService.Proto.V1.InputType.File, + Aspire.Hosting.InputType.Terminal => Aspire.DashboardService.Proto.V1.InputType.Terminal, _ => throw new InvalidOperationException($"Unexpected input type: {inputType}"), }; } @@ -282,6 +283,7 @@ public static Aspire.Hosting.InputType MapInputType(Aspire.DashboardService.Prot Aspire.DashboardService.Proto.V1.InputType.Boolean => InputType.Boolean, Aspire.DashboardService.Proto.V1.InputType.Number => InputType.Number, Aspire.DashboardService.Proto.V1.InputType.File => InputType.File, + Aspire.DashboardService.Proto.V1.InputType.Terminal => InputType.Terminal, _ => throw new InvalidOperationException($"Unexpected input type: {inputType}"), }; } @@ -592,4 +594,52 @@ public override async Task UploadFile(IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context) + { + var cancellationToken = context.CancellationToken; + + // The first frame selects the session, mirroring how UploadFile carries its metadata on the first chunk. + if (!await requestStream.MoveNext(cancellationToken).ConfigureAwait(false)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Terminal stream is empty.")); + } + + var selector = requestStream.Current; + if (selector.InteractionId <= 0) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "First frame must include an interaction ID.")); + } + if (string.IsNullOrEmpty(selector.InputName)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "First frame must include an input name.")); + } + + var stream = new GrpcTerminalStream(requestStream, responseStream); + await using var _ = stream.ConfigureAwait(false); + + try + { + // Returns once the session ends or the caller disconnects. Holding the call open for that whole time is + // what keeps the tunnel alive, so this must not be fire-and-forget. + await terminalSessionStore.AttachAsync( + selector.InteractionId, + selector.InputName, + stream, + cancellationToken).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + // The interaction completed or never had this terminal input; the dashboard may still be holding a stale + // dialog open, so report it as a precondition failure rather than faulting the whole connection. + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The dashboard closed the tunnel, typically because the browser tab or dialog went away. + } + } } diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index c2c75e90e01..16e023cc034 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -50,7 +50,8 @@ public DashboardServiceHost( ResourceLoggerService resourceLoggerService, ResourceCommandService resourceCommandService, InteractionService interactionService, - IInteractionFileUploadStore fileUploadStore) + IInteractionFileUploadStore fileUploadStore, + IInteractionTerminalSessionStore terminalSessionStore) { _logger = loggerFactory.CreateLogger(); @@ -110,6 +111,7 @@ public DashboardServiceHost( builder.Services.AddSingleton(resourceLoggerService); builder.Services.AddSingleton(interactionService); builder.Services.AddSingleton(fileUploadStore); + builder.Services.AddSingleton(terminalSessionStore); builder.WebHost.ConfigureKestrel(ConfigureKestrel); diff --git a/src/Aspire.Hosting/Dashboard/GrpcTerminalStream.cs b/src/Aspire.Hosting/Dashboard/GrpcTerminalStream.cs new file mode 100644 index 00000000000..083d0b74513 --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/GrpcTerminalStream.cs @@ -0,0 +1,132 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.DashboardService.Proto.V1; +using Google.Protobuf; +using Grpc.Core; + +namespace Aspire.Hosting.Dashboard; + +/// +/// Presents a bidirectional AttachTerminal gRPC call as a duplex . +/// +/// +/// Hex1b's HMP1 server consumes plain streams (see WithHmp1Server), so tunneling a terminal session over gRPC +/// only requires adapting the call's message pairs back into a byte stream. HMP1 framing is preserved end to end and +/// is never interpreted here: gRPC message boundaries are unrelated to HMP1 frame boundaries, so reads hand back +/// whatever bytes are available and keep the unread remainder of a message for the next read. +/// +internal sealed class GrpcTerminalStream : Stream +{ + private readonly IAsyncStreamReader _requestStream; + private readonly IServerStreamWriter _responseStream; + // gRPC response streams do not support concurrent writes. Hex1b writes terminal output from its own pump, so + // serialize here rather than relying on the caller to do it. + private readonly SemaphoreSlim _writeLock = new(1, 1); + private ReadOnlyMemory _remainder; + private bool _completed; + + public GrpcTerminalStream( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream) + { + _requestStream = requestStream; + _responseStream = responseStream; + } + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (buffer.IsEmpty) + { + return 0; + } + + while (_remainder.IsEmpty) + { + if (_completed) + { + return 0; + } + + if (!await _requestStream.MoveNext(cancellationToken).ConfigureAwait(false)) + { + _completed = true; + return 0; + } + + // The selector frame is consumed by AttachTerminal before this stream is created, but a client is free to + // send further frames with no payload; those must not be reported as end of stream. + _remainder = _requestStream.Current.Data.Memory; + } + + var count = Math.Min(buffer.Length, _remainder.Length); + _remainder[..count].CopyTo(buffer); + _remainder = _remainder[count..]; + return count; + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (buffer.IsEmpty) + { + return; + } + + // Copy rather than UnsafeWrap: the caller owns the buffer and may reuse it as soon as this method returns, + // and gRPC does not guarantee the payload is serialized before the write task completes. + var frame = new TerminalServerFrame { Data = ByteString.CopyFrom(buffer.Span) }; + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _responseStream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + public override int Read(byte[] buffer, int offset, int count) + => ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + // gRPC flushes per message, so there is nothing to flush here. + public override void Flush() + { + } + + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _writeLock.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/Aspire.Hosting/Dashboard/IInteractionTerminalSessionStore.cs b/src/Aspire.Hosting/Dashboard/IInteractionTerminalSessionStore.cs new file mode 100644 index 00000000000..456d2d7db98 --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/IInteractionTerminalSessionStore.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b; + +namespace Aspire.Hosting; + +/// +/// Tracks the AppHost-owned terminal sessions belonging to interaction inputs. +/// +/// +/// This mirrors : the interaction itself only carries a handle over the +/// dashboard gRPC channel, while the payload — here a live HMP1 byte stream rather than file bytes — is moved over a +/// dedicated streaming RPC. +/// +internal interface IInteractionTerminalSessionStore +{ + /// + /// Registers an interaction and the terminal inputs that can be attached to. + /// + void StartInteraction(int interactionId, IReadOnlyList<(string InputName, Hex1bTerminalBuilder Builder)> terminalInputs); + + /// + /// Attaches a client to a terminal session, starting the session if this is the first client. + /// + /// + /// A task that completes when the session ends or is signalled. Callers keep + /// their transport open until it completes. + /// + Task AttachAsync(int interactionId, string inputName, Stream clientStream, CancellationToken cancellationToken); + + /// + /// Tears down every terminal session owned by an interaction that completed normally. + /// + void CompleteInteraction(int interactionId); + + /// + /// Tears down every terminal session owned by an interaction that was cancelled. + /// + void CancelInteraction(int interactionId); +} diff --git a/src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs b/src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs new file mode 100644 index 00000000000..509ffc14469 --- /dev/null +++ b/src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs @@ -0,0 +1,227 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Threading.Channels; +using Hex1b; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Dashboard; + +/// +/// Owns the lifetime of terminal sessions created for interaction inputs. +/// +internal sealed class InteractionTerminalSessionStore : IInteractionTerminalSessionStore, IDisposable +{ + private readonly ConcurrentDictionary _interactions = new(); + private readonly ILogger _logger; + private int _disposed; + + public InteractionTerminalSessionStore(ILogger logger) + { + _logger = logger; + } + + public void StartInteraction(int interactionId, IReadOnlyList<(string InputName, Hex1bTerminalBuilder Builder)> terminalInputs) + { + var sessions = new Dictionary(StringComparers.InteractionInputName); + foreach (var (inputName, builder) in terminalInputs) + { + sessions[inputName] = new TerminalSession(interactionId, inputName, builder, _logger); + } + + if (_interactions.TryAdd(interactionId, new TerminalInteraction(sessions))) + { + _logger.LogDebug( + "Started tracking {SessionCount} terminal session(s) for interaction {InteractionId}.", + sessions.Count, + interactionId); + } + } + + public Task AttachAsync(int interactionId, string inputName, Stream clientStream, CancellationToken cancellationToken) + { + if (!_interactions.TryGetValue(interactionId, out var interaction) || + !interaction.Sessions.TryGetValue(inputName, out var session)) + { + throw new InvalidOperationException($"Interaction '{interactionId}' does not have a terminal input named '{inputName}'."); + } + + return session.AttachAsync(clientStream, cancellationToken); + } + + public void CompleteInteraction(int interactionId) => EndInteraction(interactionId, "completed"); + + public void CancelInteraction(int interactionId) => EndInteraction(interactionId, "cancelled"); + + private void EndInteraction(int interactionId, string reason) + { + if (!_interactions.TryRemove(interactionId, out var interaction)) + { + return; + } + + _logger.LogDebug( + "Tearing down {SessionCount} terminal session(s) for {Reason} interaction {InteractionId}.", + interaction.Sessions.Count, + reason, + interactionId); + + foreach (var session in interaction.Sessions.Values) + { + session.Stop(); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + foreach (var interactionId in _interactions.Keys) + { + EndInteraction(interactionId, "disposed"); + } + } + + private sealed class TerminalInteraction(Dictionary sessions) + { + public Dictionary Sessions { get; } = sessions; + } + + /// + /// A single AppHost-owned terminal. Clients are handed to Hex1b's HMP1 server through a channel, which lets the + /// same session serve several attached viewers (for example two dashboard tabs) using HMP1's multi-head support. + /// + private sealed class TerminalSession(int interactionId, string inputName, Hex1bTerminalBuilder builder, ILogger logger) + { + // Unbounded because the producer is a human attaching a viewer; the queue depth is realistically 0 or 1 and + // dropping or blocking an attach would strand the RPC that is waiting to be served. + private readonly Channel _clients = Channel.CreateUnbounded(); + private readonly CancellationTokenSource _stopCts = new(); + // Aspire.Hosting targets net8.0, which predates System.Threading.Lock, so this is a plain monitor gate. + private readonly object _gate = new(); + private Hex1bTerminal? _terminal; + private Task? _runTask; + private bool _stopped; + + public Task AttachAsync(Stream clientStream, CancellationToken cancellationToken) + { + EnsureStarted(); + + if (!_clients.Writer.TryWrite(clientStream)) + { + throw new InvalidOperationException($"Terminal session for input '{inputName}' is no longer accepting clients."); + } + + // The caller's transport must stay open for as long as Hex1b may use the stream. The session's own token + // ends the wait when the interaction is torn down, which lets the transport close from the AppHost side + // instead of lingering until the user closes the browser. + return WaitForSessionEndAsync(cancellationToken); + } + + private async Task WaitForSessionEndAsync(CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _stopCts.Token); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = linked.Token.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), completion); + await completion.Task.ConfigureAwait(false); + } + + private void EnsureStarted() + { + lock (_gate) + { + if (_stopped) + { + throw new InvalidOperationException($"Terminal session for input '{inputName}' has already stopped."); + } + + if (_terminal is not null) + { + return; + } + + // Aspire owns the transport: the caller configures only the workload, and the HMP1 server is attached + // here so the session is reachable over the dashboard gRPC tunnel rather than a Unix domain socket. + // Started lazily so a dialog dismissed without opening the terminal never spawns the workload. + _terminal = builder + .WithHmp1Server(_clients.Reader.ReadAllAsync) + .Build(); + + logger.LogDebug( + "Starting terminal session for interaction {InteractionId}, input {InputName}.", + interactionId, + inputName); + + _runTask = RunTerminalAsync(_terminal); + } + } + + private async Task RunTerminalAsync(Hex1bTerminal terminal) + { + // Yield before touching the terminal so RunAsync never executes inline under _gate. + await Task.Yield(); + + try + { + await terminal.RunAsync(_stopCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when the interaction completes while the terminal is still running. + } + catch (Exception ex) + { + logger.LogError( + ex, + "Terminal session for interaction {InteractionId}, input {InputName} failed.", + interactionId, + inputName); + } + finally + { + // Unblock every attached client so their transports close rather than waiting for the interaction to + // end. This is the path taken when the workload itself exits, e.g. the user types `exit`. + _stopCts.Cancel(); + await terminal.DisposeAsync().ConfigureAwait(false); + } + } + + public void Stop() + { + Task? runTask; + lock (_gate) + { + if (_stopped) + { + return; + } + + _stopped = true; + _clients.Writer.TryComplete(); + runTask = _runTask; + } + + _stopCts.Cancel(); + + if (runTask is null) + { + // The session was registered but never attached to, so there is nothing to wind down and no terminal + // was ever built. Dispose the token source directly. + _stopCts.Dispose(); + return; + } + + // Don't block interaction teardown on the workload exiting; dispose the token source once it has. + _ = runTask.ContinueWith( + static (_, state) => ((CancellationTokenSource)state!).Dispose(), + _stopCts, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + } +} diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 93ff01eb847..176ecbd19dc 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -455,6 +455,7 @@ enum InputType { INPUT_TYPE_BOOLEAN = 4; INPUT_TYPE_NUMBER = 5; INPUT_TYPE_FILE = 6; + INPUT_TYPE_TERMINAL = 7; } //////////////////////////////////////////// @@ -476,6 +477,31 @@ message UploadFileResponse { //////////////////////////////////////////// +// A message sent by the dashboard to the AppHost for a terminal input session. +// +// The stream carries an opaque HMP1 (Hex1b Muxer Protocol v1) byte stream, tunneled +// so that the terminal can be owned by the AppHost process itself rather than by a +// separate terminal host process reachable over a Unix domain socket. Neither the +// dashboard nor this service interprets `data`; both sides are byte-level relays +// between the browser's HMP1 client and the AppHost's HMP1 server. +message TerminalClientFrame { + // The interaction that owns the terminal session (sent in the first frame). + int32 interaction_id = 1; + // The interaction input that owns the terminal session (sent in the first frame). + string input_name = 2; + // A chunk of the HMP1 byte stream flowing from the browser to the AppHost. + // Empty on the first frame, which only carries the session selector. + bytes data = 3; +} + +// A message sent by the AppHost to the dashboard for a terminal input session. +message TerminalServerFrame { + // A chunk of the HMP1 byte stream flowing from the AppHost to the browser. + bytes data = 1; +} + +//////////////////////////////////////////// + service DashboardService { rpc GetApplicationInformation(ApplicationInformationRequest) returns (ApplicationInformationResponse); rpc WatchResources(WatchResourcesRequest) returns (stream WatchResourcesUpdate); @@ -483,4 +509,5 @@ service DashboardService { rpc ExecuteResourceCommand(ResourceCommandRequest) returns (ResourceCommandResponse); rpc WatchInteractions(stream WatchInteractionsRequestUpdate) returns (stream WatchInteractionsResponseUpdate); rpc UploadFile(stream UploadFileChunk) returns (UploadFileResponse); + rpc AttachTerminal(stream TerminalClientFrame) returns (stream TerminalServerFrame); } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 4ab06839045..f4ea6e2de4c 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -467,6 +467,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); ConfigureHealthChecks(); diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index d629449ca68..df114a37029 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using Hex1b; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -463,6 +464,27 @@ public long? MaxFileSize /// [AspireExportIgnore(Reason = "InteractionFileCollection owns server-local files and implements IDisposable, which is not ATS-compatible.")] public InteractionFileCollection GetFiles() => _files; + + /// + /// Gets the terminal session to run for an input. Required for terminal inputs + /// and ignored by every other input type. + /// + /// + /// + /// Configure the builder with the workload to run — for example + /// Hex1bTerminal.CreateBuilder().WithPtyProcess("docker", ["exec", "-it", id, "/bin/sh"]). The AppHost + /// attaches the transport and builds and runs the terminal, so the builder must not be built by the caller. + /// + /// + /// The session starts lazily when a client first attaches, so a dialog that is dismissed without opening the + /// terminal never starts the underlying process. The session is torn down when the interaction completes. + /// + /// + /// This property is experimental and exposes a Hex1b type directly. See the note in Aspire.Hosting.csproj. + /// + /// + [AspireExportIgnore(Reason = "Hex1bTerminalBuilder is a live builder object owning a local process; it cannot be serialized to polyglot app hosts.")] + public Hex1bTerminalBuilder? Terminal { get; init; } } /// @@ -803,7 +825,15 @@ public enum InputType /// /// A file input. Allows the user to select a file using the OS/browser file picker. /// - File + File, + /// + /// An interactive terminal. Renders a terminal that is attached to a session owned by the AppHost. + /// + /// + /// This input type is experimental. The terminal session is configured through + /// . + /// + Terminal } /// diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 407b788b226..411cf90109e 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -28,14 +28,16 @@ internal class InteractionService : IInteractionService private readonly IServiceProvider _serviceProvider; private readonly IConfiguration _configuration; private readonly IInteractionFileUploadStore _fileUploadStore; + private readonly IInteractionTerminalSessionStore _terminalSessionStore; - public InteractionService(ILogger logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider, IConfiguration configuration, IInteractionFileUploadStore fileUploadStore) + public InteractionService(ILogger logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider, IConfiguration configuration, IInteractionFileUploadStore fileUploadStore, IInteractionTerminalSessionStore terminalSessionStore) { _logger = logger; _distributedApplicationOptions = distributedApplicationOptions; _serviceProvider = serviceProvider; _configuration = configuration; _fileUploadStore = fileUploadStore; + _terminalSessionStore = terminalSessionStore; } public bool IsAvailable @@ -160,11 +162,17 @@ public async Task> PromptInputsAsy // Create the collection early to validate names and generate missing ones var inputCollection = new InteractionInputCollection(inputs); var hasFileInputs = inputs.Any(input => input.InputType == InputType.File); + var hasTerminalInputs = inputs.Any(input => input.InputType == InputType.Terminal); // Validate inputs. for (var i = 0; i < inputs.Count; i++) { var input = inputs[i]; + if (input.InputType == InputType.Terminal && input.Terminal is null) + { + throw new InvalidOperationException($"The input '{input.Name}' is a {nameof(InputType.Terminal)} input but does not set {nameof(InteractionInput.Terminal)}."); + } + if (input.DynamicLoading is { } dynamic) { if (dynamic.DependsOnInputs != null) @@ -201,6 +209,14 @@ public async Task> PromptInputsAsy .ToArray(); _fileUploadStore.StartInteraction(newState.InteractionId, fileInputs); } + if (hasTerminalInputs) + { + var terminalInputs = inputs + .Where(input => input.InputType == InputType.Terminal) + .Select(input => (input.Name, Builder: input.Terminal!)) + .ToArray(); + _terminalSessionStore.StartInteraction(newState.InteractionId, terminalInputs); + } AddInteractionUpdate(newState); using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState); @@ -527,6 +543,21 @@ private void CompleteInteractionCore(Interaction interactionState, InteractionCo } } + // Terminal sessions are torn down on both paths — unlike uploaded files, nothing survives the interaction + // for the caller to consume, so a completed dialog must still stop the workload. + if (interactionState.InteractionInfo is Interaction.InputsInteractionInfo terminalInputsInfo && + terminalInputsInfo.Inputs.Any(input => input.InputType == InputType.Terminal)) + { + if (completion.State is IReadOnlyList) + { + _terminalSessionStore.CompleteInteraction(interactionState.InteractionId); + } + else + { + _terminalSessionStore.CancelInteraction(interactionState.InteractionId); + } + } + interactionState.State = Interaction.InteractionState.Complete; interactionState.CompletionTcs.TrySetResult(completion); _interactionCollection.Remove(interactionState.InteractionId); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index e37560b5dc0..64eca71f62f 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -50,6 +50,7 @@ public MockDashboardClient(IReadOnlyList? resources = null) public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index 80e696dbfa8..7df92f569b8 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -694,6 +694,7 @@ private sealed class MockDashboardClient(Task sub public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); public ResourceViewModel? GetResource(string resourceName) => null; public IReadOnlyList GetResources() => []; public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); diff --git a/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs index 255c0edc1cd..d2f24a07681 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs @@ -151,6 +151,7 @@ private sealed class DisabledDashboardClient : IDashboardClient public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ClearConsoleLogsAsync(IReadOnlyList resourceNames, DateTime clearDate) => Task.CompletedTask; diff --git a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj index da7ef240724..3e77920f6d4 100644 --- a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj +++ b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj @@ -56,6 +56,7 @@ + diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs index 25ad7af2d54..a95890a6bfd 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs @@ -198,7 +198,8 @@ private static (DashboardServiceData Data, ResourceNotificationService Notificat new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); var data = new DashboardServiceData( notifications, loggerService, diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index cd48e683864..daa6a3d3877 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -471,7 +471,8 @@ public async Task WatchInteractions_PromptMessageBoxAsync_CompleteOnResponse(boo new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -542,7 +543,8 @@ public async Task WatchInteractions_NoExplicitLabel_LabelIsName() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -590,7 +592,8 @@ public async Task WatchInteractions_PromptInputAsync_CompleteOnCancelResponse() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -650,7 +653,8 @@ public async Task WatchInteractions_ReaderError_CompleteWithError() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -688,7 +692,8 @@ public async Task WatchInteractions_WriterError_CompleteWithError() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -1073,7 +1078,8 @@ public async Task SendInteractionRequestAsync_ClientFileTypeForTextInput_DoesNot new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - fileUploadStore); + fileUploadStore, + new TestInteractionTerminalSessionStore()); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var fileInput = new InteractionInput { Name = "File", InputType = InputType.File }; var textInput = new InteractionInput { Name = "Text", InputType = InputType.Text }; @@ -1112,7 +1118,8 @@ public async Task SendInteractionRequestAsync_UsesAuthoritativeFilesAndDisposeDe new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - fileUploadStore); + fileUploadStore, + new TestInteractionTerminalSessionStore()); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var input = new InteractionInput { Name = "File", InputType = InputType.File, Required = true, AllowMultipleFiles = true }; var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input); @@ -1176,7 +1183,8 @@ public async Task SendInteractionRequestAsync_MismatchedFiles_Throws() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - fileUploadStore); + fileUploadStore, + new TestInteractionTerminalSessionStore()); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var input = new InteractionInput { Name = "File", InputType = InputType.File }; var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input); @@ -1267,7 +1275,8 @@ private static DashboardServiceImpl CreateDashboardService( IHostEnvironment? hostEnvironment = null, IConfiguration? configuration = null, ILogger? logger = null, - IInteractionFileUploadStore? fileUploadStore = null) + IInteractionFileUploadStore? fileUploadStore = null, + IInteractionTerminalSessionStore? terminalSessionStore = null) { return new DashboardServiceImpl( dashboardServiceData, @@ -1275,7 +1284,8 @@ private static DashboardServiceImpl CreateDashboardService( new TestHostApplicationLifetime(), configuration ?? new ConfigurationBuilder().Build(), logger ?? NullLogger.Instance, - fileUploadStore ?? new TestInteractionFileUploadStore()); + fileUploadStore ?? new TestInteractionFileUploadStore(), + terminalSessionStore ?? new TestInteractionTerminalSessionStore()); } private static DashboardServiceData CreateDashboardServiceData( @@ -1294,7 +1304,8 @@ private static DashboardServiceData CreateDashboardServiceData( new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - fileUploadStore); + fileUploadStore, + new TestInteractionTerminalSessionStore()); return new DashboardServiceData( resourceNotificationService, diff --git a/tests/Aspire.Hosting.Tests/Dashboard/GrpcTerminalStreamTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/GrpcTerminalStreamTests.cs new file mode 100644 index 00000000000..ec679794d58 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Dashboard/GrpcTerminalStreamTests.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.DashboardService.Proto.V1; +using Aspire.Hosting.Dashboard; +using Aspire.Hosting.Tests.Utils.Grpc; +using Google.Protobuf; + +namespace Aspire.Hosting.Tests.Dashboard; + +public class GrpcTerminalStreamTests +{ + [Fact] + public async Task ReadAsync_SplitsSingleFrameAcrossReads() + { + var context = TestServerCallContext.Create(); + var requestStream = new TestAsyncStreamReader(context); + var responseStream = new TestServerStreamWriter(context); + await using var stream = new GrpcTerminalStream(requestStream, responseStream); + + requestStream.AddMessage(new TerminalClientFrame { Data = ByteString.CopyFrom("hello"u8.ToArray()) }); + + var buffer = new byte[2]; + + Assert.Equal(2, await stream.ReadAsync(buffer)); + Assert.Equal("he"u8.ToArray(), buffer); + + Assert.Equal(2, await stream.ReadAsync(buffer)); + Assert.Equal("ll"u8.ToArray(), buffer); + + Assert.Equal(1, await stream.ReadAsync(buffer)); + Assert.Equal("o"u8.ToArray(), buffer[..1]); + } + + [Fact] + public async Task ReadAsync_SkipsEmptyFramesWithoutSignallingEndOfStream() + { + var context = TestServerCallContext.Create(); + var requestStream = new TestAsyncStreamReader(context); + var responseStream = new TestServerStreamWriter(context); + await using var stream = new GrpcTerminalStream(requestStream, responseStream); + + requestStream.AddMessage(new TerminalClientFrame()); + requestStream.AddMessage(new TerminalClientFrame { Data = ByteString.CopyFrom("x"u8.ToArray()) }); + + var buffer = new byte[8]; + + Assert.Equal(1, await stream.ReadAsync(buffer)); + Assert.Equal((byte)'x', buffer[0]); + } + + [Fact] + public async Task ReadAsync_ReturnsZeroWhenRequestStreamCompletes() + { + var context = TestServerCallContext.Create(); + var requestStream = new TestAsyncStreamReader(context); + var responseStream = new TestServerStreamWriter(context); + await using var stream = new GrpcTerminalStream(requestStream, responseStream); + + requestStream.Complete(); + + Assert.Equal(0, await stream.ReadAsync(new byte[8])); + // A second read must stay at end of stream rather than pulling on the completed reader again. + Assert.Equal(0, await stream.ReadAsync(new byte[8])); + } + + [Fact] + public async Task WriteAsync_CopiesBufferSoCallerCanReuseIt() + { + var context = TestServerCallContext.Create(); + var requestStream = new TestAsyncStreamReader(context); + var responseStream = new TestServerStreamWriter(context); + await using var stream = new GrpcTerminalStream(requestStream, responseStream); + + var buffer = "ok"u8.ToArray(); + await stream.WriteAsync(buffer); + buffer[0] = (byte)'X'; + + var frame = await responseStream.ReadNextAsync(); + Assert.Equal("ok", frame.Data.ToStringUtf8()); + } + + [Fact] + public async Task WriteAsync_EmptyBufferDoesNotProduceFrame() + { + var context = TestServerCallContext.Create(); + var requestStream = new TestAsyncStreamReader(context); + var responseStream = new TestServerStreamWriter(context); + await using var stream = new GrpcTerminalStream(requestStream, responseStream); + + await stream.WriteAsync(ReadOnlyMemory.Empty); + await stream.WriteAsync("data"u8.ToArray()); + + var frame = await responseStream.ReadNextAsync(); + Assert.Equal("data", frame.Data.ToStringUtf8()); + } +} diff --git a/tests/Aspire.Hosting.Tests/Dashboard/InteractionTerminalSessionStoreTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/InteractionTerminalSessionStoreTests.cs new file mode 100644 index 00000000000..47e7544b48f --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Dashboard/InteractionTerminalSessionStoreTests.cs @@ -0,0 +1,209 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipelines; +using Aspire.Hosting.Dashboard; +using Hex1b; +using Hex1b.Automation; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests.Dashboard; + +public class InteractionTerminalSessionStoreTests +{ + private const int InteractionId = 42; + private const string InputName = "shell"; + + [Fact] + public async Task AttachAsync_UnknownInteraction_Throws() + { + using var store = CreateStore(); + + var ex = await Assert.ThrowsAsync( + () => store.AttachAsync(InteractionId, InputName, Stream.Null, CancellationToken.None)); + Assert.Contains("does not have a terminal input", ex.Message); + } + + [Fact] + public async Task AttachAsync_UnknownInput_Throws() + { + using var store = CreateStore(); + store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("true"))]); + + var ex = await Assert.ThrowsAsync( + () => store.AttachAsync(InteractionId, "other", Stream.Null, CancellationToken.None)); + Assert.Contains("does not have a terminal input", ex.Message); + } + + [Fact] + public async Task AttachAsync_AfterInteractionCompleted_Throws() + { + using var store = CreateStore(); + store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("true"))]); + store.CompleteInteraction(InteractionId); + + // The interaction is no longer tracked at all, so this fails the same way an unknown interaction does. + await Assert.ThrowsAsync( + () => store.AttachAsync(InteractionId, InputName, Stream.Null, CancellationToken.None)); + } + + [Fact] + public void StartInteraction_NeverAttached_TearsDownWithoutStartingWorkload() + { + using var store = CreateStore(); + store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("exit 7"))]); + + // No client ever attached, so no terminal was built and teardown must not hang or throw. + store.CancelInteraction(InteractionId); + } + + [Fact] + public async Task AttachAsync_ServesWorkloadOutputOverStream() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Uses /bin/sh to produce deterministic workload output."); + + using var store = CreateStore(); + store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("echo aspire-terminal-ok; read line"))]); + + var (serverSide, clientSide) = CreateDuplexPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + + var attachTask = store.AttachAsync(InteractionId, InputName, serverSide, cts.Token); + + await using var client = CreateClientTerminal(clientSide); + var clientRunTask = client.RunAsync(cts.Token); + + var automator = new Hex1bTerminalAutomator(client, TimeSpan.FromSeconds(60)); + await automator.WaitUntilAsync( + snapshot => snapshot.GetText().Contains("aspire-terminal-ok", StringComparison.Ordinal), + description: "workload output rendered on the client terminal"); + + // Tearing down the interaction must release the attached transport rather than stranding it. + store.CompleteInteraction(InteractionId); + await attachTask.WaitAsync(cts.Token); + + // Mirrors AttachTerminal, which disposes the tunnel stream once the attach completes. Without this the client + // has no way to observe that the session is gone. + serverSide.Dispose(); + + await IgnoreShutdownAsync(clientRunTask); + } + + [Fact] + public async Task AttachAsync_WorkloadExit_ReleasesAttachedClient() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Uses /bin/sh to produce a workload that exits on its own."); + + using var store = CreateStore(); + store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("exit 0"))]); + + var (serverSide, clientSide) = CreateDuplexPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + + var attachTask = store.AttachAsync(InteractionId, InputName, serverSide, cts.Token); + + await using var client = CreateClientTerminal(clientSide); + var clientRunTask = client.RunAsync(cts.Token); + + // The workload exits immediately, so the attach must complete without the interaction being torn down. + await attachTask.WaitAsync(cts.Token); + + serverSide.Dispose(); + + await IgnoreShutdownAsync(clientRunTask); + } + + private static InteractionTerminalSessionStore CreateStore() + => new(NullLogger.Instance); + + /// + /// Builds the AppHost-side terminal exactly as a caller would: workload only, no transport. The store attaches the + /// HMP1 server itself, which is the split the interaction input depends on. + /// + private static Hex1bTerminalBuilder CreateServerBuilder(string shellCommand) + { + return Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(80, 24) + .WithPtyProcess("/bin/sh", ["-c", shellCommand]); + } + + /// + /// Builds a real HMP1 client terminal on the far end of the tunnel, standing in for the dashboard's xterm.js client. + /// + private static Hex1bTerminal CreateClientTerminal(Stream clientSide) + { + return Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(80, 24) + .WithHmp1Client(_ => Task.FromResult(clientSide)) + .Build(); + } + + /// + /// Creates two streams wired back to back, standing in for the gRPC tunnel: what one end writes the other reads. + /// + private static (Stream ServerSide, Stream ClientSide) CreateDuplexPair() + { + var serverToClient = new Pipe(); + var clientToServer = new Pipe(); + + var serverSide = new DuplexStream(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()); + var clientSide = new DuplexStream(serverToClient.Reader.AsStream(), clientToServer.Writer.AsStream()); + return (serverSide, clientSide); + } + + private static async Task IgnoreShutdownAsync(Task clientRunTask) + { + try + { + await clientRunTask; + } + catch (Exception) + { + // The client terminal is torn down by the server closing the tunnel; how that surfaces is not under test. + } + } + + private sealed class DuplexStream(Stream reader, Stream writer) : Stream + { + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => reader.ReadAsync(buffer, cancellationToken); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => writer.WriteAsync(buffer, cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => reader.Read(buffer, offset, count); + + public override void Write(byte[] buffer, int offset, int count) => writer.Write(buffer, offset, count); + + public override void Flush() => writer.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => writer.FlushAsync(cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + reader.Dispose(); + writer.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs index 9a121c41687..352cd771503 100644 --- a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs @@ -220,7 +220,8 @@ public void IsAvailable_InteractivityEnabledConfigured_ReturnsExpectedValue(stri new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), configuration, - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); // Assert Assert.Equal(expected, interactionService.IsAvailable); @@ -248,7 +249,8 @@ public void IsAvailable_InteractivityEnabledInvalidValue_ReturnsTrue(string conf new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), configuration, - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); // Assert - Invalid values should be ignored, defaulting to true (since dashboard is enabled) Assert.True(interactionService.IsAvailable); @@ -271,7 +273,8 @@ public void IsAvailable_InteractivityDisabledAndDashboardDisabled_ReturnsFalse() new DistributedApplicationOptions { DisableDashboard = true }, new ServiceCollection().BuildServiceProvider(), configuration, - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); // Assert - Both conditions should result in false Assert.False(interactionService.IsAvailable); @@ -1340,7 +1343,8 @@ private static InteractionService CreateInteractionService(DistributedApplicatio options ?? new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), configuration, - fileUploadStore ?? new TestInteractionFileUploadStore()); + fileUploadStore ?? new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index 620b91afe45..8128719f6e6 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -1158,7 +1158,8 @@ private static InteractionService CreateInteractionService(DistributedApplicatio options ?? new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); } private sealed class MockDeploymentStateManager : IDeploymentStateManager diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs index efc9397998a..d71441e0f93 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs @@ -1294,7 +1294,8 @@ private static InteractionService CreateInteractionService(bool disableDashboard new DistributedApplicationOptions { DisableDashboard = disableDashboard }, new ServiceCollection().BuildServiceProvider(), new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); + new TestInteractionFileUploadStore(), + new TestInteractionTerminalSessionStore()); } private sealed class MockDeploymentStateManager : IDeploymentStateManager diff --git a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs index 827babf1cb6..25a6f8df655 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs @@ -1400,6 +1400,6 @@ internal static InteractionService CreateInteractionService() var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(); - return new InteractionService(logger, new DistributedApplicationOptions(), provider, configuration, new TestInteractionFileUploadStore()); + return new InteractionService(logger, new DistributedApplicationOptions(), provider, configuration, new TestInteractionFileUploadStore(), new TestInteractionTerminalSessionStore()); } } diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index ee52aa9a699..a5d47db3686 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -85,6 +85,11 @@ public Task UploadFileAsync(Stream fileStream, string fileName, long exp return Task.FromResult(Guid.NewGuid().ToString("N")); } + public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + public async IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { if (_consoleLogsChannelProvider == null) diff --git a/tests/Shared/TestInteractionTerminalSessionStore.cs b/tests/Shared/TestInteractionTerminalSessionStore.cs new file mode 100644 index 00000000000..fdad35c379f --- /dev/null +++ b/tests/Shared/TestInteractionTerminalSessionStore.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using Hex1b; + +namespace Aspire.Hosting.Utils; + +/// +/// An in-memory implementation of for tests. +/// Records lifecycle calls and never starts a real terminal workload. +/// +internal sealed class TestInteractionTerminalSessionStore : IInteractionTerminalSessionStore +{ + public ConcurrentQueue StartedInteractions { get; } = new(); + public ConcurrentQueue> StartedTerminalInputs { get; } = new(); + public ConcurrentQueue CompletedInteractions { get; } = new(); + public ConcurrentQueue CanceledInteractions { get; } = new(); + + public void StartInteraction(int interactionId, IReadOnlyList<(string InputName, Hex1bTerminalBuilder Builder)> terminalInputs) + { + StartedInteractions.Enqueue(interactionId); + StartedTerminalInputs.Enqueue(terminalInputs.ToArray()); + } + + public Task AttachAsync(int interactionId, string inputName, Stream clientStream, CancellationToken cancellationToken) + { + // Tests that exercise attach do so against the real store; this fake only needs to satisfy the contract. + return Task.CompletedTask; + } + + public void CompleteInteraction(int interactionId) => CompletedInteractions.Enqueue(interactionId); + + public void CancelInteraction(int interactionId) => CanceledInteractions.Enqueue(interactionId); +} From ac6e6dca904830a3d497d7c6af17ed3671a8e23d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 13:17:10 +1000 Subject: [PATCH 002/106] Spike: dashboard terminal dock and AppHost TerminalService Generalises the spike-1 interaction terminal into an AppHost-owned terminal service, then builds a dashboard-wide terminal dock on top of it. Aspire.Hosting - New Terminals/ namespace: IAspireTerminal (internal abstraction that keeps Hex1b out of the Aspire API surface), TerminalService (registry + change fan-out), Hex1bAspireTerminal, TerminalLaunchOptions, TerminalSurface, AspireTerminalKey, TerminalChange. - Deletes InteractionTerminalSessionStore/IInteractionTerminalSessionStore; the interaction service is now just one consumer of TerminalService. - Terminals are addressed by opaque ids instead of (interactionId, inputName), so dock tabs and interaction inputs share one tunnel and one websocket endpoint (/api/apphost-terminal?terminalId=...). - Fixes the teardown race flagged in PR feedback by splitting workload cancellation from session completion, so AttachAsync waiters are not released before DisposeAsync finishes. - WatchTerminals / CreateDockTerminal / CloseTerminal RPCs for dock discovery and lifecycle. - Minimal automation API (SendTextAsync, SendKeyAsync, WaitForTextAsync, GetScreenText) deliberately excluding Hex1b's cell-pattern DSL. Aspire.Dashboard - TerminalDock component: tabbed, resizable, collapsible bottom panel. Collapse hides rather than unmounts so xterm cell metrics and websockets survive, and terminal state lives AppHost-side so it survives browser sessions entirely. - Toggle via a terminal button in the header icon cluster, plus Ctrl+` (the shortcut check is hoisted above the input-focus guard and xterm's key handler so a focused terminal cannot swallow it). - The watch stream starts eagerly so IAspireTerminal.Show() can reveal the dock in a browser that has never opened it. Playground - Terminals.AppHost gains a "Shell into container (terminal dock)" command on shellbox that creates a dock terminal from AppHost code, calls Show(), and exercises the automation API end to end. TerminalService stays internal for this spike. The terminal host used by DCP-owned processes is untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Terminals/Terminals.AppHost/AppHost.cs | 4 +- .../TerminalInteractionCommands.cs | 50 +++ .../Terminals.AppHost.csproj | 6 +- .../Components/Controls/TerminalView.razor.cs | 2 +- .../Components/Controls/TerminalView.razor.js | 10 + .../Dialogs/InteractionsInputDialog.razor.cs | 4 +- .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.cs | 5 + .../Components/Layout/TerminalDock.razor | 59 ++++ .../Components/Layout/TerminalDock.razor.cs | 301 ++++++++++++++++++ .../Components/Layout/TerminalDock.razor.css | 98 ++++++ .../Components/Layout/TerminalDock.razor.js | 47 +++ .../Model/IGlobalKeydownListener.cs | 2 + .../Resources/Layout.Designer.cs | 45 +++ src/Aspire.Dashboard/Resources/Layout.resx | 15 + .../Resources/xlf/Layout.cs.xlf | 25 ++ .../Resources/xlf/Layout.de.xlf | 25 ++ .../Resources/xlf/Layout.es.xlf | 25 ++ .../Resources/xlf/Layout.fr.xlf | 25 ++ .../Resources/xlf/Layout.it.xlf | 25 ++ .../Resources/xlf/Layout.ja.xlf | 25 ++ .../Resources/xlf/Layout.ko.xlf | 25 ++ .../Resources/xlf/Layout.pl.xlf | 25 ++ .../Resources/xlf/Layout.pt-BR.xlf | 25 ++ .../Resources/xlf/Layout.ru.xlf | 25 ++ .../Resources/xlf/Layout.tr.xlf | 25 ++ .../Resources/xlf/Layout.zh-Hans.xlf | 25 ++ .../Resources/xlf/Layout.zh-Hant.xlf | 25 ++ .../ServiceClient/DashboardClient.cs | 46 ++- .../ServiceClient/GrpcTerminalClientStream.cs | 14 +- .../ServiceClient/IDashboardClient.cs | 21 +- .../ServiceClient/SelectedDashboardClient.cs | 25 +- .../Terminal/TerminalWebSocketProxy.cs | 53 ++- src/Aspire.Dashboard/wwwroot/js/app.js | 13 + src/Aspire.Hosting/Aspire.Hosting.csproj | 3 + .../Dashboard/DashboardService.cs | 113 ++++++- .../Dashboard/DashboardServiceHost.cs | 4 +- .../IInteractionTerminalSessionStore.cs | 41 --- .../InteractionTerminalSessionStore.cs | 227 ------------- .../Dashboard/proto/dashboard_service.proto | 77 ++++- .../DistributedApplicationBuilder.cs | 3 +- src/Aspire.Hosting/IInteractionService.cs | 9 + src/Aspire.Hosting/InteractionService.cs | 39 ++- .../Terminals/AspireTerminalKey.cs | 75 +++++ .../Terminals/Hex1bAspireTerminal.cs | 296 +++++++++++++++++ .../Terminals/IAspireTerminal.cs | 75 +++++ .../Terminals/IDockTerminalFactory.cs | 21 ++ .../PlaceholderDockTerminalFactory.cs | 70 ++++ .../Terminals/TerminalChange.cs | 35 ++ .../Terminals/TerminalLaunchOptions.cs | 34 ++ .../Terminals/TerminalService.cs | 245 ++++++++++++++ .../Terminals/TerminalSurface.cs | 22 ++ ...TwoPassScanningGeneratedAspire.verified.go | 1 + ...oPassScanningGeneratedAspire.verified.java | 3 +- ...TwoPassScanningGeneratedAspire.verified.py | 2 +- ...TwoPassScanningGeneratedAspire.verified.rs | 3 + .../Aspire.Hosting.Tests.csproj | 2 +- .../DashboardServiceDataTerminalTests.cs | 2 +- .../Dashboard/DashboardServiceTests.cs | 22 +- .../InteractionTerminalSessionStoreTests.cs | 209 ------------ .../InteractionServiceTests.cs | 8 +- .../ApplicationOrchestratorTests.cs | 2 +- .../Orchestrator/ParameterProcessorTests.cs | 2 +- .../PipelineActivityReporterTests.cs | 2 +- .../TestInteractionTerminalSessionStore.cs | 35 -- tests/Shared/TestTerminalService.cs | 24 ++ 66 files changed, 2234 insertions(+), 626 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Layout/TerminalDock.razor create mode 100644 src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css create mode 100644 src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js delete mode 100644 src/Aspire.Hosting/Dashboard/IInteractionTerminalSessionStore.cs delete mode 100644 src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs create mode 100644 src/Aspire.Hosting/Terminals/AspireTerminalKey.cs create mode 100644 src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs create mode 100644 src/Aspire.Hosting/Terminals/IAspireTerminal.cs create mode 100644 src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs create mode 100644 src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs create mode 100644 src/Aspire.Hosting/Terminals/TerminalChange.cs create mode 100644 src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs create mode 100644 src/Aspire.Hosting/Terminals/TerminalService.cs create mode 100644 src/Aspire.Hosting/Terminals/TerminalSurface.cs delete mode 100644 tests/Aspire.Hosting.Tests/Dashboard/InteractionTerminalSessionStoreTests.cs delete mode 100644 tests/Shared/TestInteractionTerminalSessionStore.cs create mode 100644 tests/Shared/TestTerminalService.cs diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index 2643b380e4f..2ecc176abbe 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -31,7 +31,9 @@ builder.AddContainer("shellbox", "alpine") .WithContainerName("terminals-playground-shellbox") .WithArgs("sleep", "infinity") - .WithContainerShellCommand(); + .WithContainerShellCommand() + // Same shell, but delivered as a tab in the dashboard's terminal dock (Ctrl+`) rather than a modal dialog. + .WithDockShellCommand(); // Latest Node.js image, kept alive so the "Node REPL" interaction command can exec into it. The Node REPL is a // readline app, so it exercises cursor addressing, history, and tab completion across the tunnel in a way a plain diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 99b3aff15ab..6d1e7c8f5d0 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Terminals; using Hex1b; using Microsoft.Extensions.DependencyInjection; @@ -143,6 +144,55 @@ private static async Task ExecIntoContainerAsync( : CommandResults.Success(); } + /// + /// Adds a command that opens a dock terminal shelled into this container and drives it with the automation API. + /// + /// + /// This is the counterpart to the interaction-input commands above. Instead of a modal dialog bound to a single + /// dialog lifetime, the terminal becomes a tab in the dashboard's terminal dock (Ctrl+`) that outlives the command + /// that created it. It also exercises IAspireTerminal's automation surface — send input, wait for output, + /// read the screen — which is how AppHost code can script a terminal it owns. + /// + [AspireExportIgnore(Reason = "Uses TerminalService, an internal API, and command handlers that are not ATS-compatible.")] + public static IResourceBuilder WithDockShellCommand(this IResourceBuilder container) + { + return container.WithCommand( + "terminal-dock-shell", + "Shell into container (terminal dock)", + executeCommand: async commandContext => + { + var containerName = ResolveContainerName(container.Resource); + var terminalService = commandContext.Services.GetRequiredService(); + + // Not disposed here on purpose: the tab is meant to outlive the command. The user closes it from the + // dock, and TerminalService tears down anything still open when the AppHost shuts down. + var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + { + Title = container.Resource.Name, + Builder = Hex1bTerminal.CreateBuilder() + .WithDimensions(120, 32) + .WithPtyProcess("docker", ["exec", "-it", containerName, "/bin/sh"]) + }); + + // Reveals the dock in every connected browser and switches it to this tab. + terminal.Show(); + + try + { + // Automation: type a command and wait for its output. The workload starts on the first automation + // call even if nobody has attached a browser yet. + await terminal.SendTextAsync("echo aspire-dock-ready\r", commandContext.CancellationToken); + await terminal.WaitForTextAsync("aspire-dock-ready", TimeSpan.FromSeconds(10), commandContext.CancellationToken); + } + catch (TimeoutException) + { + return CommandResults.Failure("Terminal did not respond to automated input."); + } + + return CommandResults.Success(); + }); + } + /// /// Resolves the name docker knows this container by. /// diff --git a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj index 5ecd3e29b63..41dc27d9755 100644 --- a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj +++ b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj @@ -8,9 +8,9 @@ true - - - + diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index c7d35cc4343..913fc21e126 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -64,7 +64,7 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable /// /// /// Used for terminals that are not backed by a resource replica — currently terminal-typed interaction inputs, - /// whose process is owned by the AppHost and reached via /api/interaction-terminal. + /// whose process is owned by the AppHost and reached via /api/apphost-terminal. /// [Parameter] public string? EndpointPathAndQuery { get; set; } diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index df8597bad58..043f19b6733 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -1051,6 +1051,16 @@ export async function initTerminal(element, wsUrl, dotNetRef) { term.loadAddon(fitAddon); term.open(state.terminalBody); + // Let Ctrl+` reach the document so the global keydown listener can toggle the terminal dock. Returning false + // tells xterm not to handle the event; without this xterm swallows it and the dock cannot be closed from a + // focused terminal. Everything else is still handled by xterm as usual. + term.attachCustomKeyEventHandler((e) => { + if (e.ctrlKey && !e.altKey && !e.metaKey && (e.key === '`' || e.key === '~' || e.code === 'Backquote')) { + return false; + } + return true; + }); + state.term = term; state.fitAddon = fitAddon; diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs index 59303aeda51..1c733d3f257 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs @@ -277,9 +277,9 @@ private async Task ToggleSecretTextVisibilityAsync(InputViewModel inputModel) /// terminal sessions by interaction id and input name, so both travel in the query string; the dashboard resolves /// them into an AttachTerminal gRPC call server-side. /// - private string BuildInteractionTerminalEndpoint(InputViewModel inputModel) + private static string BuildInteractionTerminalEndpoint(InputViewModel inputModel) { - return $"/api/interaction-terminal?interactionId={Content.Interaction.InteractionId.ToString(CultureInfo.InvariantCulture)}&input={Uri.EscapeDataString(inputModel.Input.Name)}"; + return $"/api/apphost-terminal?terminalId={Uri.EscapeDataString(inputModel.Input.TerminalId ?? string.Empty)}"; } private static Icon GetSecretTextIcon(InputViewModel inputModel) diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index ee50011e687..659ebbb19d4 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -52,6 +52,14 @@ } + + @* WindowConsole only ships at Size20 in this Fluent version, so it is scaled to 24px to line up with + the rest of the header cluster. The Size24 alternatives (WindowDevTools, Code) read as "developer + tools" rather than "terminal". *@ + + +
@Loc[nameof(Layout.MainLayoutUnhandledErrorMessage)] diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 5bcfda9bde5..46742207e48 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -18,6 +18,8 @@ namespace Aspire.Dashboard.Components.Layout; public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable { private bool _isNavMenuOpen; + + private TerminalDock? _terminalDock; private bool _runSelectionChanged; private bool _isSwitchingRuns; @@ -533,4 +535,7 @@ public async ValueTask DisposeAsync() await JSInteropHelpers.SafeDisposeAsync(_jsModule); await JSInteropHelpers.SafeDisposeAsync(_keyboardHandlers); } + + private Task ToggleTerminalDockAsync() + => _terminalDock?.ToggleAsync() ?? Task.CompletedTask; } diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor new file mode 100644 index 00000000000..293d29de04c --- /dev/null +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -0,0 +1,59 @@ +@namespace Aspire.Dashboard.Components.Layout +@using Aspire.Dashboard.Components.Controls +@using Microsoft.FluentUI.AspNetCore.Components + +@* The dock is always rendered once it has been opened for the first time. Collapsing translates it off-screen + rather than unmounting it so xterm keeps its buffer, its measured cell metrics, and its WebSocket. *@ +@if (_hasBeenOpened) +{ +
+
+
+ @foreach (var terminal in _terminals) + { +
+ @terminal.Title + + + +
+ } + + + +
+ + + +
+
+ @foreach (var terminal in _terminals) + { + @* Inactive panes use visibility rather than display so they keep real dimensions — xterm measures + its grid from the element box, and a display:none pane would refit to zero columns. *@ +
+ +
+ } + @if (_terminals.Count == 0) + { +
@Loc[nameof(Resources.Layout.TerminalDockEmpty)]
+ } +
+
+} diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs new file mode 100644 index 00000000000..8426579d402 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -0,0 +1,301 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.DashboardService.Proto.V1; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Microsoft.JSInterop; + +namespace Aspire.Dashboard.Components.Layout; + +/// +/// A collapsible, tabbed dock of terminals owned by the AppHost process, toggled with Ctrl+`. +/// +/// +/// +/// The dock's chrome (visible/collapsed, which tab is selected) is per-browser-circuit, but the terminals +/// themselves live in the AppHost. Two browsers therefore see the same tabs and the same output, and closing +/// the dock in one browser does not disturb the other or stop any workload. +/// +/// +/// Distinct from resource terminals, which are DCP-owned and reached through the terminal host. +/// +/// +public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener, IAsyncDisposable +{ + private const int DefaultHeightPx = 320; + + private readonly List _terminals = []; + private readonly CancellationTokenSource _cts = new(); + + private bool _hasBeenOpened; + private bool _isVisible; + private string? _activeTerminalId; + private int _heightPx = DefaultHeightPx; + private Task? _watchTask; + private readonly TaskCompletionSource _firstUpdateReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); + private IJSObjectReference? _jsModule; + private DotNetObjectReference? _selfRef; + private ElementReference _dockElement; + + [Inject] + public required IDashboardClient DashboardClient { get; init; } + + [Inject] + public required ShortcutManager ShortcutManager { get; init; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + [Inject] + public required IJSRuntime JS { get; init; } + + public IReadOnlySet SubscribedShortcuts { get; } = new HashSet + { + AspireKeyboardShortcut.ToggleTerminalDock + }; + + protected override void OnInitialized() + { + ShortcutManager.AddGlobalKeydownListener(this); + + // Watched eagerly rather than on first open: an `activated` notification is how AppHost code reveals a + // terminal it created (IAspireTerminal.Show()), and that has to work in a browser that has never opened the + // dock. One idle server stream per circuit is the price of that. + _watchTask = Task.Run(() => WatchTerminalsAsync(_cts.Token), _cts.Token); + } + + public Task OnPageKeyDownAsync(AspireKeyboardShortcut shortcut) + => shortcut == AspireKeyboardShortcut.ToggleTerminalDock ? ToggleAsync() : Task.CompletedTask; + + /// + /// Shows the dock, or hides it if it is already showing. + /// + /// + /// Public so the header button can drive the dock. The keyboard chord alone is not enough: whether + /// Ctrl+` reaches the page depends on the browser, the OS window manager, and any extensions the user has + /// installed, so the dock needs an affordance that cannot be intercepted. + /// + public async Task ToggleAsync() + { + if (_isVisible) + { + Hide(); + return; + } + + try + { + await ShowAsync().ConfigureAwait(true); + } + catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) + { + // The dock is already on screen; it will populate if and when the watch stream recovers. + Logger.LogDebug(ex, "Timed out waiting for the initial terminal list."); + } + } + + private async Task ShowAsync() + { + _hasBeenOpened = true; + _isVisible = true; + StateHasChanged(); + + // Wait for the first update before deciding whether the dock is empty. Terminals live in the AppHost, so a + // dock opened for the first time in a second browser (or after a reload) already has tabs, and creating one + // off a not-yet-populated list would spawn a redundant terminal. + await _firstUpdateReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), _cts.Token).ConfigureAwait(true); + + // First open with nothing running gets the built-in terminal, so the dock is never an empty shell. + if (_terminals.Count == 0) + { + await CreateTerminalAsync().ConfigureAwait(true); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Wiring happens on the render that first materialises the dock element, which is not the component's first + // render — the markup is suppressed until the dock has been opened at least once. + if (_hasBeenOpened && _jsModule is null) + { + _selfRef = DotNetObjectReference.Create(this); + _jsModule = await JS.InvokeAsync("import", "./Components/Layout/TerminalDock.razor.js").ConfigureAwait(true); + await _jsModule.InvokeVoidAsync("registerResizeHandle", _dockElement, _selfRef).ConfigureAwait(true); + } + } + + /// + /// Called from JS while the user drags the dock's top edge. + /// + [JSInvokable] + public Task SetHeightAsync(int heightPx) + { + _heightPx = Math.Clamp(heightPx, 120, 1200); + StateHasChanged(); + return Task.CompletedTask; + } + + private void Hide() + { + _isVisible = false; + StateHasChanged(); + } + + private void Activate(string terminalId) + { + _activeTerminalId = terminalId; + StateHasChanged(); + } + + private async Task CreateTerminalAsync() + { + try + { + var descriptor = await DashboardClient.CreateDockTerminalAsync(title: null, _cts.Token).ConfigureAwait(true); + + // Select eagerly rather than waiting for the watch stream so the new tab is focused immediately even if + // the notification is still in flight. Apply/Activate are both idempotent by terminal id. + Apply(TerminalChangeType.Added, descriptor); + _activeTerminalId = descriptor.TerminalId; + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to create a dock terminal."); + } + } + + private async Task CloseTerminalAsync(string terminalId) + { + try + { + await DashboardClient.CloseTerminalAsync(terminalId, _cts.Token).ConfigureAwait(true); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to close dock terminal {TerminalId}.", terminalId); + } + } + + private async Task WatchTerminalsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var update in DashboardClient.SubscribeTerminalsAsync(cancellationToken).ConfigureAwait(false)) + { + if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Snapshot) + { + _terminals.Clear(); + _terminals.AddRange(update.Snapshot.Terminals); + _activeTerminalId ??= _terminals.FirstOrDefault()?.TerminalId; + } + else if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Change) + { + Apply(update.Change.ChangeType, update.Change.Terminal); + } + + _firstUpdateReceived.TrySetResult(); + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // The component is going away or the circuit disconnected. + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Terminal dock watch stream ended unexpectedly."); + } + finally + { + // Unblocks a concurrent ShowAsync so a broken stream degrades to an empty dock rather than a hang. + _firstUpdateReceived.TrySetResult(); + } + } + + private void Apply(TerminalChangeType changeType, TerminalDescriptor descriptor) + { + var index = _terminals.FindIndex(t => t.TerminalId == descriptor.TerminalId); + + switch (changeType) + { + case TerminalChangeType.Added or TerminalChangeType.Retitled: + if (index >= 0) + { + _terminals[index] = descriptor; + } + else + { + _terminals.Add(descriptor); + } + _activeTerminalId ??= descriptor.TerminalId; + break; + + case TerminalChangeType.Removed: + if (index >= 0) + { + _terminals.RemoveAt(index); + } + if (_activeTerminalId == descriptor.TerminalId) + { + // Fall back to the neighbour that took the closed tab's place, matching editor tab behaviour. + var fallback = Math.Min(index, _terminals.Count - 1); + _activeTerminalId = fallback >= 0 ? _terminals[fallback].TerminalId : null; + } + break; + + case TerminalChangeType.Activated: + // Raised by IAspireTerminal.Show() in the AppHost, so AppHost code can reveal its own terminal. + if (index < 0) + { + _terminals.Add(descriptor); + } + _activeTerminalId = descriptor.TerminalId; + _hasBeenOpened = true; + _isVisible = true; + break; + } + } + + private static string BuildEndpoint(string terminalId) + => $"/api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}"; + + public async ValueTask DisposeAsync() + { + ShortcutManager.RemoveGlobalKeydownListener(this); + + if (_jsModule is { } module) + { + try + { + await module.DisposeAsync().ConfigureAwait(false); + } + catch (JSDisconnectedException) + { + // The circuit is already gone; there is nothing left to clean up on the browser side. + } + } + + _selfRef?.Dispose(); + + await _cts.CancelAsync().ConfigureAwait(false); + + if (_watchTask is { } watchTask) + { + try + { + await watchTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + _cts.Dispose(); + } +} diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css new file mode 100644 index 00000000000..eefe7e9de5a --- /dev/null +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css @@ -0,0 +1,98 @@ +/* The dock is fixed to the viewport bottom and taken out of layout flow so collapsing it cannot reflow the page. + Collapsing translates it fully off-screen instead of hiding it: the element keeps its box dimensions, so xterm's + measured cell metrics and column count stay valid and reopening does not need a re-fit or a repaint. */ +.terminal-dock { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 900; + display: flex; + flex-direction: column; + background-color: #0d1117; + border-top: 1px solid var(--neutral-stroke-divider-rest); + transition: transform 120ms ease-out; +} + +.terminal-dock.visible { + transform: translateY(0); +} + +.terminal-dock.collapsed { + transform: translateY(100%); + pointer-events: none; +} + +.terminal-dock-tabstrip { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 4px; + background-color: #161b22; + border-bottom: 1px solid var(--neutral-stroke-divider-rest); + flex: 0 0 auto; +} + +.terminal-dock-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px 2px 10px; + border-radius: 4px 4px 0 0; + cursor: pointer; + color: #c9d1d9; + font-size: 12px; + max-width: 220px; +} + +.terminal-dock-tab.active { + background-color: #0d1117; + color: #58a6ff; +} + +.terminal-dock-tab-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-dock-filler { + flex: 1 1 auto; +} + +.terminal-dock-body { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +.terminal-dock-pane { + position: absolute; + inset: 0; +} + +/* visibility, not display: an inactive pane must keep real dimensions or xterm refits itself to zero columns. */ +.terminal-dock-pane.inactive { + visibility: hidden; +} + +.terminal-dock-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #8b949e; + font-size: 12px; +} + +.terminal-dock-resize-handle { + flex: 0 0 auto; + height: 6px; + cursor: ns-resize; + background-color: transparent; + touch-action: none; +} + +.terminal-dock-resize-handle:hover { + background-color: var(--accent-fill-rest); +} diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js new file mode 100644 index 00000000000..20ace4c687d --- /dev/null +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js @@ -0,0 +1,47 @@ +// Drag-to-resize for the terminal dock's top edge. +// +// The dock is bottom-anchored (position: fixed; bottom: 0), so a taller dock means a *smaller* Y coordinate for its +// top edge. Height is therefore derived from the pointer's distance to the bottom of the viewport rather than from a +// delta, which keeps the grabber under the cursor even if a frame is dropped. +// +// Pointer capture is used so the drag survives the pointer leaving the 6px grabber, which is otherwise trivially easy +// at normal mouse speeds. + +export function registerResizeHandle(dockElement, dotNetRef) { + const grabber = dockElement.querySelector('.terminal-dock-resize-handle'); + if (!grabber) { + return; + } + + let dragging = false; + + grabber.addEventListener('pointerdown', (e) => { + dragging = true; + grabber.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + + grabber.addEventListener('pointermove', (e) => { + if (!dragging) { + return; + } + + const height = Math.round(window.innerHeight - e.clientY); + dotNetRef.invokeMethodAsync('SetHeightAsync', height); + }); + + const end = (e) => { + if (!dragging) { + return; + } + dragging = false; + try { + grabber.releasePointerCapture(e.pointerId); + } catch { + // The pointer may already have been released by the browser (e.g. the tab lost focus mid-drag). + } + }; + + grabber.addEventListener('pointerup', end); + grabber.addEventListener('pointercancel', end); +} diff --git a/src/Aspire.Dashboard/Model/IGlobalKeydownListener.cs b/src/Aspire.Dashboard/Model/IGlobalKeydownListener.cs index 256e58670ce..19c00a87136 100644 --- a/src/Aspire.Dashboard/Model/IGlobalKeydownListener.cs +++ b/src/Aspire.Dashboard/Model/IGlobalKeydownListener.cs @@ -25,4 +25,6 @@ public enum AspireKeyboardShortcut ResetPanelSize = 320, IncreasePanelSize = 330, DecreasePanelSize = 340, + + ToggleTerminalDock = 400, } diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 85c7d8f71bc..c82065695cf 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -105,6 +105,42 @@ public static string DashboardRunSelectUnpin { } } + /// + /// Looks up a localized string similar to Close terminal. + /// + public static string TerminalDockCloseTab { + get { + return ResourceManager.GetString("TerminalDockCloseTab", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No terminals are open.. + /// + public static string TerminalDockEmpty { + get { + return ResourceManager.GetString("TerminalDockEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Hide terminal panel (Ctrl+`). + /// + public static string TerminalDockHide { + get { + return ResourceManager.GetString("TerminalDockHide", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to New terminal. + /// + public static string TerminalDockNewTerminal { + get { + return ResourceManager.GetString("TerminalDockNewTerminal", resourceCulture); + } + } + /// /// Looks up a localized string similar to Aspire. /// @@ -132,6 +168,15 @@ public static string MainLayoutAspireRepoLink { } } + /// + /// Looks up a localized string similar to Toggle terminal (Ctrl+`). + /// + public static string MainLayoutToggleTerminalDock { + get { + return ResourceManager.GetString("MainLayoutToggleTerminalDock", resourceCulture); + } + } + /// /// Looks up a localized string similar to Settings. /// diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 71fdada3c83..165fc84a2a4 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -123,6 +123,9 @@ Help + + Toggle terminal (Ctrl+`) + Settings @@ -168,6 +171,18 @@ Aspire + + Close terminal + + + No terminals are open. + + + Hide terminal panel (Ctrl+`) + + + New terminal + Untrusted apps can send telemetry to the dashboard. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index f37aeabb244..a85940f02a1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -72,6 +72,11 @@ Nastavení + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. Došlo k neošetřené chybě. @@ -142,6 +147,26 @@ Zobrazit filtry + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 65371fef389..cad7b48f708 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -72,6 +72,11 @@ Einstellungen + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. Es ist ein unbehandelter Fehler aufgetreten. @@ -142,6 +147,26 @@ Filter anzeigen + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index f350e5f2433..c98b5b45701 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -72,6 +72,11 @@ Configuración + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. Se ha producido un error no controlado. @@ -142,6 +147,26 @@ Ver filtros + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 285623dd25d..6c098f7fef7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -72,6 +72,11 @@ Paramètres + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. Une erreur non traitée s’est produite. @@ -142,6 +147,26 @@ Afficher les filtres + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index cfaea90e6cd..25253afef3a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -72,6 +72,11 @@ Impostazioni + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. Si è verificato un errore non gestito. @@ -142,6 +147,26 @@ Visualizza filtri + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index b5211f6200e..f1c7375ed18 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -72,6 +72,11 @@ 設定 + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. ハンドルされないエラーが発生しました。 @@ -142,6 +147,26 @@ フィルターの表示 + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 169f1767a2d..f40b9423d47 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -72,6 +72,11 @@ 설정 + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. 처리되지 않은 오류가 발생했습니다. @@ -142,6 +147,26 @@ 필터 보기 + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 5d55c300631..1d1516708e0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -72,6 +72,11 @@ Ustawienia + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. Wystąpił nieobsługiwany błąd. @@ -142,6 +147,26 @@ Wyświetl filtry + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index 267f4267194..d9b82777eb6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -72,6 +72,11 @@ Configurações + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. Ocorreu um erro sem tratamento. @@ -142,6 +147,26 @@ Exibir filtros + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index f6bd0827c12..5641f0af61b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -72,6 +72,11 @@ Параметры + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. Возникла необрабатываемая ошибка. @@ -142,6 +147,26 @@ Просмотреть фильтры + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 7d3730a8fd3..961a8fc8345 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -72,6 +72,11 @@ Ayarlar + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. İşlenmemiş bir hata oluştu. @@ -142,6 +147,26 @@ Filtreleri görüntüle + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index ae54b45ad84..d3c44d50e23 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -72,6 +72,11 @@ 设置 + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. 出现未处理的错误。 @@ -142,6 +147,26 @@ 查看筛选器 + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 864d2318264..1c11b6b383b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -72,6 +72,11 @@ 設定 + + Toggle terminal (Ctrl+`) + Toggle terminal (Ctrl+`) + + An unhandled error has occurred. 發生未處理的錯誤。 @@ -142,6 +147,26 @@ 檢視篩選 + + Close terminal + Close terminal + + + + No terminals are open. + No terminals are open. + + + + Hide terminal panel (Ctrl+`) + Hide terminal panel (Ctrl+`) + + + + New terminal + New terminal + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 1165aae68d0..f02abd77568 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -1150,7 +1150,49 @@ public async Task UploadFileAsync(Stream fileStream, string fileName, lo return response.FileId; } - public async Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) + public async IAsyncEnumerable SubscribeTerminalsAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + EnsureInitialized(); + + // Unlike resources and interactions, this is not fanned out through a local channel. The dock is a single + // consumer per browser circuit and the update rate is tiny, so a direct server stream per subscriber is both + // simpler and avoids having to replay snapshot state for late subscribers. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); + using var call = _client!.WatchTerminals(new WatchTerminalsRequest(), headers: _headers, cancellationToken: cts.Token); + + await foreach (var update in call.ResponseStream.ReadAllAsync(cts.Token).ConfigureAwait(false)) + { + yield return update; + } + } + + public async Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) + { + EnsureInitialized(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); + var request = new CreateDockTerminalRequest(); + if (!string.IsNullOrWhiteSpace(title)) + { + request.Title = title; + } + + var response = await _client!.CreateDockTerminalAsync(request, headers: _headers, cancellationToken: cts.Token).ConfigureAwait(false); + return response.Terminal; + } + + public async Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) + { + EnsureInitialized(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); + await _client!.CloseTerminalAsync( + new CloseTerminalRequest { TerminalId = terminalId }, + headers: _headers, + cancellationToken: cts.Token).ConfigureAwait(false); + } + + public async Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) { EnsureInitialized(); @@ -1158,7 +1200,7 @@ public async Task AttachInteractionTerminalAsync(int interactionId, stri // token anyway so a dashboard-wide disconnect tears the tunnel down instead of leaking it. var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); var call = _client!.AttachTerminal(headers: _headers, cancellationToken: combinedTokens.Token); - var stream = new GrpcTerminalClientStream(call, interactionId, inputName, combinedTokens); + var stream = new GrpcTerminalClientStream(call, terminalId, combinedTokens); try { diff --git a/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs b/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs index 188188ed264..46d218be86d 100644 --- a/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs +++ b/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs @@ -19,8 +19,7 @@ namespace Aspire.Dashboard.ServiceClient; internal sealed class GrpcTerminalClientStream : Stream { private readonly AsyncDuplexStreamingCall _call; - private readonly int _interactionId; - private readonly string _inputName; + private readonly string _terminalId; // The linked CTS that scopes the call outlives the method that created it, so the stream owns its disposal. private readonly IDisposable? _callScope; // gRPC request streams do not support concurrent writes, and the WebSocket pump is not guaranteed to be the only @@ -32,26 +31,23 @@ internal sealed class GrpcTerminalClientStream : Stream public GrpcTerminalClientStream( AsyncDuplexStreamingCall call, - int interactionId, - string inputName, + string terminalId, IDisposable? callScope = null) { _call = call; - _interactionId = interactionId; - _inputName = inputName; + _terminalId = terminalId; _callScope = callScope; } /// - /// Sends the selector frame that tells the AppHost which interaction input this call is attaching to. The AppHost + /// Sends the selector frame that tells the AppHost which terminal this call is attaching to. The AppHost /// reads exactly one such frame before handing the call to Hex1b, so this must happen before any payload. /// public Task SendSelectorAsync(CancellationToken cancellationToken) { var frame = new TerminalClientFrame { - InteractionId = _interactionId, - InputName = _inputName + TerminalId = _terminalId }; return _call.RequestStream.WriteAsync(frame, cancellationToken); diff --git a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs index 5820e7b8668..50d0dcbc515 100644 --- a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs @@ -73,7 +73,26 @@ public interface IDashboardClient : IResourceRepository, IAsyncDisposable /// The returned stream carries opaque HMP1 frames in both directions. The dashboard relays them verbatim between /// the browser's WebSocket and the AppHost, exactly as it does for resource terminals. /// - Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken); + Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken); + + /// + /// Watches the set of AppHost-owned terminals shown as tabs in the dashboard's terminal dock. + /// + /// + /// The first update is always a snapshot; subsequent updates are individual changes. Interaction terminals are + /// deliberately excluded — they belong to a dialog, not to the dock. + /// + IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken); + + /// + /// Asks the AppHost to create a new dock terminal. + /// + Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken); + + /// + /// Asks the AppHost to close a terminal, terminating its workload. + /// + Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken); } /// diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs index ebeea9e673e..4a6819b0c73 100644 --- a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -70,10 +70,31 @@ public Task UploadFileAsync(Stream fileStream, string fileName, long exp return currentClient.UploadFileAsync(fileStream, fileName, expectedSize, interactionId, inputName, cancellationToken); } - public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) + public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) { EnsureWritable(); - return currentClient.AttachInteractionTerminalAsync(interactionId, inputName, cancellationToken); + return currentClient.AttachTerminalAsync(terminalId, cancellationToken); + } + + public IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken) => + IsReadOnly ? EmptyTerminalsAsync() : currentClient.SubscribeTerminalsAsync(cancellationToken); + + public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) + { + EnsureWritable(); + return currentClient.CreateDockTerminalAsync(title, cancellationToken); + } + + public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) + { + EnsureWritable(); + return currentClient.CloseTerminalAsync(terminalId, cancellationToken); + } + + private static async IAsyncEnumerable EmptyTerminalsAsync() + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; } private void EnsureWritable() diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index 4b1e2813598..ea81e4b52f2 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -88,11 +88,12 @@ public static void MapTerminalWebSocket(this WebApplication app) } }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName); - // Terminal-typed interaction inputs. Unlike /api/terminal — where the process is orchestrated by Aspire and - // hosted out-of-process by Aspire.TerminalHost behind a Unix domain socket — the process here is owned by the - // AppHost itself, so the session is tunneled over the existing dashboard gRPC connection. Everything below the - // stream (this pump, the browser's HMP1 client, xterm.js) is identical; only the transport differs. - app.Map("/api/interaction-terminal", async (HttpContext context, + // Terminals owned by the AppHost process itself — both terminal-typed interaction inputs and the tabs in the + // dashboard's terminal dock. Unlike /api/terminal — where the process is orchestrated by Aspire and hosted + // out-of-process by Aspire.TerminalHost behind a Unix domain socket — the session here is tunneled over the + // existing dashboard gRPC connection. Everything below the stream (this pump, the browser's HMP1 client, + // xterm.js) is identical; only the transport differs. + app.Map("/api/apphost-terminal", async (HttpContext context, IDashboardClient dashboardClient, ILoggerFactory loggerFactory) => { @@ -101,11 +102,11 @@ public static void MapTerminalWebSocket(this WebApplication app) try { - await HandleInteractionAsync(context, dashboardClient, logger, connectionId).ConfigureAwait(false); + await HandleAppHostTerminalAsync(context, dashboardClient, logger, connectionId).ConfigureAwait(false); } catch (Exception ex) { - logger.LogError(ex, "Interaction terminal WebSocket handler {ConnectionId} crashed.", connectionId); + logger.LogError(ex, "AppHost terminal WebSocket handler {ConnectionId} crashed.", connectionId); if (!context.Response.HasStarted) { @@ -122,10 +123,10 @@ public static void MapTerminalWebSocket(this WebApplication app) }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName); } - internal static async Task HandleInteractionAsync(HttpContext context, - IDashboardClient dashboardClient, - ILogger logger, - string connectionId) + internal static async Task HandleAppHostTerminalAsync(HttpContext context, + IDashboardClient dashboardClient, + ILogger logger, + string connectionId) { if (!context.WebSockets.IsWebSocketRequest) { @@ -139,7 +140,7 @@ internal static async Task HandleInteractionAsync(HttpContext context, if (!WebSocketOriginValidator.IsSameOrigin(context, out var originLogValue)) { logger.LogWarning( - "Rejecting interaction terminal WebSocket upgrade {ConnectionId} with disallowed Origin '{Origin}'.", + "Rejecting AppHost terminal WebSocket upgrade {ConnectionId} with disallowed Origin '{Origin}'.", connectionId, originLogValue); context.Response.StatusCode = StatusCodes.Status403Forbidden; @@ -147,33 +148,25 @@ internal static async Task HandleInteractionAsync(HttpContext context, return; } - var interactionIdText = context.Request.Query["interactionId"].ToString(); - var inputName = context.Request.Query["input"].ToString(); + var terminalId = context.Request.Query["terminalId"].ToString(); - if (!int.TryParse(interactionIdText, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var interactionId)) + if (string.IsNullOrWhiteSpace(terminalId)) { context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Missing or invalid 'interactionId' query parameter.").ConfigureAwait(false); + await context.Response.WriteAsync("Missing 'terminalId' query parameter.").ConfigureAwait(false); return; } - if (string.IsNullOrWhiteSpace(inputName)) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Missing 'input' query parameter.").ConfigureAwait(false); - return; - } - - // Open the tunnel before accepting the WebSocket so an unknown interaction/input surfaces as a real HTTP error + // Open the tunnel before accepting the WebSocket so an unknown terminal surfaces as a real HTTP error // instead of a WebSocket that closes immediately for no visible reason. Stream upstream; try { - upstream = await dashboardClient.AttachInteractionTerminalAsync(interactionId, inputName, context.RequestAborted).ConfigureAwait(false); + upstream = await dashboardClient.AttachTerminalAsync(terminalId, context.RequestAborted).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { - logger.LogWarning(ex, "Failed to attach interaction terminal for {InteractionId}/{InputName}.", interactionId, inputName); + logger.LogWarning(ex, "Failed to attach AppHost terminal {TerminalId}.", terminalId); context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; await context.Response.WriteAsync("Terminal is unavailable.").ConfigureAwait(false); return; @@ -186,13 +179,12 @@ internal static async Task HandleInteractionAsync(HttpContext context, } catch (Exception ex) { - logger.LogWarning(ex, "Failed to accept interaction terminal WebSocket for {InteractionId}/{InputName}.", interactionId, inputName); + logger.LogWarning(ex, "Failed to accept AppHost terminal WebSocket for {TerminalId}.", terminalId); try { upstream.Dispose(); } catch { /* swallow */ } return; } - logger.LogInformation("Interaction terminal WS opened for {InteractionId}/{InputName} ({ConnectionId}).", - interactionId, inputName, connectionId); + logger.LogInformation("AppHost terminal WS opened for {TerminalId} ({ConnectionId}).", terminalId, connectionId); try { @@ -202,8 +194,7 @@ internal static async Task HandleInteractionAsync(HttpContext context, { // Disposing ends the gRPC call, which is how the AppHost learns this viewer is gone. try { upstream.Dispose(); } catch { /* swallow */ } - logger.LogInformation("Interaction terminal WS closed for {InteractionId}/{InputName} ({ConnectionId}).", - interactionId, inputName, connectionId); + logger.LogInformation("AppHost terminal WS closed for {TerminalId} ({ConnectionId}).", terminalId, connectionId); } if (ws.State == WebSocketState.Open) diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index 55adc7add15..20e8155499a 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -246,6 +246,12 @@ window.registerGlobalKeydownListener = function (shortcutManager) { return !keyboardEvent.altKey && !keyboardEvent.ctrlKey && !keyboardEvent.metaKey; } + // Ctrl+` toggles the terminal dock. This is the only shortcut that survives a focused input, because the + // terminal itself is a focused input — without this the dock could be opened but never closed from the keyboard. + function isTerminalDockShortcut(e) { + return e.ctrlKey && !e.altKey && !e.metaKey && (e.key === "`" || e.key === "~" || e.code === "Backquote"); + } + function calculateShortcut(e) { if (modifierKeysExceptShiftNotPressed(e)) { /* general shortcuts */ @@ -289,6 +295,13 @@ window.registerGlobalKeydownListener = function (shortcutManager) { } const keydownListener = function (e) { + // Checked before the input guard on purpose: see isTerminalDockShortcut. + if (isTerminalDockShortcut(e)) { + e.preventDefault(); + shortcutManager.invokeMethodAsync('OnGlobalKeyDown', 400); + return; + } + if (isActiveElementInput()) { return; } diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index e02db465bf1..c3b73219835 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -137,6 +137,9 @@ + + diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 033126d304f..71252ac30c9 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -22,8 +22,13 @@ namespace Aspire.Hosting.Dashboard; /// An instance of this type is created for every gRPC service call, so it may not hold onto any state /// required beyond a single request. Longer-scoped data is stored in . /// +/// +/// Types from Aspire.Hosting.Terminals are qualified rather than imported: several of them +/// (TerminalDescriptor, TerminalChangeType) share a name with their generated protobuf +/// counterparts, and importing both namespaces would make every bare use ambiguous. +/// [Authorize(Policy = ResourceServiceApiKeyAuthorization.PolicyName)] -internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime, IConfiguration configuration, ILogger logger, IInteractionFileUploadStore fileUploadStore, IInteractionTerminalSessionStore terminalSessionStore) +internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime, IConfiguration configuration, ILogger logger, IInteractionFileUploadStore fileUploadStore, Terminals.TerminalService terminalService) : Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceBase { // gRPC has a maximum receive size of 4MB. Force logs into batches to avoid exceeding receive size. @@ -254,6 +259,10 @@ internal static Aspire.DashboardService.Proto.V1.InteractionInput CreateInteract { dto.FileFilter = input.FileFilter; } + if (!string.IsNullOrEmpty(input.TerminalId)) + { + dto.TerminalId = input.TerminalId; + } dto.ValidationErrors.AddRange(input.ValidationErrors); return dto; } @@ -600,22 +609,20 @@ public override async Task AttachTerminal( IServerStreamWriter responseStream, ServerCallContext context) { - var cancellationToken = context.CancellationToken; + // Linked with ApplicationStopping so a tunnel that is otherwise idle does not keep shutdown waiting. + using var linked = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, hostApplicationLifetime.ApplicationStopping); + var cancellationToken = linked.Token; - // The first frame selects the session, mirroring how UploadFile carries its metadata on the first chunk. + // The first frame selects the terminal, mirroring how UploadFile carries its metadata on the first chunk. if (!await requestStream.MoveNext(cancellationToken).ConfigureAwait(false)) { throw new RpcException(new Status(StatusCode.InvalidArgument, "Terminal stream is empty.")); } var selector = requestStream.Current; - if (selector.InteractionId <= 0) + if (string.IsNullOrEmpty(selector.TerminalId)) { - throw new RpcException(new Status(StatusCode.InvalidArgument, "First frame must include an interaction ID.")); - } - if (string.IsNullOrEmpty(selector.InputName)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, "First frame must include an input name.")); + throw new RpcException(new Status(StatusCode.InvalidArgument, "First frame must include a terminal ID.")); } var stream = new GrpcTerminalStream(requestStream, responseStream); @@ -623,18 +630,14 @@ public override async Task AttachTerminal( try { - // Returns once the session ends or the caller disconnects. Holding the call open for that whole time is + // Returns once the terminal ends or the caller disconnects. Holding the call open for that whole time is // what keeps the tunnel alive, so this must not be fire-and-forget. - await terminalSessionStore.AttachAsync( - selector.InteractionId, - selector.InputName, - stream, - cancellationToken).ConfigureAwait(false); + await terminalService.AttachAsync(selector.TerminalId, stream, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException ex) { - // The interaction completed or never had this terminal input; the dashboard may still be holding a stale - // dialog open, so report it as a precondition failure rather than faulting the whole connection. + // The terminal was disposed, or never existed; the dashboard may still be holding a stale dialog or dock + // tab open, so report it as a precondition failure rather than faulting the whole connection. throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -642,4 +645,80 @@ await terminalSessionStore.AttachAsync( // The dashboard closed the tunnel, typically because the browser tab or dialog went away. } } + + public override async Task WatchTerminals( + WatchTerminalsRequest request, + IServerStreamWriter responseStream, + ServerCallContext context) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, hostApplicationLifetime.ApplicationStopping); + var cancellationToken = linked.Token; + + // Subscribe before writing the snapshot. SubscribeDockTerminals captures both under one lock, so a terminal + // created concurrently lands in exactly one of them. + var (initial, changes) = terminalService.SubscribeDockTerminals(); + + var snapshot = new TerminalDescriptorList(); + snapshot.Terminals.AddRange(initial.Select(ToProtoDescriptor)); + await responseStream.WriteAsync(new WatchTerminalsUpdate { Snapshot = snapshot }, cancellationToken).ConfigureAwait(false); + + try + { + await foreach (var change in changes.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + await responseStream.WriteAsync( + new WatchTerminalsUpdate + { + Change = new TerminalChangeNotification + { + ChangeType = ToProtoChangeType(change.ChangeType), + Terminal = ToProtoDescriptor(change.Terminal) + } + }, + cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The dashboard disconnected or the AppHost is shutting down. + } + } + + public override Task CreateDockTerminal( + CreateDockTerminalRequest request, + ServerCallContext context) + { + var terminal = terminalService.CreateDockTerminal(string.IsNullOrWhiteSpace(request.Title) ? null : request.Title); + + return Task.FromResult(new CreateDockTerminalResponse + { + Terminal = new TerminalDescriptor { TerminalId = terminal.Id, Title = terminal.Title } + }); + } + + public override async Task CloseTerminal( + CloseTerminalRequest request, + ServerCallContext context) + { + if (terminalService.TryGetTerminal(request.TerminalId, out var terminal)) + { + await terminal.DisposeAsync().ConfigureAwait(false); + } + + // Closing an unknown terminal is not an error: the dashboard may be reacting to a tab the AppHost + // already removed. + return new CloseTerminalResponse(); + } + + private static TerminalDescriptor ToProtoDescriptor(Terminals.TerminalDescriptor descriptor) + => new() { TerminalId = descriptor.Id, Title = descriptor.Title }; + + private static TerminalChangeType ToProtoChangeType(Terminals.TerminalChangeType changeType) => changeType switch + { + Terminals.TerminalChangeType.Added => TerminalChangeType.Added, + Terminals.TerminalChangeType.Removed => TerminalChangeType.Removed, + Terminals.TerminalChangeType.Retitled => TerminalChangeType.Retitled, + Terminals.TerminalChangeType.Activated => TerminalChangeType.Activated, + _ => TerminalChangeType.Unspecified + }; } diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index 16e023cc034..de0670f7eb8 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -51,7 +51,7 @@ public DashboardServiceHost( ResourceCommandService resourceCommandService, InteractionService interactionService, IInteractionFileUploadStore fileUploadStore, - IInteractionTerminalSessionStore terminalSessionStore) + Terminals.TerminalService terminalService) { _logger = loggerFactory.CreateLogger(); @@ -111,7 +111,7 @@ public DashboardServiceHost( builder.Services.AddSingleton(resourceLoggerService); builder.Services.AddSingleton(interactionService); builder.Services.AddSingleton(fileUploadStore); - builder.Services.AddSingleton(terminalSessionStore); + builder.Services.AddSingleton(terminalService); builder.WebHost.ConfigureKestrel(ConfigureKestrel); diff --git a/src/Aspire.Hosting/Dashboard/IInteractionTerminalSessionStore.cs b/src/Aspire.Hosting/Dashboard/IInteractionTerminalSessionStore.cs deleted file mode 100644 index 456d2d7db98..00000000000 --- a/src/Aspire.Hosting/Dashboard/IInteractionTerminalSessionStore.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Hex1b; - -namespace Aspire.Hosting; - -/// -/// Tracks the AppHost-owned terminal sessions belonging to interaction inputs. -/// -/// -/// This mirrors : the interaction itself only carries a handle over the -/// dashboard gRPC channel, while the payload — here a live HMP1 byte stream rather than file bytes — is moved over a -/// dedicated streaming RPC. -/// -internal interface IInteractionTerminalSessionStore -{ - /// - /// Registers an interaction and the terminal inputs that can be attached to. - /// - void StartInteraction(int interactionId, IReadOnlyList<(string InputName, Hex1bTerminalBuilder Builder)> terminalInputs); - - /// - /// Attaches a client to a terminal session, starting the session if this is the first client. - /// - /// - /// A task that completes when the session ends or is signalled. Callers keep - /// their transport open until it completes. - /// - Task AttachAsync(int interactionId, string inputName, Stream clientStream, CancellationToken cancellationToken); - - /// - /// Tears down every terminal session owned by an interaction that completed normally. - /// - void CompleteInteraction(int interactionId); - - /// - /// Tears down every terminal session owned by an interaction that was cancelled. - /// - void CancelInteraction(int interactionId); -} diff --git a/src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs b/src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs deleted file mode 100644 index 509ffc14469..00000000000 --- a/src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs +++ /dev/null @@ -1,227 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Concurrent; -using System.Threading.Channels; -using Hex1b; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.Dashboard; - -/// -/// Owns the lifetime of terminal sessions created for interaction inputs. -/// -internal sealed class InteractionTerminalSessionStore : IInteractionTerminalSessionStore, IDisposable -{ - private readonly ConcurrentDictionary _interactions = new(); - private readonly ILogger _logger; - private int _disposed; - - public InteractionTerminalSessionStore(ILogger logger) - { - _logger = logger; - } - - public void StartInteraction(int interactionId, IReadOnlyList<(string InputName, Hex1bTerminalBuilder Builder)> terminalInputs) - { - var sessions = new Dictionary(StringComparers.InteractionInputName); - foreach (var (inputName, builder) in terminalInputs) - { - sessions[inputName] = new TerminalSession(interactionId, inputName, builder, _logger); - } - - if (_interactions.TryAdd(interactionId, new TerminalInteraction(sessions))) - { - _logger.LogDebug( - "Started tracking {SessionCount} terminal session(s) for interaction {InteractionId}.", - sessions.Count, - interactionId); - } - } - - public Task AttachAsync(int interactionId, string inputName, Stream clientStream, CancellationToken cancellationToken) - { - if (!_interactions.TryGetValue(interactionId, out var interaction) || - !interaction.Sessions.TryGetValue(inputName, out var session)) - { - throw new InvalidOperationException($"Interaction '{interactionId}' does not have a terminal input named '{inputName}'."); - } - - return session.AttachAsync(clientStream, cancellationToken); - } - - public void CompleteInteraction(int interactionId) => EndInteraction(interactionId, "completed"); - - public void CancelInteraction(int interactionId) => EndInteraction(interactionId, "cancelled"); - - private void EndInteraction(int interactionId, string reason) - { - if (!_interactions.TryRemove(interactionId, out var interaction)) - { - return; - } - - _logger.LogDebug( - "Tearing down {SessionCount} terminal session(s) for {Reason} interaction {InteractionId}.", - interaction.Sessions.Count, - reason, - interactionId); - - foreach (var session in interaction.Sessions.Values) - { - session.Stop(); - } - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - foreach (var interactionId in _interactions.Keys) - { - EndInteraction(interactionId, "disposed"); - } - } - - private sealed class TerminalInteraction(Dictionary sessions) - { - public Dictionary Sessions { get; } = sessions; - } - - /// - /// A single AppHost-owned terminal. Clients are handed to Hex1b's HMP1 server through a channel, which lets the - /// same session serve several attached viewers (for example two dashboard tabs) using HMP1's multi-head support. - /// - private sealed class TerminalSession(int interactionId, string inputName, Hex1bTerminalBuilder builder, ILogger logger) - { - // Unbounded because the producer is a human attaching a viewer; the queue depth is realistically 0 or 1 and - // dropping or blocking an attach would strand the RPC that is waiting to be served. - private readonly Channel _clients = Channel.CreateUnbounded(); - private readonly CancellationTokenSource _stopCts = new(); - // Aspire.Hosting targets net8.0, which predates System.Threading.Lock, so this is a plain monitor gate. - private readonly object _gate = new(); - private Hex1bTerminal? _terminal; - private Task? _runTask; - private bool _stopped; - - public Task AttachAsync(Stream clientStream, CancellationToken cancellationToken) - { - EnsureStarted(); - - if (!_clients.Writer.TryWrite(clientStream)) - { - throw new InvalidOperationException($"Terminal session for input '{inputName}' is no longer accepting clients."); - } - - // The caller's transport must stay open for as long as Hex1b may use the stream. The session's own token - // ends the wait when the interaction is torn down, which lets the transport close from the AppHost side - // instead of lingering until the user closes the browser. - return WaitForSessionEndAsync(cancellationToken); - } - - private async Task WaitForSessionEndAsync(CancellationToken cancellationToken) - { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _stopCts.Token); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var registration = linked.Token.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), completion); - await completion.Task.ConfigureAwait(false); - } - - private void EnsureStarted() - { - lock (_gate) - { - if (_stopped) - { - throw new InvalidOperationException($"Terminal session for input '{inputName}' has already stopped."); - } - - if (_terminal is not null) - { - return; - } - - // Aspire owns the transport: the caller configures only the workload, and the HMP1 server is attached - // here so the session is reachable over the dashboard gRPC tunnel rather than a Unix domain socket. - // Started lazily so a dialog dismissed without opening the terminal never spawns the workload. - _terminal = builder - .WithHmp1Server(_clients.Reader.ReadAllAsync) - .Build(); - - logger.LogDebug( - "Starting terminal session for interaction {InteractionId}, input {InputName}.", - interactionId, - inputName); - - _runTask = RunTerminalAsync(_terminal); - } - } - - private async Task RunTerminalAsync(Hex1bTerminal terminal) - { - // Yield before touching the terminal so RunAsync never executes inline under _gate. - await Task.Yield(); - - try - { - await terminal.RunAsync(_stopCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected when the interaction completes while the terminal is still running. - } - catch (Exception ex) - { - logger.LogError( - ex, - "Terminal session for interaction {InteractionId}, input {InputName} failed.", - interactionId, - inputName); - } - finally - { - // Unblock every attached client so their transports close rather than waiting for the interaction to - // end. This is the path taken when the workload itself exits, e.g. the user types `exit`. - _stopCts.Cancel(); - await terminal.DisposeAsync().ConfigureAwait(false); - } - } - - public void Stop() - { - Task? runTask; - lock (_gate) - { - if (_stopped) - { - return; - } - - _stopped = true; - _clients.Writer.TryComplete(); - runTask = _runTask; - } - - _stopCts.Cancel(); - - if (runTask is null) - { - // The session was registered but never attached to, so there is nothing to wind down and no terminal - // was ever built. Dispose the token source directly. - _stopCts.Dispose(); - return; - } - - // Don't block interaction teardown on the workload exiting; dispose the token source once it has. - _ = runTask.ContinueWith( - static (_, state) => ((CancellationTokenSource)state!).Dispose(), - _stopCts, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } - } -} diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 176ecbd19dc..928aac139d9 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -438,6 +438,9 @@ message InteractionInput { int64 max_file_size = 16; bool allow_multiple_files = 17; string file_filter = 18; + // Identifies the AppHost-owned terminal backing an INPUT_TYPE_TERMINAL input. The dashboard passes this to + // /terminal/attach to open the tunnel. + string terminal_id = 19; } enum MessageIntent { MESSAGE_INTENT_NONE = 0; @@ -477,7 +480,7 @@ message UploadFileResponse { //////////////////////////////////////////// -// A message sent by the dashboard to the AppHost for a terminal input session. +// A message sent by the dashboard to the AppHost for a terminal session. // // The stream carries an opaque HMP1 (Hex1b Muxer Protocol v1) byte stream, tunneled // so that the terminal can be owned by the AppHost process itself rather than by a @@ -485,16 +488,21 @@ message UploadFileResponse { // dashboard nor this service interprets `data`; both sides are byte-level relays // between the browser's HMP1 client and the AppHost's HMP1 server. message TerminalClientFrame { - // The interaction that owns the terminal session (sent in the first frame). - int32 interaction_id = 1; - // The interaction input that owns the terminal session (sent in the first frame). - string input_name = 2; + // Formerly interaction_id/input_name. Terminals are now addressed by an opaque id + // issued by the AppHost's TerminalService, which lets one tunnel serve both + // interaction-input terminals and dashboard terminal dock tabs. + reserved 1, 2; + reserved "interaction_id", "input_name"; + // A chunk of the HMP1 byte stream flowing from the browser to the AppHost. // Empty on the first frame, which only carries the session selector. bytes data = 3; + + // The terminal to attach to (sent in the first frame). + string terminal_id = 4; } -// A message sent by the AppHost to the dashboard for a terminal input session. +// A message sent by the AppHost to the dashboard for a terminal session. message TerminalServerFrame { // A chunk of the HMP1 byte stream flowing from the AppHost to the browser. bytes data = 1; @@ -502,6 +510,60 @@ message TerminalServerFrame { //////////////////////////////////////////// +// A terminal owned by the AppHost and displayed as a tab in the dashboard's terminal dock. +message TerminalDescriptor { + string terminal_id = 1; + string title = 2; +} + +enum TerminalChangeType { + TERMINAL_CHANGE_TYPE_UNSPECIFIED = 0; + TERMINAL_CHANGE_TYPE_ADDED = 1; + TERMINAL_CHANGE_TYPE_REMOVED = 2; + TERMINAL_CHANGE_TYPE_RETITLED = 3; + // The AppHost called Show() on this terminal. Dashboards should reveal the dock + // and switch to this terminal's tab. + TERMINAL_CHANGE_TYPE_ACTIVATED = 4; +} + +message WatchTerminalsRequest { +} + +message TerminalDescriptorList { + repeated TerminalDescriptor terminals = 1; +} + +message TerminalChangeNotification { + TerminalChangeType change_type = 1; + TerminalDescriptor terminal = 2; +} + +message WatchTerminalsUpdate { + oneof kind { + // Sent once, first, carrying the terminals that already exist. + TerminalDescriptorList snapshot = 1; + TerminalChangeNotification change = 2; + } +} + +message CreateDockTerminalRequest { + // Optional title for the new terminal. The AppHost picks a default when empty. + string title = 1; +} + +message CreateDockTerminalResponse { + TerminalDescriptor terminal = 1; +} + +message CloseTerminalRequest { + string terminal_id = 1; +} + +message CloseTerminalResponse { +} + +//////////////////////////////////////////// + service DashboardService { rpc GetApplicationInformation(ApplicationInformationRequest) returns (ApplicationInformationResponse); rpc WatchResources(WatchResourcesRequest) returns (stream WatchResourcesUpdate); @@ -510,4 +572,7 @@ service DashboardService { rpc WatchInteractions(stream WatchInteractionsRequestUpdate) returns (stream WatchInteractionsResponseUpdate); rpc UploadFile(stream UploadFileChunk) returns (UploadFileResponse); rpc AttachTerminal(stream TerminalClientFrame) returns (stream TerminalServerFrame); + rpc WatchTerminals(WatchTerminalsRequest) returns (stream WatchTerminalsUpdate); + rpc CreateDockTerminal(CreateDockTerminalRequest) returns (CreateDockTerminalResponse); + rpc CloseTerminal(CloseTerminalRequest) returns (CloseTerminalResponse); } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index f4ea6e2de4c..9eb947e19f4 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -467,7 +467,8 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); ConfigureHealthChecks(); diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index df114a37029..7fb8c6e0902 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -485,6 +485,15 @@ public long? MaxFileSize /// [AspireExportIgnore(Reason = "Hex1bTerminalBuilder is a live builder object owning a local process; it cannot be serialized to polyglot app hosts.")] public Hex1bTerminalBuilder? Terminal { get; init; } + + /// + /// Identifies the AppHost-owned terminal created for this input. Stamped by the interaction service when the + /// dialog is raised and sent to the dashboard so it can open the tunnel. + /// + /// + /// Deliberately internal: this is transport addressing, not something an AppHost author sets. + /// + internal string? TerminalId { get; set; } } /// diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 411cf90109e..6823b1f375f 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -28,16 +28,16 @@ internal class InteractionService : IInteractionService private readonly IServiceProvider _serviceProvider; private readonly IConfiguration _configuration; private readonly IInteractionFileUploadStore _fileUploadStore; - private readonly IInteractionTerminalSessionStore _terminalSessionStore; + private readonly Terminals.TerminalService _terminalService; - public InteractionService(ILogger logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider, IConfiguration configuration, IInteractionFileUploadStore fileUploadStore, IInteractionTerminalSessionStore terminalSessionStore) + public InteractionService(ILogger logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider, IConfiguration configuration, IInteractionFileUploadStore fileUploadStore, Terminals.TerminalService terminalService) { _logger = logger; _distributedApplicationOptions = distributedApplicationOptions; _serviceProvider = serviceProvider; _configuration = configuration; _fileUploadStore = fileUploadStore; - _terminalSessionStore = terminalSessionStore; + _terminalService = terminalService; } public bool IsAvailable @@ -211,11 +211,22 @@ public async Task> PromptInputsAsy } if (hasTerminalInputs) { - var terminalInputs = inputs - .Where(input => input.InputType == InputType.Terminal) - .Select(input => (input.Name, Builder: input.Terminal!)) - .ToArray(); - _terminalSessionStore.StartInteraction(newState.InteractionId, terminalInputs); + // Terminals are created eagerly so the dialog carries a terminal id, but the underlying workload + // does not start until a client actually attaches. A dialog dismissed without opening the terminal + // therefore never spawns a process. + foreach (var input in inputs) + { + if (input.InputType == InputType.Terminal) + { + var terminal = _terminalService.CreateTerminal(new Terminals.TerminalLaunchOptions + { + Title = string.IsNullOrEmpty(input.Label) ? input.Name : input.Label, + Builder = input.Terminal!, + Surface = Terminals.TerminalSurface.Interaction + }); + input.TerminalId = terminal.Id; + } + } } AddInteractionUpdate(newState); @@ -548,13 +559,13 @@ private void CompleteInteractionCore(Interaction interactionState, InteractionCo if (interactionState.InteractionInfo is Interaction.InputsInteractionInfo terminalInputsInfo && terminalInputsInfo.Inputs.Any(input => input.InputType == InputType.Terminal)) { - if (completion.State is IReadOnlyList) - { - _terminalSessionStore.CompleteInteraction(interactionState.InteractionId); - } - else + foreach (var input in terminalInputsInfo.Inputs) { - _terminalSessionStore.CancelInteraction(interactionState.InteractionId); + if (input.InputType == InputType.Terminal && input.TerminalId is { } terminalId) + { + _terminalService.RemoveAndDisposeInBackground(terminalId); + input.TerminalId = null; + } } } diff --git a/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs b/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs new file mode 100644 index 00000000000..48cc7a3b761 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Terminals; + +/// +/// The non-printable keys that can be sent to a terminal through . +/// +/// +/// This is deliberately a small, Aspire-owned enum rather than a projection of the underlying terminal +/// library's key enum. Each value maps to a raw byte sequence in , +/// which keeps the mapping under Aspire's control and avoids leaking a third-party enum through +/// . +/// +internal enum AspireTerminalKey +{ + Enter, + Tab, + Escape, + Backspace, + Delete, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + + /// Ctrl+C — sends the interrupt control character. + CtrlC, + + /// Ctrl+D — sends the end-of-transmission control character. + CtrlD +} + +/// +/// Maps values to the byte sequences a terminal workload expects. +/// +internal static class AspireTerminalKeySequences +{ + /// + /// Gets the raw sequence for . + /// + /// + /// Cursor and editing keys use the normal-mode sequences from the xterm control sequence reference + /// (see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html, "PC-Style Function Keys"). Applications + /// that enable DECCKM (application cursor keys) expect SS3-prefixed forms instead — ESC O A rather + /// than ESC [ A — but normal mode is the safer default because most workloads accept both and + /// tracking DECCKM state would mean reaching back into the emulator for every keystroke. + /// + /// Backspace sends DEL (0x7f) rather than BS (0x08) because that is what terminal emulators send by + /// default on Unix, and what readline-based shells expect. + /// + public static string Get(AspireTerminalKey key) => key switch + { + AspireTerminalKey.Enter => "\r", + AspireTerminalKey.Tab => "\t", + AspireTerminalKey.Escape => "\u001b", + AspireTerminalKey.Backspace => "\u007f", + AspireTerminalKey.Delete => "\u001b[3~", + AspireTerminalKey.Up => "\u001b[A", + AspireTerminalKey.Down => "\u001b[B", + AspireTerminalKey.Right => "\u001b[C", + AspireTerminalKey.Left => "\u001b[D", + AspireTerminalKey.Home => "\u001b[H", + AspireTerminalKey.End => "\u001b[F", + AspireTerminalKey.PageUp => "\u001b[5~", + AspireTerminalKey.PageDown => "\u001b[6~", + AspireTerminalKey.CtrlC => "\u0003", + AspireTerminalKey.CtrlD => "\u0004", + _ => throw new ArgumentOutOfRangeException(nameof(key), key, "Unknown terminal key.") + }; +} diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs new file mode 100644 index 00000000000..8ecf0fed98b --- /dev/null +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -0,0 +1,296 @@ +// 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 Hex1b; +using Hex1b.Automation; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Terminals; + +/// +/// The Hex1b-backed implementation of . +/// +/// +/// Clients are handed to Hex1b's HMP1 server through a channel, which lets a single terminal serve several +/// attached viewers (for example two dashboard browser tabs, or a dock tab reopened after being closed) +/// using HMP1's multi-head support. The workload lives in the AppHost, so terminal state survives a viewer +/// disconnecting entirely. +/// +internal sealed class Hex1bAspireTerminal : IAspireTerminal +{ + private static readonly TimeSpan s_defaultAutomationTimeout = TimeSpan.FromSeconds(30); + + // Unbounded because the producer is a viewer attaching; the queue depth is realistically 0 or 1 and + // dropping or blocking an attach would strand the RPC that is waiting to be served. + private readonly Channel _clients = Channel.CreateUnbounded(); + + // Two distinct signals, deliberately. _workloadCts stops the workload; _sessionEnded reports that + // teardown has *finished*. Collapsing them into one token releases attached clients while Hex1b is + // still disposing, which lets a gRPC handler return and dispose the transport out from under it. + private readonly CancellationTokenSource _workloadCts = new(); + private readonly TaskCompletionSource _sessionEnded = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // Aspire.Hosting targets net8.0, which predates System.Threading.Lock, so this is a plain monitor gate. + private readonly object _gate = new(); + + private readonly TerminalService _owner; + private readonly Hex1bTerminalBuilder _builder; + private readonly ILogger _logger; + + private Hex1bTerminal? _terminal; + private Hex1bTerminalAutomator? _automator; + private Task? _runTask; + private bool _stopped; + + public Hex1bAspireTerminal(TerminalService owner, string id, TerminalLaunchOptions options, ILogger logger) + { + _owner = owner; + _builder = options.Builder; + _logger = logger; + Id = id; + Title = options.Title; + Surface = options.Surface; + } + + public string Id { get; } + + public string Title { get; private set; } + + public TerminalSurface Surface { get; } + + public TerminalDescriptor Descriptor => new(Id, Title); + + public void Show() + { + if (Surface != TerminalSurface.Dock) + { + // Interaction terminals are revealed by their own dialog, so there is no dock tab to switch to. + return; + } + + _owner.NotifyActivated(this); + } + + public void Retitle(string title) + { + lock (_gate) + { + if (string.Equals(Title, title, StringComparison.Ordinal)) + { + return; + } + + Title = title; + } + + _owner.NotifyRetitled(this); + } + + /// + /// Attaches a viewer, starting the workload if this is the first thing to need it. + /// + /// + /// A task that completes once the terminal has fully torn down, or once + /// is signalled. Callers keep their transport open until it completes. + /// + public Task AttachAsync(Stream clientStream, CancellationToken cancellationToken) + { + EnsureStarted(); + + if (!_clients.Writer.TryWrite(clientStream)) + { + throw new InvalidOperationException($"Terminal '{Id}' is no longer accepting clients."); + } + + return WaitForSessionEndAsync(cancellationToken); + } + + private async Task WaitForSessionEndAsync(CancellationToken cancellationToken) + { + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled); + await Task.WhenAny(_sessionEnded.Task, cancelled.Task).ConfigureAwait(false); + } + + /// + /// Starts the workload if it is not already running. + /// + /// + /// Startup is lazy so that an interaction dialog dismissed without ever opening its terminal never + /// spawns a process. The first attach *or* the first automation call is what starts it. + /// + private Hex1bTerminal EnsureStarted() + { + lock (_gate) + { + if (_stopped) + { + throw new InvalidOperationException($"Terminal '{Id}' has already stopped."); + } + + if (_terminal is not null) + { + return _terminal; + } + + // Aspire owns the transport: the caller configures only the workload, and the HMP1 server is + // attached here so the terminal is reachable over the dashboard gRPC tunnel rather than a Unix + // domain socket. + _terminal = _builder + .WithHmp1Server(_clients.Reader.ReadAllAsync) + .Build(); + + _automator = new Hex1bTerminalAutomator(_terminal, s_defaultAutomationTimeout); + + _logger.LogDebug("Starting terminal {TerminalId} ({Title}).", Id, Title); + + _runTask = RunTerminalAsync(_terminal); + return _terminal; + } + } + + private async Task RunTerminalAsync(Hex1bTerminal terminal) + { + // Yield before touching the terminal so RunAsync never executes inline under _gate. + await Task.Yield(); + + try + { + await terminal.RunAsync(_workloadCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when the terminal is disposed while the workload is still running. + } + catch (Exception ex) + { + _logger.LogError(ex, "Terminal {TerminalId} ({Title}) failed.", Id, Title); + } + finally + { + // Dispose *before* releasing attached clients. Hex1b may still write to the attached transports + // while it tears the terminal down; signalling completion first would let an attach caller return + // and dispose its transport out from under Hex1b. + try + { + await terminal.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Disposing terminal {TerminalId} ({Title}) failed.", Id, Title); + } + + _sessionEnded.TrySetResult(); + } + } + + public async Task SendTextAsync(string text, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(text); + + EnsureStarted(); + await _automator!.TypeAsync(text, cancellationToken).ConfigureAwait(false); + } + + public async Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) + { + var terminal = EnsureStarted(); + var sequence = AspireTerminalKeySequences.Get(key); + await terminal.SendInputAsync(Encoding.UTF8.GetBytes(sequence), cancellationToken).ConfigureAwait(false); + } + + public async Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(text); + + EnsureStarted(); + + // Hex1b's wait takes a timeout but no token, so the caller's cancellation is layered on here. The + // underlying wait keeps running until its timeout elapses; that is acceptable because it is a passive + // screen poll with no side effects. + var wait = _automator!.WaitUntilTextAsync(text, timeout ?? s_defaultAutomationTimeout); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled); + + var completed = await Task.WhenAny(wait, cancelled.Task).ConfigureAwait(false); + if (completed != wait) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + try + { + await wait.ConfigureAwait(false); + } + catch (WaitUntilTimeoutException ex) + { + // Translate so callers never have to reference Hex1b to handle a timeout. + throw new TimeoutException($"Terminal '{Id}' did not display the expected text within the timeout.", ex); + } + } + + public string GetScreenText() + { + Hex1bTerminalAutomator? automator; + lock (_gate) + { + automator = _automator; + } + + // A terminal that has never been attached to or driven has no screen yet. Reporting empty is + // friendlier than starting the workload as a side effect of a read. + if (automator is null) + { + return string.Empty; + } + + // The snapshot holds pooled buffers, so it must be released rather than left to finalization. + using var snapshot = automator.CreateSnapshot(); + return snapshot.GetScreenText(); + } + + /// + /// Stops the workload without notifying the owning service. Used when the service is tearing everything down. + /// + public Task StopAsync() + { + lock (_gate) + { + if (_stopped) + { + return _sessionEnded.Task; + } + + _stopped = true; + _clients.Writer.TryComplete(); + + if (_runTask is null) + { + // Registered but never started, so there is nothing to wind down. + _workloadCts.Cancel(); + _workloadCts.Dispose(); + _sessionEnded.TrySetResult(); + return _sessionEnded.Task; + } + } + + _workloadCts.Cancel(); + + _ = _sessionEnded.Task.ContinueWith( + static (_, state) => ((CancellationTokenSource)state!).Dispose(), + _workloadCts, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + return _sessionEnded.Task; + } + + public async ValueTask DisposeAsync() + { + _owner.Remove(this); + await StopAsync().ConfigureAwait(false); + } +} diff --git a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs new file mode 100644 index 00000000000..7219fd9fc91 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Terminals; + +/// +/// A terminal owned by the AppHost process, surfaced in the dashboard and driveable from AppHost code. +/// +/// +/// +/// This is deliberately a thin, Aspire-shaped abstraction over the underlying terminal implementation +/// (currently Hex1b). Keeping the implementation type out of this interface is what lets terminals be +/// used from Aspire APIs without dragging Hex1b's very large surface area into Aspire's own. +/// +/// +/// The automation members are an intentionally small subset. Hex1b exposes a rich cell-pattern matching +/// DSL (CellPatternSearcher and around sixty supporting types); none of it is projected here. +/// "Send some input, wait for some text, read the screen" covers the scenarios a spike needs, and the +/// surface can grow later if real usage demands it. +/// +/// +/// Disposing the terminal cancels its workload and removes it from the dashboard. Terminals attached to +/// an interaction are disposed automatically when the interaction completes or is cancelled. +/// +/// +internal interface IAspireTerminal : IAsyncDisposable +{ + /// + /// Gets the opaque identifier used to address this terminal over the dashboard connection. + /// + string Id { get; } + + /// + /// Gets the title shown on the terminal's dock tab. + /// + string Title { get; } + + /// + /// Gets the surface this terminal is displayed on. + /// + TerminalSurface Surface { get; } + + /// + /// Reveals the terminal dock in every connected dashboard and switches to this terminal's tab. + /// + /// + /// Only meaningful for terminals. Interaction terminals are + /// revealed by their dialog, so this is a no-op for them. + /// + void Show(); + + /// + /// Sends text to the terminal's workload as though it had been typed. + /// + Task SendTextAsync(string text, CancellationToken cancellationToken = default); + + /// + /// Sends a single non-printable key to the terminal's workload. + /// + Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default); + + /// + /// Waits until appears on the terminal screen. + /// + /// The text to wait for. + /// How long to wait before giving up. Defaults to 30 seconds. + /// Cancellation token. + /// The text did not appear before elapsed. + Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Gets the current contents of the terminal screen, with lines separated by newlines. + /// + string GetScreenText(); +} diff --git a/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs b/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs new file mode 100644 index 00000000000..1320f3c61ae --- /dev/null +++ b/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Terminals; + +/// +/// Produces the terminal that opens when the dashboard's terminal dock creates a new tab. +/// +/// +/// Indirected through an interface so the dock's default experience can change (today a built-in TUI, +/// later a real Aspire REPL) without knowing anything about it. +/// +internal interface IDockTerminalFactory +{ + /// + /// Creates the launch options for a new dock terminal. + /// + /// A caller-supplied title, or to use the factory's default. + /// A 1-based counter of dock terminals created so far, for default titles. + TerminalLaunchOptions Create(string? title, int ordinal); +} diff --git a/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs b/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs new file mode 100644 index 00000000000..bff67efb23b --- /dev/null +++ b/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs @@ -0,0 +1,70 @@ +// 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.Input; +using Hex1b.Widgets; + +namespace Aspire.Hosting.Terminals; + +/// +/// The default dock terminal: a small in-process TUI that stands in for the built-in Aspire REPL. +/// +/// +/// +/// This is a placeholder. It exists to prove the dock end-to-end — that the AppHost can create a terminal, +/// that the dashboard discovers it over the watch stream, that the HMP1 tunnel renders it in xterm.js, and +/// that keystrokes travel back — without also having to design what an Aspire REPL should actually do. +/// +/// +/// Because it runs as a Hex1b app rather than a PTY process, there is no child process to manage and it +/// behaves identically on every platform. +/// +/// +internal sealed class PlaceholderDockTerminalFactory : IDockTerminalFactory +{ + public TerminalLaunchOptions Create(string? title, int ordinal) + { + var resolvedTitle = title ?? (ordinal == 1 ? "Aspire" : $"Aspire {ordinal}"); + + return new TerminalLaunchOptions + { + Title = resolvedTitle, + Surface = TerminalSurface.Dock, + Builder = Hex1bTerminal.CreateBuilder() + .WithHex1bApp(ctx => BuildPlaceholderApp(ctx, resolvedTitle)) + }; + } + + private static Hex1bWidget BuildPlaceholderApp(RootContext ctx, string title) + { + var body = ctx.Center( + ctx.Border(b => + [ + b.VStack(v => + [ + v.Text(""), + v.Text(" The built-in Aspire REPL lives here. "), + v.Text(""), + v.Text(" This placeholder proves the dock, the "), + v.Text(" watch stream, and the HMP1 tunnel. "), + v.Text(""), + ]) + ]).Title($" {title} ")); + + var info = ctx.InfoBar(s => + [ + s.Section(title), + s.Spacer(), + s.Section("placeholder"), + ]).Divider(" "); + + // Bind a key so the terminal visibly accepts focus and input even though the placeholder has + // nothing to do with it. Without a binding the app never requests a redraw, which makes a working + // tunnel look indistinguishable from a dead one. + return ctx.VStack(v => [body.Fill(), info]).InputBindings(bindings => + { + bindings.Key(Hex1bKey.Enter).Action(_ => { }, "Refresh"); + }); + } +} diff --git a/src/Aspire.Hosting/Terminals/TerminalChange.cs b/src/Aspire.Hosting/Terminals/TerminalChange.cs new file mode 100644 index 00000000000..b7884559618 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalChange.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Terminals; + +/// +/// The dashboard-visible description of a terminal. +/// +internal sealed record TerminalDescriptor(string Id, string Title); + +/// +/// The kind of change that occurred to the set of dock terminals. +/// +internal enum TerminalChangeType +{ + /// A terminal was created. + Added, + + /// A terminal was disposed and should be removed from the dock. + Removed, + + /// An existing terminal's title changed. + Retitled, + + /// + /// was called. Dashboards should reveal the dock and switch to + /// this terminal's tab. + /// + Activated +} + +/// +/// A change to the set of dock terminals, broadcast to every connected dashboard. +/// +internal sealed record TerminalChange(TerminalChangeType ChangeType, TerminalDescriptor Terminal); diff --git a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs new file mode 100644 index 00000000000..d06db2d99f9 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b; + +namespace Aspire.Hosting.Terminals; + +/// +/// Describes a terminal to be created by . +/// +/// +/// takes a Hex1b type directly. That is a deliberate spike shortcut: it keeps the +/// workload description expressive without designing an Aspire-shaped equivalent up front. It is also the +/// last remaining Hex1b leak on this path — already hides Hex1b from +/// everything downstream, so closing this one is what would make publishable. +/// +internal sealed class TerminalLaunchOptions +{ + /// + /// Gets or sets the title shown on the terminal's dock tab. + /// + public required string Title { get; set; } + + /// + /// Gets or sets the configured workload. Aspire attaches the transport itself, so callers must not + /// call WithHmp1Server or Build on the builder. + /// + public required Hex1bTerminalBuilder Builder { get; set; } + + /// + /// Gets or sets the surface the terminal is displayed on. Defaults to . + /// + public TerminalSurface Surface { get; set; } = TerminalSurface.Dock; +} diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs new file mode 100644 index 00000000000..a5c26641f57 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -0,0 +1,245 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Terminals; + +/// +/// Owns every terminal whose process is hosted by the AppHost itself. +/// +/// +/// +/// Two experiences share this service: terminals belonging to an interaction +/// input, and terminals shown as tabs in the dashboard's terminal dock. They differ only in +/// ; the lifetime, transport, and automation machinery is identical. +/// +/// +/// This is distinct from the terminal host, which exists solely to surface terminals for DCP-owned processes. +/// Those are owned by the resource, reachable over a Unix domain socket, and are not tracked here. +/// +/// +/// The service is internal for now. Making it public requires first replacing +/// with an Aspire-shaped workload description, since that is the +/// only remaining place a Hex1b type is visible. +/// +/// +internal sealed class TerminalService : IAsyncDisposable +{ + private readonly ConcurrentDictionary _terminals = new(StringComparer.Ordinal); + private readonly ILogger _logger; + private readonly IDockTerminalFactory _dockTerminalFactory; + private readonly object _syncLock = new(); + private ImmutableHashSet> _outgoingChannels = []; + private int _disposed; + private int _dockTerminalCount; + + public TerminalService(ILogger logger, IDockTerminalFactory dockTerminalFactory) + { + _logger = logger; + _dockTerminalFactory = dockTerminalFactory; + } + + /// + /// Creates a terminal for the dashboard's terminal dock using the configured dock terminal factory. + /// + public IAspireTerminal CreateDockTerminal(string? title = null) + { + var options = _dockTerminalFactory.Create(title, Interlocked.Increment(ref _dockTerminalCount)); + options.Surface = TerminalSurface.Dock; + return CreateTerminal(options); + } + + /// + /// Creates a terminal. The workload does not start until something needs it: the first viewer attaching, + /// or the first automation call. + /// + public IAspireTerminal CreateTerminal(TerminalLaunchOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ObjectDisposedException.ThrowIf(_disposed != 0, this); + + // Terminal ids are opaque to the dashboard and appear in websocket query strings, so use a + // non-guessable value rather than a sequence number. + var id = Guid.NewGuid().ToString("n"); + var terminal = new Hex1bAspireTerminal(this, id, options, _logger); + + _terminals[id] = terminal; + _logger.LogDebug("Created {Surface} terminal {TerminalId} ({Title}).", options.Surface, id, options.Title); + + if (terminal.Surface == TerminalSurface.Dock) + { + Publish(new TerminalChange(TerminalChangeType.Added, terminal.Descriptor)); + } + + return terminal; + } + + /// + /// Attaches a viewer transport to a terminal. + /// + /// + /// A task that completes when the terminal ends or is signalled. + /// Callers keep their transport open until it completes. + /// + public Task AttachAsync(string terminalId, Stream clientStream, CancellationToken cancellationToken) + { + if (!_terminals.TryGetValue(terminalId, out var terminal)) + { + throw new InvalidOperationException($"There is no terminal with id '{terminalId}'."); + } + + return terminal.AttachAsync(clientStream, cancellationToken); + } + + /// + /// Gets a terminal by id. + /// + public bool TryGetTerminal(string terminalId, [NotNullWhen(true)] out IAspireTerminal? terminal) + { + if (_terminals.TryGetValue(terminalId, out var found)) + { + terminal = found; + return true; + } + + terminal = null; + return false; + } + + /// + /// Subscribes to the dock's terminal list, returning the current set followed by a stream of changes. + /// + /// + /// The snapshot and the subscription are produced under the same lock so a terminal created concurrently + /// is either in the snapshot or in the change stream, never dropped and never duplicated. + /// + public TerminalSubscription SubscribeDockTerminals() + { + lock (_syncLock) + { + var channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { AllowSynchronousContinuations = false, SingleReader = true, SingleWriter = false }); + + ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Add(c), channel); + + var initial = _terminals.Values + .Where(t => t.Surface == TerminalSurface.Dock) + .Select(t => t.Descriptor) + .ToImmutableArray(); + + return new TerminalSubscription(initial, StreamChanges()); + + async IAsyncEnumerable StreamChanges([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + try + { + await foreach (var change in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return change; + } + } + finally + { + ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Remove(c), channel); + } + } + } + } + + internal void NotifyActivated(Hex1bAspireTerminal terminal) + => Publish(new TerminalChange(TerminalChangeType.Activated, terminal.Descriptor)); + + /// + /// Removes a terminal from the registry and tears its workload down without waiting for it. + /// + /// + /// Used on the interaction completion path, which runs under a lock held by the interaction collection and + /// must not block on a workload that may be ignoring cancellation. The registry entry is removed + /// synchronously so the terminal is unreachable the moment the dialog closes. + /// + internal void RemoveAndDisposeInBackground(string terminalId) + { + if (!_terminals.TryGetValue(terminalId, out var terminal)) + { + return; + } + + Remove(terminal); + _ = DisposeQuietlyAsync(terminal); + + async Task DisposeQuietlyAsync(Hex1bAspireTerminal target) + { + try + { + await target.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error disposing terminal {TerminalId}.", target.Id); + } + } + } + + internal void NotifyRetitled(Hex1bAspireTerminal terminal) + => Publish(new TerminalChange(TerminalChangeType.Retitled, terminal.Descriptor)); + + internal void Remove(Hex1bAspireTerminal terminal) + { + if (!_terminals.TryRemove(terminal.Id, out _)) + { + return; + } + + _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title); + + if (terminal.Surface == TerminalSurface.Dock) + { + Publish(new TerminalChange(TerminalChangeType.Removed, terminal.Descriptor)); + } + } + + private void Publish(TerminalChange change) + { + foreach (var channel in _outgoingChannels) + { + channel.Writer.TryWrite(change); + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + foreach (var terminal in _terminals.Values) + { + Remove(terminal); + + // Don't await the workload winding down. AppHost shutdown should not be held up by a terminal + // whose process ignores cancellation; the process is torn down with the AppHost regardless. + _ = terminal.StopAsync(); + } + + foreach (var channel in _outgoingChannels) + { + channel.Writer.TryComplete(); + } + + await Task.CompletedTask.ConfigureAwait(false); + } +} + +/// +/// The current set of dock terminals plus a stream of subsequent changes. +/// +internal sealed record TerminalSubscription( + ImmutableArray InitialState, + IAsyncEnumerable Subscription); diff --git a/src/Aspire.Hosting/Terminals/TerminalSurface.cs b/src/Aspire.Hosting/Terminals/TerminalSurface.cs new file mode 100644 index 00000000000..251bf6da189 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalSurface.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Terminals; + +/// +/// Identifies where a terminal is displayed in the dashboard. +/// +internal enum TerminalSurface +{ + /// + /// The terminal is a tab in the dashboard's terminal dock, and is listed by the terminal watch stream. + /// + Dock, + + /// + /// The terminal belongs to an interaction input and is displayed inside + /// that interaction's dialog. These are addressed directly by the dialog and are deliberately excluded + /// from the dock's tab list. + /// + Interaction +} diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 7ace4143e1d..a71fa0ef8bb 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -187,6 +187,7 @@ const ( InputTypeBoolean InputType = "Boolean" InputTypeNumber InputType = "Number" InputTypeFile InputType = "File" + InputTypeTerminal InputType = "Terminal" ) // HealthStatus represents HealthStatus. diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index faf9349bf26..75c5e3d2e2f 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -17356,7 +17356,8 @@ public enum InputType implements WireValueEnum { CHOICE("Choice"), BOOLEAN("Boolean"), NUMBER("Number"), - FILE("File"); + FILE("File"), + TERMINAL("Terminal"); private final String value; diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 4ed6ce2c250..f8b601c9574 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1533,7 +1533,7 @@ def _validate_dict_types(args: typing.Any, arg_types: typing.Any) -> bool: ImagePullPolicy = typing.Literal["Default", "Always", "Missing", "Never"] -InputType = typing.Literal["Text", "SecretText", "Choice", "Boolean", "Number", "File"] +InputType = typing.Literal["Text", "SecretText", "Choice", "Boolean", "Number", "File", "Terminal"] MessageIntent = typing.Literal["None", "Success", "Warning", "Error", "Information", "Confirmation"] diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index ce8aaf29563..02878b23860 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -422,6 +422,8 @@ pub enum InputType { Number, #[serde(rename = "File")] File, + #[serde(rename = "Terminal")] + Terminal, } impl std::fmt::Display for InputType { @@ -433,6 +435,7 @@ impl std::fmt::Display for InputType { Self::Boolean => write!(f, "Boolean"), Self::Number => write!(f, "Number"), Self::File => write!(f, "File"), + Self::Terminal => write!(f, "Terminal"), } } } diff --git a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj index 3e77920f6d4..0dc1efddc34 100644 --- a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj +++ b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj @@ -56,7 +56,7 @@ - + diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs index a95890a6bfd..9dbb3dc7467 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs @@ -199,7 +199,7 @@ private static (DashboardServiceData Data, ResourceNotificationService Notificat new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); var data = new DashboardServiceData( notifications, loggerService, diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index daa6a3d3877..b933bacf8c2 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -472,7 +472,7 @@ public async Task WatchInteractions_PromptMessageBoxAsync_CompleteOnResponse(boo new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -544,7 +544,7 @@ public async Task WatchInteractions_NoExplicitLabel_LabelIsName() new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -593,7 +593,7 @@ public async Task WatchInteractions_PromptInputAsync_CompleteOnCancelResponse() new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -654,7 +654,7 @@ public async Task WatchInteractions_ReaderError_CompleteWithError() new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -693,7 +693,7 @@ public async Task WatchInteractions_WriterError_CompleteWithError() new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -1079,7 +1079,7 @@ public async Task SendInteractionRequestAsync_ClientFileTypeForTextInput_DoesNot new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), fileUploadStore, - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var fileInput = new InteractionInput { Name = "File", InputType = InputType.File }; var textInput = new InteractionInput { Name = "Text", InputType = InputType.Text }; @@ -1119,7 +1119,7 @@ public async Task SendInteractionRequestAsync_UsesAuthoritativeFilesAndDisposeDe new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), fileUploadStore, - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var input = new InteractionInput { Name = "File", InputType = InputType.File, Required = true, AllowMultipleFiles = true }; var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input); @@ -1184,7 +1184,7 @@ public async Task SendInteractionRequestAsync_MismatchedFiles_Throws() new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), fileUploadStore, - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var input = new InteractionInput { Name = "File", InputType = InputType.File }; var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input); @@ -1276,7 +1276,7 @@ private static DashboardServiceImpl CreateDashboardService( IConfiguration? configuration = null, ILogger? logger = null, IInteractionFileUploadStore? fileUploadStore = null, - IInteractionTerminalSessionStore? terminalSessionStore = null) + Terminals.TerminalService? terminalService = null) { return new DashboardServiceImpl( dashboardServiceData, @@ -1285,7 +1285,7 @@ private static DashboardServiceImpl CreateDashboardService( configuration ?? new ConfigurationBuilder().Build(), logger ?? NullLogger.Instance, fileUploadStore ?? new TestInteractionFileUploadStore(), - terminalSessionStore ?? new TestInteractionTerminalSessionStore()); + terminalService ?? TestTerminalService.Create()); } private static DashboardServiceData CreateDashboardServiceData( @@ -1305,7 +1305,7 @@ private static DashboardServiceData CreateDashboardServiceData( new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), fileUploadStore, - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); return new DashboardServiceData( resourceNotificationService, diff --git a/tests/Aspire.Hosting.Tests/Dashboard/InteractionTerminalSessionStoreTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/InteractionTerminalSessionStoreTests.cs deleted file mode 100644 index 47e7544b48f..00000000000 --- a/tests/Aspire.Hosting.Tests/Dashboard/InteractionTerminalSessionStoreTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.IO.Pipelines; -using Aspire.Hosting.Dashboard; -using Hex1b; -using Hex1b.Automation; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aspire.Hosting.Tests.Dashboard; - -public class InteractionTerminalSessionStoreTests -{ - private const int InteractionId = 42; - private const string InputName = "shell"; - - [Fact] - public async Task AttachAsync_UnknownInteraction_Throws() - { - using var store = CreateStore(); - - var ex = await Assert.ThrowsAsync( - () => store.AttachAsync(InteractionId, InputName, Stream.Null, CancellationToken.None)); - Assert.Contains("does not have a terminal input", ex.Message); - } - - [Fact] - public async Task AttachAsync_UnknownInput_Throws() - { - using var store = CreateStore(); - store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("true"))]); - - var ex = await Assert.ThrowsAsync( - () => store.AttachAsync(InteractionId, "other", Stream.Null, CancellationToken.None)); - Assert.Contains("does not have a terminal input", ex.Message); - } - - [Fact] - public async Task AttachAsync_AfterInteractionCompleted_Throws() - { - using var store = CreateStore(); - store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("true"))]); - store.CompleteInteraction(InteractionId); - - // The interaction is no longer tracked at all, so this fails the same way an unknown interaction does. - await Assert.ThrowsAsync( - () => store.AttachAsync(InteractionId, InputName, Stream.Null, CancellationToken.None)); - } - - [Fact] - public void StartInteraction_NeverAttached_TearsDownWithoutStartingWorkload() - { - using var store = CreateStore(); - store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("exit 7"))]); - - // No client ever attached, so no terminal was built and teardown must not hang or throw. - store.CancelInteraction(InteractionId); - } - - [Fact] - public async Task AttachAsync_ServesWorkloadOutputOverStream() - { - Assert.SkipWhen(OperatingSystem.IsWindows(), "Uses /bin/sh to produce deterministic workload output."); - - using var store = CreateStore(); - store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("echo aspire-terminal-ok; read line"))]); - - var (serverSide, clientSide) = CreateDuplexPair(); - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - var attachTask = store.AttachAsync(InteractionId, InputName, serverSide, cts.Token); - - await using var client = CreateClientTerminal(clientSide); - var clientRunTask = client.RunAsync(cts.Token); - - var automator = new Hex1bTerminalAutomator(client, TimeSpan.FromSeconds(60)); - await automator.WaitUntilAsync( - snapshot => snapshot.GetText().Contains("aspire-terminal-ok", StringComparison.Ordinal), - description: "workload output rendered on the client terminal"); - - // Tearing down the interaction must release the attached transport rather than stranding it. - store.CompleteInteraction(InteractionId); - await attachTask.WaitAsync(cts.Token); - - // Mirrors AttachTerminal, which disposes the tunnel stream once the attach completes. Without this the client - // has no way to observe that the session is gone. - serverSide.Dispose(); - - await IgnoreShutdownAsync(clientRunTask); - } - - [Fact] - public async Task AttachAsync_WorkloadExit_ReleasesAttachedClient() - { - Assert.SkipWhen(OperatingSystem.IsWindows(), "Uses /bin/sh to produce a workload that exits on its own."); - - using var store = CreateStore(); - store.StartInteraction(InteractionId, [(InputName, CreateServerBuilder("exit 0"))]); - - var (serverSide, clientSide) = CreateDuplexPair(); - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - var attachTask = store.AttachAsync(InteractionId, InputName, serverSide, cts.Token); - - await using var client = CreateClientTerminal(clientSide); - var clientRunTask = client.RunAsync(cts.Token); - - // The workload exits immediately, so the attach must complete without the interaction being torn down. - await attachTask.WaitAsync(cts.Token); - - serverSide.Dispose(); - - await IgnoreShutdownAsync(clientRunTask); - } - - private static InteractionTerminalSessionStore CreateStore() - => new(NullLogger.Instance); - - /// - /// Builds the AppHost-side terminal exactly as a caller would: workload only, no transport. The store attaches the - /// HMP1 server itself, which is the split the interaction input depends on. - /// - private static Hex1bTerminalBuilder CreateServerBuilder(string shellCommand) - { - return Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(80, 24) - .WithPtyProcess("/bin/sh", ["-c", shellCommand]); - } - - /// - /// Builds a real HMP1 client terminal on the far end of the tunnel, standing in for the dashboard's xterm.js client. - /// - private static Hex1bTerminal CreateClientTerminal(Stream clientSide) - { - return Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(80, 24) - .WithHmp1Client(_ => Task.FromResult(clientSide)) - .Build(); - } - - /// - /// Creates two streams wired back to back, standing in for the gRPC tunnel: what one end writes the other reads. - /// - private static (Stream ServerSide, Stream ClientSide) CreateDuplexPair() - { - var serverToClient = new Pipe(); - var clientToServer = new Pipe(); - - var serverSide = new DuplexStream(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()); - var clientSide = new DuplexStream(serverToClient.Reader.AsStream(), clientToServer.Writer.AsStream()); - return (serverSide, clientSide); - } - - private static async Task IgnoreShutdownAsync(Task clientRunTask) - { - try - { - await clientRunTask; - } - catch (Exception) - { - // The client terminal is torn down by the server closing the tunnel; how that surfaces is not under test. - } - } - - private sealed class DuplexStream(Stream reader, Stream writer) : Stream - { - public override bool CanRead => true; - public override bool CanWrite => true; - public override bool CanSeek => false; - public override long Length => throw new NotSupportedException(); - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - => reader.ReadAsync(buffer, cancellationToken); - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - => writer.WriteAsync(buffer, cancellationToken); - - public override int Read(byte[] buffer, int offset, int count) => reader.Read(buffer, offset, count); - - public override void Write(byte[] buffer, int offset, int count) => writer.Write(buffer, offset, count); - - public override void Flush() => writer.Flush(); - - public override Task FlushAsync(CancellationToken cancellationToken) => writer.FlushAsync(cancellationToken); - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - - public override void SetLength(long value) => throw new NotSupportedException(); - - protected override void Dispose(bool disposing) - { - if (disposing) - { - reader.Dispose(); - writer.Dispose(); - } - - base.Dispose(disposing); - } - } -} diff --git a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs index 352cd771503..5395f36c0aa 100644 --- a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs @@ -221,7 +221,7 @@ public void IsAvailable_InteractivityEnabledConfigured_ReturnsExpectedValue(stri new ServiceCollection().BuildServiceProvider(), configuration, new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); // Assert Assert.Equal(expected, interactionService.IsAvailable); @@ -250,7 +250,7 @@ public void IsAvailable_InteractivityEnabledInvalidValue_ReturnsTrue(string conf new ServiceCollection().BuildServiceProvider(), configuration, new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); // Assert - Invalid values should be ignored, defaulting to true (since dashboard is enabled) Assert.True(interactionService.IsAvailable); @@ -274,7 +274,7 @@ public void IsAvailable_InteractivityDisabledAndDashboardDisabled_ReturnsFalse() new ServiceCollection().BuildServiceProvider(), configuration, new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); // Assert - Both conditions should result in false Assert.False(interactionService.IsAvailable); @@ -1344,7 +1344,7 @@ private static InteractionService CreateInteractionService(DistributedApplicatio new ServiceCollection().BuildServiceProvider(), configuration, fileUploadStore ?? new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index 8128719f6e6..db2f371996b 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -1159,7 +1159,7 @@ private static InteractionService CreateInteractionService(DistributedApplicatio new ServiceCollection().BuildServiceProvider(), new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); } private sealed class MockDeploymentStateManager : IDeploymentStateManager diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs index d71441e0f93..cf773c8b297 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs @@ -1295,7 +1295,7 @@ private static InteractionService CreateInteractionService(bool disableDashboard new ServiceCollection().BuildServiceProvider(), new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), new TestInteractionFileUploadStore(), - new TestInteractionTerminalSessionStore()); + TestTerminalService.Create()); } private sealed class MockDeploymentStateManager : IDeploymentStateManager diff --git a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs index 25a6f8df655..09dda8ebd15 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs @@ -1400,6 +1400,6 @@ internal static InteractionService CreateInteractionService() var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(); - return new InteractionService(logger, new DistributedApplicationOptions(), provider, configuration, new TestInteractionFileUploadStore(), new TestInteractionTerminalSessionStore()); + return new InteractionService(logger, new DistributedApplicationOptions(), provider, configuration, new TestInteractionFileUploadStore(), TestTerminalService.Create()); } } diff --git a/tests/Shared/TestInteractionTerminalSessionStore.cs b/tests/Shared/TestInteractionTerminalSessionStore.cs deleted file mode 100644 index fdad35c379f..00000000000 --- a/tests/Shared/TestInteractionTerminalSessionStore.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Concurrent; -using Hex1b; - -namespace Aspire.Hosting.Utils; - -/// -/// An in-memory implementation of for tests. -/// Records lifecycle calls and never starts a real terminal workload. -/// -internal sealed class TestInteractionTerminalSessionStore : IInteractionTerminalSessionStore -{ - public ConcurrentQueue StartedInteractions { get; } = new(); - public ConcurrentQueue> StartedTerminalInputs { get; } = new(); - public ConcurrentQueue CompletedInteractions { get; } = new(); - public ConcurrentQueue CanceledInteractions { get; } = new(); - - public void StartInteraction(int interactionId, IReadOnlyList<(string InputName, Hex1bTerminalBuilder Builder)> terminalInputs) - { - StartedInteractions.Enqueue(interactionId); - StartedTerminalInputs.Enqueue(terminalInputs.ToArray()); - } - - public Task AttachAsync(int interactionId, string inputName, Stream clientStream, CancellationToken cancellationToken) - { - // Tests that exercise attach do so against the real store; this fake only needs to satisfy the contract. - return Task.CompletedTask; - } - - public void CompleteInteraction(int interactionId) => CompletedInteractions.Enqueue(interactionId); - - public void CancelInteraction(int interactionId) => CanceledInteractions.Enqueue(interactionId); -} diff --git a/tests/Shared/TestTerminalService.cs b/tests/Shared/TestTerminalService.cs new file mode 100644 index 00000000000..380bd3e3c56 --- /dev/null +++ b/tests/Shared/TestTerminalService.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Utils; + +/// +/// Creates instances for tests. +/// +/// +/// +/// There is no test double here on purpose. is a registry plus a change-notification +/// fan-out; constructing one is cheap and it never starts a Hex1b workload until something attaches to a terminal. +/// Tests that only need the service to exist as a constructor dependency therefore get better coverage from the real +/// type than from a fake. +/// +/// +internal static class TestTerminalService +{ + public static TerminalService Create() + => new(NullLogger.Instance, new PlaceholderDockTerminalFactory()); +} From 119d370f10b678f3a72b23a812f848028ca4642c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 13:25:02 +1000 Subject: [PATCH 003/106] Render dock terminals chromeless at the dashboard font size Dock terminals previously attached as HMP1 secondaries, which locks the grid to the producer's 80x24 and shrinks the font until that grid fits. In a 1600x276 dock pane that produced 8px text letterboxed into a quarter of the available width, with a card border, titlebar and dims readout wrapped around it. Add an opt-in chromeless mode to TerminalView: - buildChrome skips the titlebar entirely and the injected stylesheet zeroes the frame border, padding and radius so only the xterm grid shows. Frame/body inset constants become per-state helpers so the space calculations stay correct. - applyRoleAwareLayout never takes the secondary branch when chromeless, and the terminal auto-promotes to HMP1 primary on hello, so the producer PTY resizes to the fitted grid instead of the reverse. - The initial font comes from --type-ramp-base-font-size so dock terminals track dashboard text scaling. Only TerminalDock opts in; the console logs view and the interaction input dialog keep their chrome. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.cs | 21 ++- .../Components/Controls/TerminalView.razor.js | 141 +++++++++++++++--- .../Components/Layout/TerminalDock.razor | 2 +- 3 files changed, 139 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 913fc21e126..71bcc5d05fd 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -69,6 +69,18 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Parameter] public string? EndpointPathAndQuery { get; set; } + /// + /// Gets or sets a value indicating whether the terminal renders without its surrounding chrome — no card border, + /// titlebar or internal padding, just the xterm grid. + /// + /// + /// Used by the terminal dock, which supplies its own tab-strip chrome and title. Chromeless terminals also size + /// their grid to fill the available space at the dashboard's base font size, rather than shrinking the font to fit + /// the producer's grid. + /// + [Parameter] + public bool Chromeless { get; set; } + /// /// Raised when the JS side pushes a fresh toolbar state snapshot (role, /// dims, font size, etc.). The host page subscribes so the chrome that @@ -205,7 +217,7 @@ private async Task InitializeTerminalAsync(string endpoint) _connectedGeneration = -1; _terminalId = await _jsModule.InvokeAsync( - "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef); + "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef, new TerminalViewOptions(Chromeless)); } catch (JSDisconnectedException) { @@ -443,6 +455,13 @@ public async ValueTask DisposeAsync() } } +/// +/// Options passed to the JS initTerminal entry point. Serialized with camelCase property names by the default +/// JS interop options, so Chromeless arrives as options.chromeless. +/// +/// Whether to render without the card border, titlebar and internal padding. +public sealed record TerminalViewOptions(bool Chromeless); + /// /// Snapshot of the JS terminal's current role, sizing, and dims, pushed up /// to the host page so the toolbar can render the right state. diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 043f19b6733..3aeb762f7b7 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -350,6 +350,32 @@ function ensureTerminalStyles() { padding: 6px; } +/* + * Chromeless mode — used by the terminal dock, where the surrounding tab + * strip already provides the framing and a title. Everything that makes + * the terminal look like a standalone card is removed (stage padding, + * frame border/radius, titlebar, body padding) so the xterm grid is the + * only thing visible, and the frame stretches to fill the dock pane + * instead of hugging the grid. The JS layout math reads the same values + * from state.frameBorderPx / state.bodyPaddingPx, which are zeroed for + * chromeless terminals — keep the two in sync. + */ +.aspire-terminal-host.chromeless .terminal-pane { + padding: 0; + background: #0d1117; +} +.aspire-terminal-host.chromeless #terminal { + align-items: stretch; +} +.aspire-terminal-host.chromeless #terminal-frame { + flex: 1; + border: none; + border-radius: 0; +} +.aspire-terminal-host.chromeless #terminal-body { + padding: 0; +} + .aspire-terminal-host .xterm:focus, .aspire-terminal-host .xterm:focus-visible { outline: none; @@ -437,7 +463,7 @@ function buildChrome(state) { // it with our own host so we can apply our flex column layout // without disturbing whatever else the parent has set on it. const host = document.createElement('div'); - host.className = 'aspire-terminal-host'; + host.className = state.chromeless ? 'aspire-terminal-host chromeless' : 'aspire-terminal-host'; blazorElement.appendChild(host); // Terminal stage. @@ -451,20 +477,35 @@ function buildChrome(state) { const frame = document.createElement('div'); frame.id = 'terminal-frame'; - const titlebar = document.createElement('div'); - titlebar.id = 'terminal-titlebar'; - const titleText = document.createElement('span'); - titleText.id = 'terminal-title'; - titleText.textContent = 'terminal'; - const dimsText = document.createElement('span'); - dimsText.id = 'terminal-dims'; - dimsText.textContent = ''; - titlebar.append(titleText, dimsText); + // Chromeless hosts get no titlebar at all rather than a hidden one, so + // getAvailableBodySpace measures zero for it and the OSC title handler + // below simply has nothing to write to. The dock renders the title on + // its tab instead. + let titlebar = null; + let titleText = null; + let dimsText = null; + if (!state.chromeless) + { + titlebar = document.createElement('div'); + titlebar.id = 'terminal-titlebar'; + titleText = document.createElement('span'); + titleText.id = 'terminal-title'; + titleText.textContent = 'terminal'; + dimsText = document.createElement('span'); + dimsText.id = 'terminal-dims'; + dimsText.textContent = ''; + titlebar.append(titleText, dimsText); + } const body = document.createElement('div'); body.id = 'terminal-body'; - frame.append(titlebar, body); + if (titlebar) { + frame.append(titlebar, body); + } + else { + frame.append(body); + } terminalContainer.appendChild(frame); host.append(pane); @@ -513,16 +554,28 @@ const FRAME_BORDER_PX = 2; // pass it straight to computeOptimalFont / fit(); fit-mode's body-pin // and pinBodyToNatural add the padding back when they set the outer // body dimensions. +// +// Chromeless terminals (the dock) drop both the border and the padding +// in CSS, so they carry zeroed copies on state — always read the metrics +// through these helpers rather than the constants directly. const TERMINAL_BODY_PADDING_PX = 6; +function frameBorderPx(state) { + return state.chromeless ? 0 : FRAME_BORDER_PX; +} +function bodyPaddingPx(state) { + return state.chromeless ? 0 : TERMINAL_BODY_PADDING_PX; +} function getAvailableBodySpace(state) { const titlebarH = state.terminalTitlebar ? state.terminalTitlebar.offsetHeight : 0; + const border = frameBorderPx(state); + const padding = bodyPaddingPx(state); const stageW = state.terminalContainer ? state.terminalContainer.clientWidth : 0; const stageH = state.terminalContainer ? state.terminalContainer.clientHeight : 0; - const outerW = Math.max(0, stageW - FRAME_BORDER_PX * 2); - const outerH = Math.max(0, stageH - titlebarH - FRAME_BORDER_PX * 2); + const outerW = Math.max(0, stageW - border * 2); + const outerH = Math.max(0, stageH - titlebarH - border * 2); return { - width: Math.max(0, outerW - TERMINAL_BODY_PADDING_PX * 2), - height: Math.max(0, outerH - TERMINAL_BODY_PADDING_PX * 2), + width: Math.max(0, outerW - padding * 2), + height: Math.max(0, outerH - padding * 2), }; } @@ -573,7 +626,14 @@ function applyRoleAwareLayout(state) { const generation = ++state.layoutGeneration; const haveProducerDims = !!state.client && state.client.width > 0 && state.client.height > 0; - const isSecondary = !!state.client && !state.client.isPrimary && haveProducerDims; + // Chromeless terminals always take the font-driven path. The secondary + // branch below locks the grid to the producer's dims and shrinks the + // font until that grid fits, which in a short, wide dock pane collapses + // an 80x24 producer down to ~8px text letterboxed into a fraction of + // the width. The dock wants the opposite: dashboard-sized text and a + // grid that fills the pane, so it fits locally and (once primary) + // pushes the resulting dims back to the producer. + const isSecondary = !state.chromeless && !!state.client && !state.client.isPrimary && haveProducerDims; const availableW = probeW; const availableH = probeH; @@ -612,8 +672,9 @@ function applyRoleAwareLayout(state) { // Font-driven: pin body to fill the pane (content + padding on // each side, since body is border-box); fit() picks cols×rows // for the padded content area. - const bodyW = `${availableW + TERMINAL_BODY_PADDING_PX * 2}px`; - const bodyH = `${availableH + TERMINAL_BODY_PADDING_PX * 2}px`; + const pad = bodyPaddingPx(state); + const bodyW = `${availableW + pad * 2}px`; + const bodyH = `${availableH + pad * 2}px`; if (body.style.width !== bodyW || body.style.height !== bodyH) { body.style.width = bodyW; body.style.height = bodyH; @@ -704,8 +765,9 @@ function pinBodyToNatural(state, root, body) { // body is border-box with padding, so pin the outer size to // (screen dims + padding on each side) — the content area then // matches the xterm-screen dims exactly. - const bodyW = `${w + TERMINAL_BODY_PADDING_PX * 2}px`; - const bodyH = `${h + TERMINAL_BODY_PADDING_PX * 2}px`; + const pad = bodyPaddingPx(state); + const bodyW = `${w + pad * 2}px`; + const bodyH = `${h + pad * 2}px`; if (body.style.width !== bodyW || body.style.height !== bodyH) { body.style.width = bodyW; body.style.height = bodyH; @@ -953,9 +1015,31 @@ function takePrimary(state) { } } -export async function initTerminal(element, wsUrl, dotNetRef) { +// Reads the dashboard's base type-ramp size so a chromeless terminal renders +// at the same scale as the rest of the UI instead of auto-shrinking to fit a +// producer grid. Fluent exports --type-ramp-base-font-size on the document +// root; the clamp keeps a nonsense token value from producing an unreadable +// or absurd grid. +function resolveDashboardFontPx() { + try { + const raw = getComputedStyle(document.documentElement).getPropertyValue('--type-ramp-base-font-size'); + const parsed = Number.parseFloat(raw); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.min(24, Math.max(9, Math.round(parsed))); + } + } catch { /* ignore — fall through to the default */ } + return DEFAULT_FONT_PX; +} + +// `options` is optional: { chromeless: bool }. Chromeless drops the frame, +// titlebar and padding so only the xterm grid shows (used by the terminal +// dock, which supplies its own tab-strip chrome). +export async function initTerminal(element, wsUrl, dotNetRef, options) { await ensureXtermLoaded(); + const chromeless = !!options?.chromeless; + const initialFontPx = chromeless ? resolveDashboardFontPx() : DEFAULT_FONT_PX; + const id = nextId++; const state = { id, @@ -978,15 +1062,16 @@ export async function initTerminal(element, wsUrl, dotNetRef) { generation: 0, }, // Layout / sizing state (per-instance — we never use globals). + chromeless, sizeMode: 'font', fixedDims: null, - currentFontPx: DEFAULT_FONT_PX, + currentFontPx: initialFontPx, // Font size that "Fit" mode uses, tracked separately from // currentFontPx because fixed-preset layout overwrites the latter // with the auto-calculated optimal font. Preserving the user's last // font-mode font here lets setSizeMode('font') restore it when the // user flips back to Fit. - fitFontPx: DEFAULT_FONT_PX, + fitFontPx: initialFontPx, cellWRatio: 0, cellHRatio: 0, layoutGeneration: 0, @@ -1251,6 +1336,16 @@ function connectClient(state, wsUrl) { // role-aware path: secondary locks-and-scales to producer dims; // primary fits/computes-font into the available stage). applyRoleAwareLayout(state); + // Chromeless terminals size the grid from the pane rather than from + // the producer, so those dims are only correct once we are primary + // and can push them upstream. Unlike a resource terminal — which may + // legitimately be driven by a CLI viewer elsewhere — a dock terminal + // is owned by the AppHost purely to be shown here, so claiming + // primary on attach is the expected behaviour rather than snatching + // control from another user. + if (state.chromeless) { + maybeAutoPromote(state); + } }; client.onRoleChange = (payload) => { diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 293d29de04c..bf80c4c378e 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -47,7 +47,7 @@ its grid from the element box, and a display:none pane would refit to zero columns. *@
- +
} @if (_terminals.Count == 0) From fa373e3d80dbd54be89ffb5dc2edb4c4056ba536 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 13:26:17 +1000 Subject: [PATCH 004/106] Add a Resource surface to TerminalSurface Captures the planned third surface for resource-attached terminals so the intent lives in the code rather than in review discussion. Nothing produces the value yet; the remarks explain that every surface check in TerminalService is an explicit test for Dock, so a resource terminal already behaves correctly by default (excluded from the dock tab list, Show() is a no-op). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- src/Aspire.Hosting/Terminals/TerminalSurface.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Terminals/TerminalSurface.cs b/src/Aspire.Hosting/Terminals/TerminalSurface.cs index 251bf6da189..b7719506a6e 100644 --- a/src/Aspire.Hosting/Terminals/TerminalSurface.cs +++ b/src/Aspire.Hosting/Terminals/TerminalSurface.cs @@ -18,5 +18,19 @@ internal enum TerminalSurface /// that interaction's dialog. These are addressed directly by the dialog and are deliberately excluded /// from the dock's tab list. ///
- Interaction + Interaction, + + /// + /// The terminal is attached to a resource in the application model and is displayed on that resource's + /// own terminal view rather than in the dock. + /// + /// + /// Nothing produces this value yet. It exists so that resource terminals — which today are owned by the + /// DCP terminal host rather than by — can be adopted into the same registry + /// and exposed through for automation. Every surface check in + /// is written as an explicit test for , so a resource + /// terminal already behaves correctly by default: it stays out of the dock's tab list and + /// is a no-op for it. + /// + Resource } From 116aed53dbff4afe5c73efe158acd5691b9349c0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 14:26:21 +1000 Subject: [PATCH 005/106] Detach terminals into their own resizable windows Adds a shared popup-window mechanism so any terminal in the dashboard can be opened in a dedicated browser window that is resizable independently of the dashboard layout. - app-terminalwindow.js: a popup registry keyed by an opaque string. Close is detected by polling win.closed rather than pagehide + postMessage, because pagehide never fires on a crash or a force-close and a terminal stuck on "running in a separate window" with no way back is far worse than a 400ms poll. - TerminalWindowLauncher: the reusable C# wrapper over that module, shared by the dock and the resource console page. - TerminalWindow page: a full-viewport chromeless TerminalView routed for both AppHost terminals (/terminal-window/apphost/{id}) and resource terminals (/terminal-window/resource/{name}[/{replica}]). It reaches the dashboard on its own, so it survives the opener being reloaded or closed. The two callers deliberately differ in what happens to the in-page view: - The dock swaps the pane for a placeholder while detached. It must not keep an attached TerminalView, because two attached viewers contend for the HMP1 primary role and would fight over the PTY grid size. Reattaching remounts the view and HMP1 StateSync replays the screen, the same path that already survives a browser reload. - Resource terminals keep the inline view rendering. They are multi-headed and the window is an additional viewer, not a relocation. Verified live against the Terminals playground: dock detach swaps to the placeholder and reattaches when the window closes; resource detach leaves the inline view intact; both windows refit their grid on resize. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor | 36 +++- .../Components/Layout/TerminalDock.razor.cs | 136 +++++++++++++- .../Components/Layout/TerminalDock.razor.css | 24 +++ .../Components/Pages/ConsoleLogs.razor.cs | 57 ++++++ .../Components/Pages/TerminalWindow.razor | 25 +++ .../Components/Pages/TerminalWindow.razor.cs | 174 ++++++++++++++++++ .../Components/Pages/TerminalWindow.razor.css | 25 +++ .../Model/TerminalWindowLauncher.cs | 173 +++++++++++++++++ .../Resources/ConsoleLogs.Designer.cs | 12 ++ .../Resources/ConsoleLogs.resx | 6 + .../Resources/Layout.Designer.cs | 54 ++++++ src/Aspire.Dashboard/Resources/Layout.resx | 18 ++ .../Resources/xlf/ConsoleLogs.cs.xlf | 10 + .../Resources/xlf/ConsoleLogs.de.xlf | 10 + .../Resources/xlf/ConsoleLogs.es.xlf | 10 + .../Resources/xlf/ConsoleLogs.fr.xlf | 10 + .../Resources/xlf/ConsoleLogs.it.xlf | 10 + .../Resources/xlf/ConsoleLogs.ja.xlf | 10 + .../Resources/xlf/ConsoleLogs.ko.xlf | 10 + .../Resources/xlf/ConsoleLogs.pl.xlf | 10 + .../Resources/xlf/ConsoleLogs.pt-BR.xlf | 10 + .../Resources/xlf/ConsoleLogs.ru.xlf | 10 + .../Resources/xlf/ConsoleLogs.tr.xlf | 10 + .../Resources/xlf/ConsoleLogs.zh-Hans.xlf | 10 + .../Resources/xlf/ConsoleLogs.zh-Hant.xlf | 10 + .../Resources/xlf/Layout.cs.xlf | 30 +++ .../Resources/xlf/Layout.de.xlf | 30 +++ .../Resources/xlf/Layout.es.xlf | 30 +++ .../Resources/xlf/Layout.fr.xlf | 30 +++ .../Resources/xlf/Layout.it.xlf | 30 +++ .../Resources/xlf/Layout.ja.xlf | 30 +++ .../Resources/xlf/Layout.ko.xlf | 30 +++ .../Resources/xlf/Layout.pl.xlf | 30 +++ .../Resources/xlf/Layout.pt-BR.xlf | 30 +++ .../Resources/xlf/Layout.ru.xlf | 30 +++ .../Resources/xlf/Layout.tr.xlf | 30 +++ .../Resources/xlf/Layout.zh-Hans.xlf | 30 +++ .../Resources/xlf/Layout.zh-Hant.xlf | 30 +++ .../wwwroot/js/app-terminalwindow.js | 116 ++++++++++++ 39 files changed, 1372 insertions(+), 4 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor create mode 100644 src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.css create mode 100644 src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs create mode 100644 src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index bf80c4c378e..0ebdfcdcf42 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -32,6 +32,18 @@
+ @if (_popupBlocked) + { + @Loc[nameof(Resources.Layout.TerminalDockDetachBlocked)] + } + + + - + @if (_detachedTerminalIds.Contains(terminal.TerminalId)) + { + @* Deliberately does not render a TerminalView. The window is the viewer while detached, and a + second attached viewer would contend for the HMP1 primary role and resize the PTY behind + the window the user is actually typing into. *@ +
+ @Loc[nameof(Resources.Layout.TerminalDockDetached)] +
+ + @Loc[nameof(Resources.Layout.TerminalDockFocusWindow)] + + + @Loc[nameof(Resources.Layout.TerminalDockReturnToDock)] + +
+
+ } + else + { + + }
} @if (_terminals.Count == 0) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 8426579d402..a4f409d0868 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -39,6 +39,17 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener private DotNetObjectReference? _selfRef; private ElementReference _dockElement; + /// + /// Terminals the user has popped out into their own window. The dock keeps the tab — the terminal is still + /// running and still AppHost-owned — but stops rendering a viewer for it, so the window is the only place it is + /// on screen. That is deliberate: a dock pane and a detached window are the same small viewport twice over, and + /// two attached viewers would fight over the HMP1 primary role and therefore over the PTY's grid size. + /// + private readonly HashSet _detachedTerminalIds = []; + + private TerminalWindowLauncher? _windowLauncher; + private bool _popupBlocked; + [Inject] public required IDashboardClient DashboardClient { get; init; } @@ -54,6 +65,9 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener [Inject] public required IJSRuntime JS { get; init; } + [Inject] + public required NavigationManager NavigationManager { get; init; } + public IReadOnlySet SubscribedShortcuts { get; } = new HashSet { AspireKeyboardShortcut.ToggleTerminalDock @@ -152,6 +166,92 @@ private void Activate(string terminalId) StateHasChanged(); } + private TerminalWindowLauncher WindowLauncher + => _windowLauncher ??= new TerminalWindowLauncher(JS, OnDetachedWindowClosedAsync); + + /// + /// Pops the active terminal out into its own window. + /// + private async Task DetachActiveAsync() + { + if (_activeTerminalId is not { } terminalId) + { + return; + } + + _popupBlocked = false; + + try + { + var url = NavigationManager.ToAbsoluteUri($"/terminal-window/apphost/{Uri.EscapeDataString(terminalId)}").ToString(); + var result = await WindowLauncher.OpenAsync(terminalId, url).ConfigureAwait(true); + + if (result is TerminalWindowOpenResult.Blocked) + { + // Surfaced in the tab strip rather than swallowed: to the user, detaching just did nothing. + _popupBlocked = true; + } + else + { + _detachedTerminalIds.Add(terminalId); + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to detach terminal {TerminalId} into a window.", terminalId); + } + } + + private async Task FocusDetachedWindowAsync(string terminalId) + { + try + { + // A window the browser closed without us noticing yet would otherwise leave the pane stuck on the + // placeholder, so a failed focus reattaches instead. + if (!await WindowLauncher.FocusAsync(terminalId).ConfigureAwait(true)) + { + await OnDetachedWindowClosedAsync(terminalId).ConfigureAwait(true); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to focus the window for terminal {TerminalId}.", terminalId); + } + } + + private async Task ReturnToDockAsync(string terminalId) + { + try + { + await WindowLauncher.CloseAsync(terminalId).ConfigureAwait(true); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Reattach regardless: leaving the pane on the placeholder because the close call failed would strand + // the terminal with no viewer at all. + Logger.LogWarning(ex, "Failed to close the window for terminal {TerminalId}.", terminalId); + } + + _detachedTerminalIds.Remove(terminalId); + StateHasChanged(); + } + + /// + /// Reattaches a terminal whose window the user closed. Remounting TerminalView opens a fresh socket and + /// the HMP1 state sync replays the screen, so nothing is lost by having had no viewer in between. + /// + private Task OnDetachedWindowClosedAsync(string terminalId) + { + if (_detachedTerminalIds.Remove(terminalId)) + { + return InvokeAsync(StateHasChanged); + } + + return Task.CompletedTask; + } + private async Task CreateTerminalAsync() { try @@ -196,7 +296,12 @@ private async Task WatchTerminalsAsync(CancellationToken cancellationToken) } else if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Change) { - Apply(update.Change.ChangeType, update.Change.Terminal); + if (Apply(update.Change.ChangeType, update.Change.Terminal) is { } endedTerminalId) + { + // The terminal is gone, so its window is showing a dead grid. Close it here rather than + // leaving the user to notice and dismiss it. + await InvokeAsync(() => CloseDetachedWindowAsync(endedTerminalId)).ConfigureAwait(false); + } } _firstUpdateReceived.TrySetResult(); @@ -218,7 +323,11 @@ private async Task WatchTerminalsAsync(CancellationToken cancellationToken) } } - private void Apply(TerminalChangeType changeType, TerminalDescriptor descriptor) + /// + /// Applies a change from the watch stream. Returns the id of a terminal whose detached window should be closed + /// because the terminal itself has ended, or when there is nothing to close. + /// + private string? Apply(TerminalChangeType changeType, TerminalDescriptor descriptor) { var index = _terminals.FindIndex(t => t.TerminalId == descriptor.TerminalId); @@ -247,7 +356,7 @@ private void Apply(TerminalChangeType changeType, TerminalDescriptor descriptor) var fallback = Math.Min(index, _terminals.Count - 1); _activeTerminalId = fallback >= 0 ? _terminals[fallback].TerminalId : null; } - break; + return _detachedTerminalIds.Remove(descriptor.TerminalId) ? descriptor.TerminalId : null; case TerminalChangeType.Activated: // Raised by IAspireTerminal.Show() in the AppHost, so AppHost code can reveal its own terminal. @@ -260,6 +369,20 @@ private void Apply(TerminalChangeType changeType, TerminalDescriptor descriptor) _isVisible = true; break; } + + return null; + } + + private async Task CloseDetachedWindowAsync(string terminalId) + { + try + { + await WindowLauncher.CloseAsync(terminalId).ConfigureAwait(true); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to close the window for ended terminal {TerminalId}.", terminalId); + } } private static string BuildEndpoint(string terminalId) @@ -283,6 +406,13 @@ public async ValueTask DisposeAsync() _selfRef?.Dispose(); + if (_windowLauncher is { } launcher) + { + // Leaves any detached windows open: they are viewers of AppHost-owned terminals and have no reason to + // die because this circuit went away. + await launcher.DisposeAsync().ConfigureAwait(false); + } + await _cts.CancelAsync().ConfigureAwait(false); if (_watchTask is { } watchTask) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css index eefe7e9de5a..f3a9f1f5846 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css @@ -96,3 +96,27 @@ .terminal-dock-resize-handle:hover { background-color: var(--accent-fill-rest); } + +/* Shown in place of the terminal while it is running in a detached window. */ +.terminal-dock-detached { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + height: 100%; + color: #8b949e; + font-size: 13px; +} + +.terminal-dock-detached-actions { + display: flex; + gap: 8px; +} + +.terminal-dock-popup-blocked { + align-self: center; + padding-right: 8px; + color: #f0883e; + font-size: 12px; +} diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 21f223eb226..d020c3063c6 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -20,6 +20,7 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using Microsoft.JSInterop; +using IToastService = Microsoft.FluentUI.AspNetCore.Components.IToastService; using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; using MenuItemRole = Microsoft.FluentUI.AspNetCore.Components.MenuItemRole; @@ -128,6 +129,9 @@ public void Cancel() [Inject] public required ResourceMenuBuilder ResourceMenuBuilder { get; init; } + [Inject] + public required IToastService ToastService { get; init; } + [CascadingParameter] public required ViewportInformation ViewportInformation { get; init; } @@ -160,6 +164,7 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in private int _terminalReplicaIndex; private Controls.TerminalToolbarState? _terminalToolbarState; private IReadOnlyList _terminalSizePresets = Array.Empty(); + private TerminalWindowLauncher? _terminalWindowLauncher; // View toggle for terminal resources. The page surfaces both LogViewer // and TerminalView in MainSection (both stay mounted so flipping does @@ -715,6 +720,14 @@ private void UpdateMenuButtons() NestedMenuItems = nested, }); } + + _logsMenuItems.Add(new() + { + OnClick = OpenTerminalWindowAsync, + Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarOpenInWindow)], + Icon = new Icons.Regular.Size16.WindowNew(), + IsDisabled = _terminalResourceName is null, + }); } else { @@ -1311,6 +1324,12 @@ public async ValueTask DisposeAsync() await TaskHelpers.WaitIgnoreCancelAsync(_logEntryChannelReaderTask); await CancelAllSubscriptionsAsync(); + + if (_terminalWindowLauncher is not null) + { + await _terminalWindowLauncher.DisposeAsync(); + } + TelemetryContext.Dispose(); } @@ -1488,6 +1507,44 @@ private Task TerminalSizeChangedAsync(string? newKey) return _terminalViewRef.SetSizeModeAsync(newKey); } + // Resource terminals never reattach, so the close callback has nothing to do: the inline view was live the + // whole time the window was open. + private TerminalWindowLauncher TerminalWindowLauncher + => _terminalWindowLauncher ??= new TerminalWindowLauncher(JS, _ => Task.CompletedTask); + + /// + /// Opens the selected resource's terminal in its own resizable window. + /// + /// + /// Unlike the terminal dock, the inline view keeps rendering. Resource terminals are multi-headed, so the window + /// is an additional viewer rather than a relocation, and seeing the session on the resource page while working in + /// a larger window is the point of detaching it. + /// + private async Task OpenTerminalWindowAsync() + { + if (_terminalResourceName is not { Length: > 0 } resourceName) + { + return; + } + + try + { + var path = $"/terminal-window/resource/{Uri.EscapeDataString(resourceName)}/{_terminalReplicaIndex}"; + var result = await TerminalWindowLauncher.OpenAsync( + key: $"resource:{resourceName}:{_terminalReplicaIndex}", + url: NavigationManager.ToAbsoluteUri(path).ToString()).ConfigureAwait(true); + + if (result is TerminalWindowOpenResult.Blocked) + { + ToastService.ShowError(Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarOpenInWindowBlocked)]); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Failed to open a terminal window for resource {ResourceName}.", resourceName); + } + } + // IComponentWithTelemetry impl public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.ConsoleLogs); diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor new file mode 100644 index 00000000000..dc87dcffe04 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor @@ -0,0 +1,25 @@ +@page "/terminal-window/apphost/{TerminalId}" +@page "/terminal-window/resource/{ResourceName}" +@page "/terminal-window/resource/{ResourceName}/{ReplicaIndex:int}" +@layout EmptyLayout +@namespace Aspire.Dashboard.Components.Pages +@using Aspire.Dashboard.Components.Controls +@using Aspire.Dashboard.Components.Layout + +@_title + +@* The terminal is the whole page. There is no nav, no toolbar and no terminal chrome, so the grid gets the entire + window and refits as the user resizes it. *@ +
+ @if (_ended) + { +
@Loc[nameof(Dashboard.Resources.Layout.TerminalWindowEnded)]
+ } + else + { + + } +
diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs new file mode 100644 index 00000000000..ee08f92cee6 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.DashboardService.Proto.V1; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; + +namespace Aspire.Dashboard.Components.Pages; + +/// +/// Renders a single terminal as an entire browser window, with no dashboard chrome around it. +/// +/// +/// +/// This is what the dashboard opens when the user detaches a terminal. Because terminals are multi-headed, the window +/// is just another viewer: it reaches the dashboard on its own and keeps working after the page that spawned it is +/// reloaded or closed. +/// +/// +/// The window is the terminal's whole viewport, so resizing it resizes the grid — that is the reason to detach in the +/// first place, and it comes for free from the chromeless fit layout plus the existing resize observer. +/// +/// +public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable +{ + private readonly CancellationTokenSource _cts = new(); + + private string? _endpoint; + private string _title = string.Empty; + private bool _ended; + private Task? _watchTask; + + /// + /// Gets or sets the id of an AppHost-owned dock terminal to attach to. + /// + [Parameter] + public string? TerminalId { get; set; } + + /// + /// Gets or sets the name of the resource whose terminal to attach to. + /// + [Parameter] + public string? ResourceName { get; set; } + + /// + /// Gets or sets the 0-based replica index of the resource terminal to attach to. + /// + [Parameter] + public int ReplicaIndex { get; set; } + + [Inject] + public required IDashboardClient DashboardClient { get; init; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + protected override void OnParametersSet() + { + if (TerminalId is { Length: > 0 } terminalId) + { + _endpoint = $"/api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}"; + + // The title of an AppHost terminal is owned by the AppHost and can change while the window is open, and + // the terminal can also be closed out from under it. Both arrive on the watch stream, so the window + // follows it rather than showing a stale name or a dead grid. + _title = terminalId; + _watchTask ??= Task.Run(() => WatchTerminalsAsync(terminalId, _cts.Token), _cts.Token); + } + else if (ResourceName is { Length: > 0 } resourceName) + { + // Resource terminals are named by the resource, which does not change for the life of the window. + _endpoint = null; + _title = ReplicaIndex > 0 ? $"{resourceName} #{ReplicaIndex}" : resourceName; + } + } + + private async Task WatchTerminalsAsync(string terminalId, CancellationToken cancellationToken) + { + try + { + await foreach (var update in DashboardClient.SubscribeTerminalsAsync(cancellationToken).ConfigureAwait(false)) + { + var changed = update.KindCase switch + { + WatchTerminalsUpdate.KindOneofCase.Snapshot => ApplySnapshot(terminalId, update.Snapshot), + WatchTerminalsUpdate.KindOneofCase.Change => ApplyChange(terminalId, update.Change), + _ => false + }; + + if (changed) + { + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // The window is closing. + } + catch (Exception ex) + { + // A broken stream only costs the window its title updates; the terminal itself is on a separate socket. + Logger.LogWarning(ex, "Terminal window watch stream ended unexpectedly."); + } + } + + private bool ApplySnapshot(string terminalId, TerminalDescriptorList snapshot) + { + var descriptor = snapshot.Terminals.FirstOrDefault(t => t.TerminalId == terminalId); + if (descriptor is null) + { + // Detached windows can outlive the terminal they were opened for, including across a dashboard restart. + return MarkEnded(); + } + + return SetTitle(descriptor.Title); + } + + private bool ApplyChange(string terminalId, TerminalChangeNotification change) + { + if (change.Terminal.TerminalId != terminalId) + { + return false; + } + + return change.ChangeType is TerminalChangeType.Removed + ? MarkEnded() + : SetTitle(change.Terminal.Title); + } + + private bool SetTitle(string title) + { + if (string.IsNullOrEmpty(title) || _title == title) + { + return false; + } + + _title = title; + return true; + } + + private bool MarkEnded() + { + if (_ended) + { + return false; + } + + _ended = true; + return true; + } + + /// + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync().ConfigureAwait(false); + + if (_watchTask is { } watchTask) + { + try + { + await watchTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + _cts.Dispose(); + } +} diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.css b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.css new file mode 100644 index 00000000000..5c1f77cc913 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.css @@ -0,0 +1,25 @@ +/* The detached window has no dashboard chrome, so the terminal is laid out against the viewport itself rather than + against a page container. Fixed positioning keeps it correct while the user drags the window edge, which is the + whole reason to detach a terminal. */ +.terminal-window { + position: fixed; + inset: 0; + display: flex; + background: #0d1117; + overflow: hidden; +} + +.terminal-window ::deep .aspire-terminal-host { + flex: 1; + min-width: 0; + min-height: 0; +} + +.terminal-window-ended { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #8b949e; + font-size: 13px; +} diff --git a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs new file mode 100644 index 00000000000..6f600bda773 --- /dev/null +++ b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs @@ -0,0 +1,173 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.JSInterop; + +namespace Aspire.Dashboard.Model; + +/// +/// The outcome of asking the browser to open a terminal in its own window. +/// +public enum TerminalWindowOpenResult +{ + /// + /// A new window was opened. + /// + Opened, + + /// + /// A window was already open for this terminal, so it was brought to the front instead. + /// + Focused, + + /// + /// The browser blocked the popup. The caller is expected to tell the user, because from their point of view + /// nothing happened. + /// + Blocked +} + +/// +/// Opens terminals in their own browser windows on behalf of a component, and reports when the user closes one. +/// +/// +/// +/// Detaching a terminal does not move it. Terminals are multi-headed — several viewers can attach to one PTY — and +/// the popup reaches the dashboard on its own, so it outlives the page that opened it. This type only owns the +/// window handle, so a component can focus the window, close it, and learn when it went away. +/// +/// +/// Whether the in-page view keeps rendering while a window is open is the caller's policy, not this type's. The +/// terminal dock replaces the pane with a placeholder because a dock tab and its window are the same viewport in +/// two places; a resource terminal keeps rendering inline, because seeing it in both is the point. +/// +/// +public sealed class TerminalWindowLauncher : IAsyncDisposable +{ + private const int DefaultWindowWidthPx = 960; + private const int DefaultWindowHeightPx = 600; + + private readonly IJSRuntime _js; + private readonly Func _onWindowClosed; + private readonly HashSet _tracked = []; + + private DotNetObjectReference? _selfRef; + private IJSObjectReference? _module; + + /// + /// Initializes a new instance of the class. + /// + /// The JS runtime for the owning component's circuit. + /// + /// Invoked with the terminal key when the user closes a detached window. Not raised for windows closed through + /// , because the caller already knows about those. + /// + public TerminalWindowLauncher(IJSRuntime js, Func onWindowClosed) + { + ArgumentNullException.ThrowIfNull(js); + ArgumentNullException.ThrowIfNull(onWindowClosed); + + _js = js; + _onWindowClosed = onWindowClosed; + } + + /// + /// Opens in a window dedicated to the terminal identified by , or + /// focuses the existing window if one is already open for it. + /// + /// + /// An opaque, page-stable identifier for the terminal — a dock terminal id, or a resource name and replica index. + /// + /// The dashboard URL that renders the detached terminal. + /// Requested window width, in pixels. + /// Requested window height, in pixels. + public async Task OpenAsync( + string key, + string url, + int widthPx = DefaultWindowWidthPx, + int heightPx = DefaultWindowHeightPx) + { + var module = await GetModuleAsync().ConfigureAwait(false); + + var result = await module.InvokeAsync( + "openTerminalWindow", key, url, widthPx, heightPx, _selfRef).ConfigureAwait(false); + + if (result is not "blocked") + { + _tracked.Add(key); + } + + return result switch + { + "opened" => TerminalWindowOpenResult.Opened, + "focused" => TerminalWindowOpenResult.Focused, + _ => TerminalWindowOpenResult.Blocked + }; + } + + /// + /// Brings the window for to the front. Returns if no window is open + /// for it, which the caller can treat as a cue to reattach. + /// + public async Task FocusAsync(string key) + { + var module = await GetModuleAsync().ConfigureAwait(false); + return await module.InvokeAsync("focusTerminalWindow", key).ConfigureAwait(false); + } + + /// + /// Closes the window for . The close callback is deliberately not raised. + /// + public async Task CloseAsync(string key) + { + _tracked.Remove(key); + + var module = await GetModuleAsync().ConfigureAwait(false); + await module.InvokeVoidAsync("closeTerminalWindow", key).ConfigureAwait(false); + } + + /// + /// Called from JS when a detached window is observed to have closed. + /// + [JSInvokable] + public Task OnTerminalWindowClosedAsync(string key) + { + _tracked.Remove(key); + return _onWindowClosed(key); + } + + private async Task GetModuleAsync() + { + // Imported lazily: most sessions never detach a terminal, and the import is only legal once the circuit can + // reach the browser, which rules out doing it in a constructor. + _selfRef ??= DotNetObjectReference.Create(this); + return _module ??= await _js.InvokeAsync( + "import", "/js/app-terminalwindow.js").ConfigureAwait(false); + } + + /// + public async ValueTask DisposeAsync() + { + if (_module is { } module) + { + try + { + // Stop watching, but leave the windows open. They are independent viewers of an AppHost-owned + // terminal, so closing them because the opener navigated away would throw away live work. + foreach (var key in _tracked) + { + await module.InvokeVoidAsync("untrackTerminalWindow", key).ConfigureAwait(false); + } + + await module.DisposeAsync().ConfigureAwait(false); + } + catch (JSDisconnectedException) + { + // The circuit is already gone, so there is nothing left to untrack. + } + } + + _tracked.Clear(); + _selfRef?.Dispose(); + } +} diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index fb2be72e089..98f4fa99a72 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -219,6 +219,18 @@ public static string TerminalToolbarGridSizeAuto { } } + public static string TerminalToolbarOpenInWindow { + get { + return ResourceManager.GetString("TerminalToolbarOpenInWindow", resourceCulture); + } + } + + public static string TerminalToolbarOpenInWindowBlocked { + get { + return ResourceManager.GetString("TerminalToolbarOpenInWindowBlocked", resourceCulture); + } + } + public static string ConsoleLogsViewConsoleOption { get { return ResourceManager.GetString("ConsoleLogsViewConsoleOption", resourceCulture); diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index 7e11a8fee3d..7fef8f36cd1 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -208,6 +208,12 @@ Fit + + Open in new window + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + Console logs Option in the View dropdown that shows the resource's console logs. diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index c82065695cf..68d4ef6be8f 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -123,6 +123,42 @@ public static string TerminalDockEmpty { } } + /// + /// Looks up a localized string similar to The browser blocked the terminal window. Allow pop-ups for the dashboard and try again.. + /// + public static string TerminalDockDetachBlocked { + get { + return ResourceManager.GetString("TerminalDockDetachBlocked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open terminal in a new window. + /// + public static string TerminalDockDetach { + get { + return ResourceManager.GetString("TerminalDockDetach", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This terminal is running in a separate window.. + /// + public static string TerminalDockDetached { + get { + return ResourceManager.GetString("TerminalDockDetached", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Focus window. + /// + public static string TerminalDockFocusWindow { + get { + return ResourceManager.GetString("TerminalDockFocusWindow", resourceCulture); + } + } + /// /// Looks up a localized string similar to Hide terminal panel (Ctrl+`). /// @@ -141,6 +177,24 @@ public static string TerminalDockNewTerminal { } } + /// + /// Looks up a localized string similar to Return to panel. + /// + public static string TerminalDockReturnToDock { + get { + return ResourceManager.GetString("TerminalDockReturnToDock", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This terminal has ended.. + /// + public static string TerminalWindowEnded { + get { + return ResourceManager.GetString("TerminalWindowEnded", resourceCulture); + } + } + /// /// Looks up a localized string similar to Aspire. /// diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 165fc84a2a4..8548b4593a8 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -177,12 +177,30 @@ No terminals are open. + + Open terminal in a new window + + + This terminal is running in a separate window. + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + Focus window + Hide terminal panel (Ctrl+`) New terminal + + Return to panel + + + This terminal has ended. + Untrusted apps can send telemetry to the dashboard. diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf index 3af534814ee..e1bc9cb67f9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf index 17edeb0d221..628394c26b8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf index 270b893183f..c42e8919dcb 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf index 49ae39032d6..67ef1fcdf53 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf index d0c6f0b3b3d..0704dd6d76e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf index df97140c766..9a3c3f1cf9a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf index 62250a55555..52436b1012b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf index ed83fee123c..23d7211e3ff 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf index 47a46d3c60c..8306030bdd8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf index bff41df02fd..bc66b23f773 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf index 909088609cc..8e377c5fdff 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf index 3a93f458c91..e9b479caeb2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf index 2f82fca0ea7..bf75d3cb666 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf @@ -157,6 +157,16 @@ Increase font size + + Open in new window + Open in new window + + + + The browser blocked the terminal window. Allow pop-ups for this site and try again. + The browser blocked the terminal window. Allow pop-ups for this site and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index a85940f02a1..64d7e45e66d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index cad7b48f708..397df951e66 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index c98b5b45701..dbdf12f4c5e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 6c098f7fef7..07d1245a5af 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 25253afef3a..56e825710aa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index f1c7375ed18..b8250640caf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index f40b9423d47..145a04998ab 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 1d1516708e0..1d98de808d6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index d9b82777eb6..cdc742c918b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 5641f0af61b..20de56e93b2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 961a8fc8345..dca73534598 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index d3c44d50e23..9bcbdda1b5b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 1c11b6b383b..0b283cd6b5e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -152,11 +152,31 @@ Close terminal + + Open terminal in a new window + Open terminal in a new window + + + + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. + + + + This terminal is running in a separate window. + This terminal is running in a separate window. + + No terminals are open. No terminals are open. + + Focus window + Focus window + + Hide terminal panel (Ctrl+`) Hide terminal panel (Ctrl+`) @@ -167,6 +187,16 @@ New terminal + + Return to panel + Return to panel + + + + This terminal has ended. + This terminal has ended. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js new file mode 100644 index 00000000000..612781f7866 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Tracks terminal sessions that the user has popped out into their own browser window. +// +// A detached window is not a move: terminals are multi-headed (HMP1 supports several viewers on one PTY), and the +// popup navigates to the dashboard on its own, so it stays alive even if the opener is reloaded or closed. All this +// module owns is the window handle, so the page that opened it can focus it, close it, and find out when the user +// closed it themselves. +// +// Keys are opaque strings chosen by the caller: a dock terminal id, or "resource::". They only have to +// be stable and unique within the page. + +const openWindows = new Map(); +let pollHandle = null; + +// The opener finds out about a closed popup by polling `closed` rather than by listening for a `pagehide` message +// from the popup. `pagehide` does not fire when the tab crashes or is force-closed by the OS, and a terminal that is +// wedged in a "running in a separate window" state with no way back is much worse than a poll that ticks twice a +// second while a window happens to be open. +const POLL_INTERVAL_MS = 400; + +const DEFAULT_FEATURES = 'popup=yes,resizable=yes,scrollbars=no,menubar=no,toolbar=no,location=no,status=no'; + +/** + * Opens a terminal in its own window, or focuses the window if one is already open for this key. + * @returns {'opened'|'focused'|'blocked'} + */ +export function openTerminalWindow(key, url, width, height, owner) { + const existing = openWindows.get(key); + if (existing && !existing.win.closed) { + existing.win.focus(); + return 'focused'; + } + + const features = `${DEFAULT_FEATURES},width=${Math.round(width)},height=${Math.round(height)}`; + + // A name makes the popup reusable: if the user closed the tab that opened it and detaches again, the browser + // targets the same window instead of stacking a second one on top of it. + const win = window.open(url, windowNameFor(key), features); + if (!win) { + // Blocked. The caller surfaces this, because a silently missing window looks like the terminal was lost. + return 'blocked'; + } + + openWindows.set(key, { win, owner }); + ensurePolling(); + return 'opened'; +} + +export function focusTerminalWindow(key) { + const entry = openWindows.get(key); + if (!entry || entry.win.closed) { + return false; + } + + entry.win.focus(); + return true; +} + +/** + * Closes the window for this key. No close notification is raised: the caller is the one asking, so it already + * knows to reattach, and dropping the entry here keeps the poll from reporting a close the caller initiated. + */ +export function closeTerminalWindow(key) { + const entry = openWindows.get(key); + openWindows.delete(key); + + if (entry && !entry.win.closed) { + entry.win.close(); + } +} + +/** + * Stops tracking a window without closing it. Used when the opening component goes away: the popup is an + * independent viewer of an AppHost-owned terminal and has no reason to die with the page that spawned it. + */ +export function untrackTerminalWindow(key) { + openWindows.delete(key); +} + +export function isTerminalWindowOpen(key) { + const entry = openWindows.get(key); + return !!entry && !entry.win.closed; +} + +function windowNameFor(key) { + return `aspire-terminal-${key.replace(/[^a-zA-Z0-9_-]/g, '_')}`; +} + +function ensurePolling() { + if (pollHandle !== null) { + return; + } + + pollHandle = setInterval(() => { + // Snapshot the entries: the .NET callback can re-enter this module (for example by detaching another + // terminal) and mutate the map while we are walking it. + for (const [key, entry] of [...openWindows.entries()]) { + if (!entry.win.closed) { + continue; + } + + openWindows.delete(key); + + // A disposed component leaves a stale reference behind; a failed notification is not worth surfacing + // because the only consequence is that a page which is already going away misses a UI update. + entry.owner.invokeMethodAsync('OnTerminalWindowClosedAsync', key).catch(() => { }); + } + + if (openWindows.size === 0) { + clearInterval(pollHandle); + pollHandle = null; + } + }, POLL_INTERVAL_MS); +} From 2f1616bb0adadd18cfcba3c82cbf625117070d1c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 31 Aug 2026 13:06:20 +1000 Subject: [PATCH 006/106] Improve terminal sizing controls Move terminal sizing controls into the footer, preserve the active resolution across terminal peers, and default new terminals to 132x50. Ensure configured dimensions reach both DCP PTYs and HMP consumers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 395e0f6a-f4c1-4db8-a3dd-e0fbf5392773 --- docs/specs/with-terminal.md | 15 +- .../Components/Controls/TerminalView.razor.cs | 81 +++- .../Components/Controls/TerminalView.razor.js | 446 ++++++++++++++---- .../Components/Pages/ConsoleLogs.razor | 3 +- .../Components/Pages/ConsoleLogs.razor.cs | 145 +----- .../Resources/ConsoleLogs.Designer.cs | 6 + .../Resources/ConsoleLogs.resx | 3 + .../Resources/xlf/ConsoleLogs.cs.xlf | 5 + .../Resources/xlf/ConsoleLogs.de.xlf | 5 + .../Resources/xlf/ConsoleLogs.es.xlf | 5 + .../Resources/xlf/ConsoleLogs.fr.xlf | 5 + .../Resources/xlf/ConsoleLogs.it.xlf | 5 + .../Resources/xlf/ConsoleLogs.ja.xlf | 5 + .../Resources/xlf/ConsoleLogs.ko.xlf | 5 + .../Resources/xlf/ConsoleLogs.pl.xlf | 5 + .../Resources/xlf/ConsoleLogs.pt-BR.xlf | 5 + .../Resources/xlf/ConsoleLogs.ru.xlf | 5 + .../Resources/xlf/ConsoleLogs.tr.xlf | 5 + .../Resources/xlf/ConsoleLogs.zh-Hans.xlf | 5 + .../Resources/xlf/ConsoleLogs.zh-Hant.xlf | 5 + .../ApplicationModel/TerminalAnnotation.cs | 8 +- .../TerminalResourceBuilderExtensions.cs | 2 +- .../Hmp1UdsServerListenerFilter.cs | 121 +++++ src/Aspire.TerminalHost/TerminalHostArgs.cs | 12 +- src/Aspire.TerminalHost/TerminalReplica.cs | 218 +++++---- .../Pages/ConsoleLogsTerminalTests.cs | 20 +- .../Aspire.Hosting.Tests/WithTerminalTests.cs | 4 +- .../TerminalHostAppTests.cs | 124 ++++- .../TerminalHostArgsTests.cs | 4 +- 29 files changed, 903 insertions(+), 374 deletions(-) create mode 100644 src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 8ea33c9db5c..c24526df2ca 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -138,6 +138,17 @@ rendered inside the toolbar's options (⋯) `AspireMenuButton`: visible` transition the page calls `refreshLayout` on the JS terminal to guarantee xterm rebinds to the new available space. +The terminal frame keeps font decrease/increase buttons, the current font +size, and the live columns-by-rows selector together in its bottom-right +footer. The selector offers Fit mode and predefined terminal dimensions, +keeping both sizing operations available without opening the page options +menu. A terminal starts at 132×50. A viewer adopts the producer's current +dimensions, and taking control by typing preserves those dimensions; only an +explicit footer sizing action or a resize from another controlling peer changes +the grid. The bottom-left footer hint advertises F6, which moves +keyboard focus from terminal input to the footer controls; Shift+F6 +moves focus to the preceding dashboard control. + The console log stream is now subscribed to for terminal-enabled resources too (previously it was suppressed), which is what makes the Console view non-empty for a `WithTerminal()` resource. @@ -161,8 +172,8 @@ when the executable (or container) spec carries a populated `terminal` block: "terminal": { "udsPath": "/run/user/1000/aspire/trmnl//-/producer.sock", "socketMode": "connect", - "cols": 120, - "rows": 30 + "cols": 132, + "rows": 50 } } ``` diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 71bcc5d05fd..16ae0937386 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -3,6 +3,7 @@ using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Controls; @@ -58,6 +59,33 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Parameter] public int ReplicaIndex { get; set; } + /// + /// Gets or sets the accessible label for decreasing the font size. + /// + /// + /// The label parameters all default to the shared ConsoleLogs resources so the four hosts that render a + /// terminal (resource page, dock, detached window, interaction dialog) don't each have to thread the same five + /// strings through. Set them only to override. + /// + [Parameter] + public string? DecreaseFontSizeLabel { get; set; } + + /// Gets or sets the accessible label for increasing the font size. + [Parameter] + public string? IncreaseFontSizeLabel { get; set; } + + /// Gets or sets the accessible label for the terminal dimensions selector. + [Parameter] + public string? TerminalDimensionsLabel { get; set; } + + /// Gets or sets the label for fitting the terminal to the available space. + [Parameter] + public string? FitLabel { get; set; } + + /// Gets or sets the hint describing how to move focus from the terminal to its controls. + [Parameter] + public string? FocusControlsHintLabel { get; set; } + /// /// Gets or sets an explicit endpoint (path and query) to connect to, overriding /// and . @@ -82,11 +110,8 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable public bool Chromeless { get; set; } /// - /// Raised when the JS side pushes a fresh toolbar state snapshot (role, - /// dims, font size, etc.). The host page subscribes so the chrome that - /// used to live inside the terminal frame — status badge, "Take control" - /// button, font controls, size dropdown, dims readout — can be rendered - /// in the page's existing toolbar instead. + /// Raised when the JS side pushes a fresh terminal state snapshot (role, + /// dimensions, font size, etc.) for hosts that need to observe it. /// [Parameter] public EventCallback OnToolbarStateChanged { get; set; } @@ -94,6 +119,9 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Inject] public required IJSRuntime JS { get; init; } + [Inject] + public required IStringLocalizer Loc { get; init; } + [Inject] public required NavigationManager NavigationManager { get; init; } @@ -213,11 +241,22 @@ private async Task InitializeTerminalAsync(string endpoint) _jsModule = await JS.InvokeAsync( "import", "/Components/Controls/TerminalView.razor.js"); - _selfRef ??= DotNetObjectReference.Create(this); + if (OnToolbarStateChanged.HasDelegate) + { + _selfRef ??= DotNetObjectReference.Create(this); + } _connectedGeneration = -1; _terminalId = await _jsModule.InvokeAsync( - "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef, new TerminalViewOptions(Chromeless)); + "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef, new TerminalViewOptions + { + Chromeless = Chromeless, + DecreaseFontSize = DecreaseFontSizeLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarDecreaseFontSize)], + IncreaseFontSize = IncreaseFontSizeLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarIncreaseFontSize)], + TerminalDimensions = TerminalDimensionsLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSize)], + Fit = FitLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSizeAuto)], + FocusControlsHint = FocusControlsHintLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalFocusControlsHint)], + }); } catch (JSDisconnectedException) { @@ -284,8 +323,7 @@ public async Task ReconnectAsync(string? newEndpoint) /// /// Invoked by the JS terminal whenever its role/size/font state changes. /// Forwards the snapshot to the host page via . - /// JS remains the source of truth for terminal state — the toolbar - /// renders whatever the most recent snapshot says. + /// JS remains the source of truth for terminal state. /// [JSInvokable] public Task OnTerminalStateChanged(TerminalToolbarState state) @@ -459,12 +497,29 @@ public async ValueTask DisposeAsync() /// Options passed to the JS initTerminal entry point. Serialized with camelCase property names by the default /// JS interop options, so Chromeless arrives as options.chromeless. /// -/// Whether to render without the card border, titlebar and internal padding. -public sealed record TerminalViewOptions(bool Chromeless); +public sealed record TerminalViewOptions +{ + /// Whether to render without the card border, titlebar and internal padding. + public bool Chromeless { get; init; } + + /// Accessible label for the footer's decrease-font-size button. + public required string DecreaseFontSize { get; init; } + + /// Accessible label for the footer's increase-font-size button. + public required string IncreaseFontSize { get; init; } + + /// Accessible label for the footer's dimensions selector. + public required string TerminalDimensions { get; init; } + + /// Label for the option that fits the grid to the available space. + public required string Fit { get; init; } + + /// Hint describing how to move focus from the terminal to its controls. + public required string FocusControlsHint { get; init; } +} /// -/// Snapshot of the JS terminal's current role, sizing, and dims, pushed up -/// to the host page so the toolbar can render the right state. +/// Snapshot of the JS terminal's current role, sizing, and dimensions. /// public sealed record TerminalToolbarState { diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 3aeb762f7b7..7e6a17971fb 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -132,7 +132,7 @@ function cancelPendingReconnect(state) { // In primary mode we drive the producer's PTY dims, so we expose a footer // with two mutually-exclusive sizing modes: // -// "font" (Auto) : user controls font size with +/- buttons; FitAddon +// "font" (Fit) : user controls font size with +/- buttons; FitAddon // picks cols×rows to fill the available stage at that // font. Window resize → fit → new cols×rows broadcast. // @@ -141,33 +141,35 @@ function cancelPendingReconnect(state) { // fill the stage and lock cols×rows. Window resize → // recompute font, cols×rows stay fixed (no broadcast). // -// In secondary mode (someone else is primary), both control groups hide -// (.read-only) and we lock our xterm grid to the producer's cols×rows, -// then pick the largest integer font size whose rendered grid fits the -// viewport (letterboxing on whichever axis has spare room). This mirrors -// primary fixed-mode; we deliberately avoid CSS transform: scale() here -// because xterm.js computes mouse-to-cell coordinates from -// getBoundingClientRect (which returns transformed dims) divided by its -// internally-measured cell width (which is untransformed), so any -// scale ≠ 1 offsets text selection by roughly the scale factor. +// In secondary mode (someone else is primary), changing either control +// promotes this peer to primary. Until then we lock our xterm grid to the +// producer's cols×rows, then pick the largest integer font size whose +// rendered grid fits the viewport (letterboxing on whichever axis has spare +// room). This mirrors primary fixed-mode; we deliberately avoid CSS +// transform: scale() here because xterm.js computes mouse-to-cell coordinates +// from getBoundingClientRect (which returns transformed dims) divided by its +// internally-measured cell width (which is untransformed), so any scale != 1 +// offsets text selection by roughly the scale factor. const MIN_FONT_PX = 4; const MAX_FONT_PX = 72; const DEFAULT_FONT_PX = 13; +const DEFAULT_TERMINAL_COLS = 132; +const DEFAULT_TERMINAL_ROWS = 50; const SIZE_PRESETS = [ - // NOTE: The "Auto" label is overridden on the .NET side in - // ConsoleLogs.razor.cs (OnTerminalToolbarStateChangedAsync) using the - // dashboard's localized resource (ConsoleLogs.resx → - // TerminalToolbarGridSizeAuto). The English string here is only a - // fallback for the rare case where someone consumes the SIZE_PRESETS - // list directly from JS without going through GetSizePresetsAsync — - // we never bind it to the UI as-is. - { value: "auto", label: "Auto", cols: 0, rows: 0 }, + { value: "auto", label: "Fit", cols: 0, rows: 0 }, { value: "80x24", label: "80×24", cols: 80, rows: 24 }, { value: "80x30", label: "80×30", cols: 80, rows: 30 }, { value: "100x30", label: "100×30", cols: 100, rows: 30 }, { value: "132x30", label: "132×30", cols: 132, rows: 30 }, { value: "132x50", label: "132×50", cols: 132, rows: 50 }, ]; +const DEFAULT_CONTROL_LABELS = { + decreaseFontSize: "Decrease font size", + increaseFontSize: "Increase font size", + terminalDimensions: "Terminal dimensions", + fit: "Fit", + focusControlsHint: "F6: Focus terminal controls", +}; // Inject the WebMuxerDemo terminal-frame styles into exactly once // per page load. Lifted near-verbatim from samples/WebMuxerDemo/wwwroot/ @@ -318,19 +320,28 @@ function ensureTerminalStyles() { letter-spacing: 0.2px; } -/* - * Live cols × rows readout on the right side of the titlebar. Kept in - * sync from term.onResize so it always shows the grid the PTY sees. - */ +/* Live grid size and preset selector in the terminal footer. */ .aspire-terminal-host #terminal-dims { flex: 0 0 auto; - margin-left: 12px; - padding-left: 12px; - border-left: 1px solid #30363d; - color: var(--aspire-term-fg-muted); + margin-left: 6px; + padding: 2px 22px 2px 8px; + background: #21262d; + border: 1px solid var(--aspire-term-border); + border-radius: 3px; + color: var(--aspire-term-fg); + font: inherit; font-variant-numeric: tabular-nums; letter-spacing: 0.2px; white-space: nowrap; + cursor: pointer; +} +.aspire-terminal-host #terminal-dims:hover:not(:disabled) { + background: #30363d; + border-color: #484f58; +} +.aspire-terminal-host #terminal-dims:disabled { + opacity: 0.35; + cursor: not-allowed; } .aspire-terminal-host #terminal-body { @@ -375,6 +386,69 @@ function ensureTerminalStyles() { .aspire-terminal-host.chromeless #terminal-body { padding: 0; } +/* + * Chromeless terminals auto-fit their grid to the available space at the + * dashboard font size, so the footer's fixed-size picker and font stepper + * have nothing meaningful to control — and the dock/detached window asked + * for the xterm grid alone. Hide the footer rather than special-casing the + * control wiring, which stays identical for both modes. + */ +.aspire-terminal-host.chromeless #terminal-footer { + display: none; +} + +.aspire-terminal-host #terminal-footer { + flex: 0 0 auto; + min-width: 0; + height: 30px; + padding: 0 14px; + background: linear-gradient(180deg, #1a2029 0%, #161b22 100%); + border-top: 1px solid #30363d; + color: var(--aspire-term-fg-muted); + font: 12px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + user-select: none; +} +.aspire-terminal-host #terminal-focus-hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.aspire-terminal-host #terminal-controls { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; +} +.aspire-terminal-host #terminal-footer button { + width: 22px; + height: 22px; + padding: 0; + background: #21262d; + border: 1px solid var(--aspire-term-border); + border-radius: 3px; + color: var(--aspire-term-fg); + font: 14px/1 ui-monospace, monospace; + cursor: pointer; +} +.aspire-terminal-host #terminal-footer button:hover:not(:disabled) { + background: #30363d; + border-color: #484f58; +} +.aspire-terminal-host #terminal-footer button:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.aspire-terminal-host #font-display { + min-width: 34px; + color: var(--aspire-term-fg); + text-align: center; + font-variant-numeric: tabular-nums; +} .aspire-terminal-host .xterm:focus, .aspire-terminal-host .xterm:focus-visible { @@ -429,13 +503,11 @@ function ensureTerminalStyles() { // #terminal-frame (the bordered/shadowed card) // #terminal-titlebar (title text from OSC 0/2) // #terminal-body (xterm host; sized by layout) +// #terminal-footer (font size + dimensions controls) // -// The status badge, "Take control" button, font controls, size dropdown -// and live dims readout that used to sit inside the chrome have been -// hoisted into the page's toolbar — see ConsoleLogs.razor for the host. -// State snapshots flow up to .NET via `state.dotNetRef` (registered at -// init time) and commands flow back in via the exported wrappers -// `takePrimary`, `setFontSize`, `setSizeModeAuto`, `setSizeModeFixed`. +// State snapshots can still flow up to .NET via `state.dotNetRef` when a +// host subscribes, but the frequently used sizing controls stay beside the +// terminal so they remain accessible without opening the page options menu. // // All lookup roots are scoped to state.host so the layout helpers can // run in pages that might (in the future) host multiple terminals. @@ -483,7 +555,6 @@ function buildChrome(state) { // its tab instead. let titlebar = null; let titleText = null; - let dimsText = null; if (!state.chromeless) { titlebar = document.createElement('div'); @@ -491,20 +562,21 @@ function buildChrome(state) { titleText = document.createElement('span'); titleText.id = 'terminal-title'; titleText.textContent = 'terminal'; - dimsText = document.createElement('span'); - dimsText.id = 'terminal-dims'; - dimsText.textContent = ''; - titlebar.append(titleText, dimsText); + titlebar.appendChild(titleText); } const body = document.createElement('div'); body.id = 'terminal-body'; + // The footer is built even for chromeless hosts so every control + // reference on `state` stays non-null; CSS hides it in that mode. + const footer = buildFooter(state); + if (titlebar) { - frame.append(titlebar, body); + frame.append(titlebar, body, footer); } else { - frame.append(body); + frame.append(body, footer); } terminalContainer.appendChild(frame); host.append(pane); @@ -514,8 +586,133 @@ function buildChrome(state) { state.terminalFrame = frame; state.terminalTitlebar = titlebar; state.titleText = titleText; - state.dimsText = dimsText; state.terminalBody = body; + state.terminalFooter = footer; +} + +function buildFooter(state) { + const footer = document.createElement('div'); + footer.id = 'terminal-footer'; + footer.tabIndex = -1; + + const focusHint = document.createElement('span'); + focusHint.id = 'terminal-focus-hint'; + focusHint.textContent = state.labels.focusControlsHint; + + const fontMinus = document.createElement('button'); + fontMinus.id = 'font-minus'; + fontMinus.type = 'button'; + fontMinus.textContent = '-'; + fontMinus.title = state.labels.decreaseFontSize; + fontMinus.setAttribute('aria-label', state.labels.decreaseFontSize); + fontMinus.disabled = true; + fontMinus.addEventListener('click', () => { + if (fontMinus.disabled) return; + setFontSize(state, state.currentFontPx - 1); + maybeAutoPromote(state); + }); + + const fontDisplay = document.createElement('span'); + fontDisplay.id = 'font-display'; + fontDisplay.textContent = `${state.currentFontPx}px`; + + const fontPlus = document.createElement('button'); + fontPlus.id = 'font-plus'; + fontPlus.type = 'button'; + fontPlus.textContent = '+'; + fontPlus.title = state.labels.increaseFontSize; + fontPlus.setAttribute('aria-label', state.labels.increaseFontSize); + fontPlus.disabled = true; + fontPlus.addEventListener('click', () => { + if (fontPlus.disabled) return; + setFontSize(state, state.currentFontPx + 1); + maybeAutoPromote(state); + }); + + const sizeSelect = document.createElement('select'); + sizeSelect.id = 'terminal-dims'; + sizeSelect.title = state.labels.terminalDimensions; + sizeSelect.setAttribute('aria-label', state.labels.terminalDimensions); + for (const preset of SIZE_PRESETS) { + const option = document.createElement('option'); + option.value = preset.value; + option.textContent = preset.value === 'auto' ? state.labels.fit : preset.label; + sizeSelect.appendChild(option); + } + sizeSelect.disabled = true; + sizeSelect.addEventListener('change', () => { + if (sizeSelect.disabled) return; + const selected = SIZE_PRESETS.find((preset) => preset.value === sizeSelect.value); + if (!selected) return; + + if (selected.value === 'auto') { + setSizeMode(state, 'font', null); + } else { + setSizeMode(state, 'fixed', { cols: selected.cols, rows: selected.rows }); + } + maybeAutoPromote(state); + }); + + const controls = document.createElement('div'); + controls.id = 'terminal-controls'; + controls.append(fontMinus, fontDisplay, fontPlus, sizeSelect); + footer.append(focusHint, controls); + state.terminalFocusHint = focusHint; + state.fontMinusBtn = fontMinus; + state.fontDisplay = fontDisplay; + state.fontPlusBtn = fontPlus; + state.sizeSelect = sizeSelect; + + return footer; +} + +const FOCUSABLE_ELEMENT_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +function moveFocusFromTerminal(state, reverse) { + if (!reverse) { + const firstControl = [state.fontMinusBtn, state.fontPlusBtn, state.sizeSelect] + .find((element) => element && !element.disabled); + (firstControl || state.terminalFooter)?.focus(); + return true; + } + + const focusableElements = Array.from(document.querySelectorAll(FOCUSABLE_ELEMENT_SELECTOR)) + .filter((element) => element.getClientRects().length > 0); + const activeIndex = focusableElements.indexOf(document.activeElement); + for (let index = activeIndex - 1; index >= 0; index--) { + const candidate = focusableElements[index]; + if (!state.host?.contains(candidate)) { + candidate.focus(); + return true; + } + } + + return false; +} + +function attachTerminalFocusNavigation(state, term) { + term.attachCustomKeyEventHandler((event) => { + if (event.key !== 'F6') { + return true; + } + + if (event.type === 'keydown' && moveFocusFromTerminal(state, event.shiftKey)) { + event.preventDefault(); + event.stopPropagation(); + } + + // Returning false prevents xterm from forwarding F6 to the PTY. When + // there is no previous dashboard control, leaving the event's default + // action intact lets the browser apply its own Shift+F6 navigation. + return false; + }); } function safeFit(state) { @@ -536,13 +733,43 @@ function safeFit(state) { } } -function updateDimsReadout(state) { - if (!state.dimsText || !state.term) return; - const cols = state.term.cols | 0; - const rows = state.term.rows | 0; - // xterm briefly reports 0x0 during teardown; suppress that instead of - // flashing a zero-sized readout at the user. - state.dimsText.textContent = cols > 0 && rows > 0 ? `${cols} × ${rows}` : ''; +function updateTerminalControls(state) { + const snapshot = buildToolbarSnapshot(state); + + if (state.fontDisplay) { + state.fontDisplay.textContent = `${state.currentFontPx}px`; + } + if (state.fontMinusBtn) { + state.fontMinusBtn.disabled = !snapshot.fontControlsEnabled || state.currentFontPx <= MIN_FONT_PX; + } + if (state.fontPlusBtn) { + state.fontPlusBtn.disabled = !snapshot.fontControlsEnabled || state.currentFontPx >= MAX_FONT_PX; + } + + if (!state.sizeSelect) return; + + const cols = state.term?.cols | 0; + const rows = state.term?.rows | 0; + const previousCurrentOption = state.sizeSelect.querySelector('option[data-current-dimensions]'); + if (previousCurrentOption) { + previousCurrentOption.remove(); + } + if (snapshot.sizeKey !== 'auto' && + !state.sizeSelect.querySelector(`option[value="${snapshot.sizeKey}"]`)) { + const currentOption = document.createElement('option'); + currentOption.value = snapshot.sizeKey; + currentOption.textContent = `${state.fixedDims.cols}×${state.fixedDims.rows}`; + currentOption.dataset.currentDimensions = ''; + state.sizeSelect.appendChild(currentOption); + } + const fitOption = state.sizeSelect.querySelector('option[value="auto"]'); + if (fitOption) { + fitOption.textContent = cols > 0 && rows > 0 && snapshot.sizeKey === 'auto' + ? `${state.labels.fit} (${cols} × ${rows})` + : state.labels.fit; + } + state.sizeSelect.value = snapshot.sizeKey; + state.sizeSelect.disabled = !snapshot.sizeSelectEnabled; } const FRAME_BORDER_PX = 2; @@ -569,16 +796,31 @@ function getAvailableBodySpace(state) { const titlebarH = state.terminalTitlebar ? state.terminalTitlebar.offsetHeight : 0; const border = frameBorderPx(state); const padding = bodyPaddingPx(state); + const footerH = state.terminalFooter ? state.terminalFooter.offsetHeight : 0; const stageW = state.terminalContainer ? state.terminalContainer.clientWidth : 0; const stageH = state.terminalContainer ? state.terminalContainer.clientHeight : 0; const outerW = Math.max(0, stageW - border * 2); - const outerH = Math.max(0, stageH - titlebarH - border * 2); + const outerH = Math.max(0, stageH - titlebarH - footerH - border * 2); return { width: Math.max(0, outerW - padding * 2), height: Math.max(0, outerH - padding * 2), }; } +// A secondary peer displays the producer's grid rather than choosing its own. +// Record that grid as fixed sizing state so a later keyboard-driven promotion +// keeps the existing resolution. Only an explicit footer action switches back +// to Fit or selects another preset. +function adoptProducerDimensions(state) { + const client = state.client; + if (!client || client.isPrimary || client.width <= 0 || client.height <= 0) { + return; + } + + state.sizeMode = 'fixed'; + state.fixedDims = { cols: client.width, rows: client.height }; +} + // Sizes the xterm display based on the current role and (in primary // mode) the current sizing mode. See docs/muxer-learnings.md §3. // @@ -647,6 +889,13 @@ function applyRoleAwareLayout(state) { root.style.height = ''; } + if (state.sizeMode === 'font') { + // Secondary layout temporarily replaces currentFontPx with the + // font that fits the producer grid. Restore the user's Fit-mode + // font when an explicit sizing action promotes this peer. + state.currentFontPx = state.fitFontPx; + } + if (state.sizeMode === 'fixed' && state.fixedDims) { const optFont = computeOptimalFont(state, state.fixedDims.cols, state.fixedDims.rows, availableW, availableH); if (term.options.fontSize !== optFont) { @@ -902,15 +1151,11 @@ function setSizeMode(state, mode, dims) { } } -// Computes the current toolbar state snapshot and (when changed) pushes -// it up to the Blazor host so the page-level toolbar can render the -// status badge, "Take control" button, font controls, size dropdown and -// dims readout. RAF-coalesced because callers include term.onResize, -// applyRoleAwareLayout's RAF callbacks and ResizeObserver — they can -// fire in rapid bursts during window/sidebar resize. Change-detected -// so a no-op call (e.g. layout pass that produced identical dims) does -// not round-trip to .NET. +// Updates the in-frame controls and, when a host observer is registered, +// pushes a state snapshot to .NET. Observer notifications are RAF-coalesced +// and change-detected because layout callbacks can fire in rapid bursts. function notifyToolbar(state) { + updateTerminalControls(state); if (state._toolbarFlushPending) return; state._toolbarFlushPending = true; requestAnimationFrame(() => { @@ -974,14 +1219,10 @@ function buildToolbarSnapshot(state) { sizeMode: state.sizeMode, sizeKey, fontPx: state.currentFontPx, - // Font/size controls are enabled whenever this tab is primary or - // could become primary on demand. If we're not primary yet, the - // setFontSizeFromHost / setSizeModeFromHost entry points will - // auto-promote before applying the change so the user doesn't have - // to click "Take control" first — this is especially important - // after a WS reconnect, which silently drops primary status. - // Connecting state still gates these off via canTakeControl=false. - fontControlsEnabled: (isPrimary && state.sizeMode === 'font') || canTakeControl, + // Sizing controls are enabled whenever this tab is primary or could + // become primary on demand. A font action explicitly switches fixed + // sizing to Fit mode before applying the requested font size. + fontControlsEnabled: isPrimary || canTakeControl, sizeSelectEnabled: isPrimary || canTakeControl, cols: term && term.cols ? term.cols : 0, rows: term && term.rows ? term.rows : 0, @@ -1031,9 +1272,10 @@ function resolveDashboardFontPx() { return DEFAULT_FONT_PX; } -// `options` is optional: { chromeless: bool }. Chromeless drops the frame, -// titlebar and padding so only the xterm grid shows (used by the terminal -// dock, which supplies its own tab-strip chrome). +// `options` is optional: { chromeless: bool, ...control labels }. Chromeless +// drops the frame, titlebar, footer and padding so only the xterm grid shows +// (used by the terminal dock and detached terminal windows, which supply their +// own chrome). export async function initTerminal(element, wsUrl, dotNetRef, options) { await ensureXtermLoaded(); @@ -1048,11 +1290,9 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { fitAddon: null, element, wsUrl, - // Blazor host (TerminalView) — the JS side pushes state snapshots - // into [JSInvokable] OnTerminalStateChanged so the page-level - // toolbar can render the status badge / take-control button / - // font ± / size dropdown / dims readout. May be null if the - // host opted not to receive notifications. + labels: { ...DEFAULT_CONTROL_LABELS, ...(options || {}) }, + // Optional Blazor host observer for consumers that need terminal + // state beyond the controls rendered directly in the frame. dotNetRef: dotNetRef || null, utf8Decoder: new TextDecoder('utf-8', { fatal: false }), reconnect: { @@ -1063,8 +1303,12 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { }, // Layout / sizing state (per-instance — we never use globals). chromeless, - sizeMode: 'font', - fixedDims: null, + // Chromeless terminals fill whatever space the dock pane or detached + // window gives them at the dashboard's font size, so they start in Fit + // mode. Everything else starts at the default fixed resolution and only + // leaves it when the user explicitly picks another preset. + sizeMode: chromeless ? 'font' : 'fixed', + fixedDims: chromeless ? null : { cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS }, currentFontPx: initialFontPx, // Font size that "Fit" mode uses, tracked separately from // currentFontPx because fixed-preset layout overwrites the latter @@ -1086,8 +1330,13 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { terminalFrame: null, terminalTitlebar: null, titleText: null, - dimsText: null, + sizeSelect: null, terminalBody: null, + terminalFooter: null, + terminalFocusHint: null, + fontMinusBtn: null, + fontDisplay: null, + fontPlusBtn: null, }; // Build the chrome BEFORE creating the xterm — term.open(body) @@ -1148,6 +1397,13 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { state.term = term; state.fitAddon = fitAddon; + attachTerminalFocusNavigation(state, term); + + const helperTextArea = state.terminalBody.querySelector('.xterm-helper-textarea'); + if (helperTextArea && state.terminalFocusHint) { + helperTextArea.setAttribute('aria-keyshortcuts', 'F6 Shift+F6'); + helperTextArea.setAttribute('aria-describedby', state.terminalFocusHint.id); + } // Defense in depth: if Cascadia hadn't entered the FontFace cache // by the time we constructed Terminal (preload above failed/timed @@ -1177,7 +1433,7 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { requestAnimationFrame(() => { calibrateRatios(state); applyRoleAwareLayout(state); - updateDimsReadout(state); + updateTerminalControls(state); }); // OSC 0 / OSC 2 / OSC 1 — terminal apps push window/icon titles via @@ -1192,18 +1448,18 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { // term.onResize fires whenever fitAddon.fit() OR a manual term.resize() // changes the xterm grid. Forward to the producer via sendResize, but // Hmp1Client.sendResize() silently no-ops when we're not primary, so - // viewers' fit() calls don't disturb the producer. Push fresh dims to - // the toolbar and recalibrate ratios so future fixed-mode font calcs - // stay accurate. + // viewers' fit() calls don't disturb the producer. Refresh the live + // dimensions selector and recalibrate ratios so future fixed-mode font + // calculations stay accurate. // // Recalibration is deferred one RAF because xterm dispatches onResize // *before* it re-renders .xterm-screen; measuring offsetWidth here // would divide the old rendered width by the new cols count and yield - // a cellWRatio ~half of the true value. That in turn made the toolbar's - // Fit preview report roughly double the real cols×rows. + // a cellWRatio ~half of the true value. That in turn made the Fit + // dimensions report roughly double the real cols×rows. term.onResize(({ cols, rows }) => { if (state.client) state.client.sendResize(cols, rows); - updateDimsReadout(state); + updateTerminalControls(state); requestAnimationFrame(() => { if (state.term !== term) return; calibrateRatios(state); @@ -1211,12 +1467,11 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { }); }); - // User input auto-promotes to primary. Consolidating the toolbar - // into the ⋯ menu removed the explicit "Take control" button, so we - // rely on the same auto-promote path as font/size changes: if the - // viewer types (or pastes, or hits Enter), they take primary before - // the input goes out. Server drops non-primary input, so promoting - // first ensures the keystroke lands. No-ops when we're already + // User input auto-promotes to primary. There is no explicit "Take + // control" button, so we rely on the same auto-promote path as font/size + // changes: if the viewer types (or pastes, or hits Enter), they take + // primary before the input goes out. Server drops non-primary input, so + // promoting first ensures the keystroke lands. No-ops when we're already // primary or the client isn't connected yet. term.onData((data) => { if (!state.client) return; @@ -1331,6 +1586,7 @@ function connectClient(state, wsUrl) { client.onHello = (payload) => { if (myGeneration !== state.reconnect.generation) return; dbg(state, 'client.onHello', payload); + adoptProducerDimensions(state); notifyToolbar(state); // Now that we know producer dims + role, apply layout (fits the // role-aware path: secondary locks-and-scales to producer dims; @@ -1351,6 +1607,7 @@ function connectClient(state, wsUrl) { client.onRoleChange = (payload) => { if (myGeneration !== state.reconnect.generation) return; dbg(state, 'client.onRoleChange', payload); + adoptProducerDimensions(state); notifyToolbar(state); // Run layout FIRST so fixed-mode (if active) can resize the grid // to fixedDims; the resulting term.onResize will sendResize the @@ -1376,6 +1633,7 @@ function connectClient(state, wsUrl) { client.onResize = (cols, rows) => { if (myGeneration !== state.reconnect.generation) return; dbg(state, 'client.onResize', { cols, rows }); + adoptProducerDimensions(state); // Producer's grid changed (only happens via primary's Resize). // For secondaries this is the trigger to re-fit the frame to // the new producer dims. @@ -1496,15 +1754,11 @@ export function disposeTerminal(id) { terminals.delete(id); } -// --- Toolbar commands ---------------------------------------------------- +// --- Host commands ------------------------------------------------------- // -// These wrappers let the page-level toolbar (ConsoleLogs.razor) drive the -// same actions that used to live inside the terminal's own chrome. Each -// is idempotent and silently no-ops if the terminal id is unknown or the -// underlying client/term isn't ready — JS remains authoritative, so a -// stale toolbar click can't put us into a bad state. Mode/role guards -// match the disabled-state logic in flushToolbarState; we still re-check -// here in case the .NET disabled flag hasn't reached the user's click yet. +// These wrappers let a .NET host drive the same actions as the terminal's +// in-frame controls. Each is idempotent and silently no-ops if the terminal +// id is unknown or the underlying client/term isn't ready. export function getSizePresets() { // Return a copy so .NET-side callers can't accidentally mutate the diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index 6cfd899db51..b40f5466928 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -160,8 +160,7 @@ + ReplicaIndex="@_terminalReplicaIndex" /> } else diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index d020c3063c6..4400d9f69a8 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -162,8 +162,6 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in private bool _selectedResourceHasTerminal; private string? _terminalResourceName; private int _terminalReplicaIndex; - private Controls.TerminalToolbarState? _terminalToolbarState; - private IReadOnlyList _terminalSizePresets = Array.Empty(); private TerminalWindowLauncher? _terminalWindowLauncher; // View toggle for terminal resources. The page surfaces both LogViewer @@ -465,21 +463,6 @@ protected override async Task OnParametersSetAsync() protected override async Task OnAfterRenderAsync(bool firstRender) { - // After a layout transition (e.g. mobile→desktop viewport flip moves - // the toolbar back inline from the mobile filter dialog) the toolbar - // RenderFragment re-evaluates against the page's current state. If - // _terminalToolbarState was cleared during the transition but the - // JS terminal is still alive in MainSection, the toolbar would not - // re-render until the JS side happens to push a new snapshot — and - // JS suppresses no-op pushes via change detection. Ask JS to re-push - // so the toolbar controls reappear. - if (_selectedResourceHasTerminal && - _terminalViewRef is { } terminalView && - _terminalToolbarState is null) - { - await terminalView.RefreshToolbarStateAsync(); - } - // Detect a view-flip TO Terminal and prod xterm to relayout. The // wrapper element transitions from display:none to visible on this // render and ResizeObserver is not guaranteed to fire for that @@ -504,10 +487,6 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa _selectedResourceHasTerminal = false; _terminalResourceName = null; _terminalReplicaIndex = 0; - // Drop any prior terminal's toolbar state so we don't briefly render - // the wrong badge/dims/dropdown for the new resource while the JS - // terminal is initializing and pushing its first snapshot. - _terminalToolbarState = null; // Only (re)default the view on an actual resource-selection change. // SubscribeAsync also runs on filter changes (clearing the console logs @@ -664,60 +643,11 @@ private void UpdateMenuButtons() Checked = _activeView == ConsoleLogsView.Terminal, }); - _logsMenuItems.Add(new() - { - IsDivider = true - }); - } - - if (_activeView == ConsoleLogsView.Terminal) - { - // Terminal-only items: font +/- and a nested Terminal dimensions - // submenu carrying the same presets the old inline toolbar used. - // We render these unconditionally so the menu structure is stable - // even before the first toolbar-state snapshot arrives; enabled - // state and current font readout come from _terminalToolbarState - // when present. - var terminalState = _terminalToolbarState; - var fontPx = terminalState?.FontPx ?? 0; - var fontControlsEnabled = terminalState?.FontControlsEnabled ?? false; - var sizeSelectEnabled = terminalState?.SizeSelectEnabled ?? false; - - _logsMenuItems.Add(new() + if (_activeView == ConsoleLogsView.Console) { - OnClick = TerminalFontMinusAsync, - Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarDecreaseFontSize)], - Icon = new Icons.Regular.Size16.Subtract(), - IsDisabled = !fontControlsEnabled || fontPx <= TerminalFontMin, - }); - - _logsMenuItems.Add(new() - { - OnClick = TerminalFontPlusAsync, - Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarIncreaseFontSize)], - Icon = new Icons.Regular.Size16.Add(), - IsDisabled = !fontControlsEnabled || fontPx >= TerminalFontMax, - }); - - if (_terminalSizePresets.Count > 0) - { - var nested = new List(); - foreach (var preset in _terminalSizePresets) - { - var value = preset.Value; - nested.Add(new() - { - OnClick = () => TerminalSizeChangedAsync(value), - Text = preset.Label, - IsDisabled = !sizeSelectEnabled, - }); - } - _logsMenuItems.Add(new() { - Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSize)], - Icon = new Icons.Regular.Size16.ArrowExpand(), - NestedMenuItems = nested, + IsDivider = true }); } @@ -729,7 +659,8 @@ private void UpdateMenuButtons() IsDisabled = _terminalResourceName is null, }); } - else + + if (_activeView == ConsoleLogsView.Console) { // Console-view items: preserved from the original menu. _logsMenuItems.Add(new() @@ -1385,47 +1316,6 @@ public ConsoleLogsPageState ConvertViewModelToSerializable() return new ConsoleLogsPageState(selectedResourceName); } - // --- Terminal toolbar wiring ----------------------------------------- - // - // The TerminalView component pushes a TerminalToolbarState snapshot up - // here whenever the underlying xterm/HMP1 state changes (role flips, - // resize, font change). Those snapshots drive the page-level toolbar - // that replaces the in-frame chrome the terminal used to render itself. - // JS remains the source of truth for terminal state; this layer just - // mirrors the latest snapshot and routes user actions back to JS via - // the TerminalView public methods. - private const int TerminalFontStep = 1; - private const int TerminalFontMin = 4; - private const int TerminalFontMax = 72; - - private async Task OnTerminalToolbarStateChangedAsync(Controls.TerminalToolbarState state) - { - _terminalToolbarState = state; - - // First snapshot after init — fetch the size preset list once so - // the dropdown stays in sync with whatever JS knows how to handle. - if (_terminalSizePresets.Count == 0 && _terminalViewRef is not null) - { - // The JS side ships labels as English string literals (it has no - // localization stack of its own). Numeric labels like "80×24" are - // language-neutral and pass through unchanged, but "Auto" is an - // English word and must come from the dashboard's .resx so it - // matches the rest of the terminal toolbar in every supported - // culture. Apply the localized label here, where we still have - // access to IStringLocalizer, before - // handing the list to FluentSelect. - var presets = await _terminalViewRef.GetSizePresetsAsync(); - _terminalSizePresets = presets - .Select(p => p.Value == "auto" - ? p with { Label = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSizeAuto)] } - : p) - .ToList(); - } - - UpdateMenuButtons(); - StateHasChanged(); - } - private Task HandleViewChangedAsync(string? newView) { if (newView is null) @@ -1480,33 +1370,6 @@ private Task HandleViewChangedAsync(string? newView) internal ResourceViewModel? GetResourceSnapshotForTest(string resourceName) => _resourceByName.TryGetValue(resourceName, out var resource) ? resource : null; - private Task TerminalFontMinusAsync() - { - if (_terminalToolbarState is not { } s || _terminalViewRef is null) - { - return Task.CompletedTask; - } - return _terminalViewRef.SetFontSizeAsync(Math.Max(TerminalFontMin, s.FontPx - TerminalFontStep)); - } - - private Task TerminalFontPlusAsync() - { - if (_terminalToolbarState is not { } s || _terminalViewRef is null) - { - return Task.CompletedTask; - } - return _terminalViewRef.SetFontSizeAsync(Math.Min(TerminalFontMax, s.FontPx + TerminalFontStep)); - } - - private Task TerminalSizeChangedAsync(string? newKey) - { - if (newKey is null || _terminalViewRef is null) - { - return Task.CompletedTask; - } - return _terminalViewRef.SetSizeModeAsync(newKey); - } - // Resource terminals never reattach, so the close callback has nothing to do: the inline view was live the // whole time the window was open. private TerminalWindowLauncher TerminalWindowLauncher diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index 98f4fa99a72..52a18f51e6e 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -218,6 +218,12 @@ public static string TerminalToolbarGridSizeAuto { return ResourceManager.GetString("TerminalToolbarGridSizeAuto", resourceCulture); } } + + public static string TerminalFocusControlsHint { + get { + return ResourceManager.GetString("TerminalFocusControlsHint", resourceCulture); + } + } public static string TerminalToolbarOpenInWindow { get { diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index 7fef8f36cd1..c6f9c5b9517 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -214,6 +214,9 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + F6: Focus terminal controls + Console logs Option in the View dropdown that shows the resource's console logs. diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf index e1bc9cb67f9..9700ce24f97 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf index 628394c26b8..4cf787b9873 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf index c42e8919dcb..f187756bd9f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf index 67ef1fcdf53..f6f6590a2fe 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf index 0704dd6d76e..520b103c096 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf index 9a3c3f1cf9a..c9189e15b2b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf index 52436b1012b..ec7edda1f6d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf index 23d7211e3ff..bc406fd62e8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf index 8306030bdd8..08279df1406 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf index bc66b23f773..6268d9fa654 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf index 8e377c5fdff..fec24041fa5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf index e9b479caeb2..6a6bd00eee6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf index bf75d3cb666..20ca65da0b9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf @@ -137,6 +137,11 @@ Console logs capture paused at {0} {0} is a time + + F6: Focus terminal controls + F6: Focus terminal controls + + Decrease font size Decrease font size diff --git a/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs index 2da91522c18..bb27a3b94bc 100644 --- a/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs @@ -96,11 +96,11 @@ internal void Initialize(IReadOnlyList terminalHosts) [Experimental("ASPIRETERMINAL001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public sealed class TerminalOptions { - private int _columns = 120; - private int _rows = 30; + private int _columns = 132; + private int _rows = 50; /// - /// Gets or sets the initial number of columns for the terminal. The value must be greater than zero. Defaults to 120. + /// Gets or sets the initial number of columns for the terminal. The value must be greater than zero. Defaults to 132. /// /// Thrown when set to zero or a negative value. public int Columns @@ -114,7 +114,7 @@ public int Columns } /// - /// Gets or sets the initial number of rows for the terminal. The value must be greater than zero. Defaults to 30. + /// Gets or sets the initial number of rows for the terminal. The value must be greater than zero. Defaults to 50. /// /// Thrown when set to zero or a negative value. public int Rows diff --git a/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs b/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs index 70b0577e6d6..7db151f5ae9 100644 --- a/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs @@ -116,7 +116,7 @@ public static IResourceBuilder WithTerminal(this IResourceBuilder build /// Polyglot dispatcher for . /// Exposed to non-C# AppHosts via ATS as withTerminal — they cannot pass a /// C# , so this overload simply applies the defaults from - /// (120×30). Polyglot AppHosts that need to customise + /// (132×50). Polyglot AppHosts that need to customise /// the terminal dimensions can wait for a future overload that accepts a DTO. ///
/// Adds an interactive terminal session to a resource using the default terminal options. diff --git a/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs b/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs new file mode 100644 index 00000000000..a005f25ff9f --- /dev/null +++ b/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs @@ -0,0 +1,121 @@ +// 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.Tokens; +using Microsoft.Extensions.Logging; + +namespace Aspire.TerminalHost; + +/// +/// Hosts an HMP1 presentation adapter on a Unix domain socket for one terminal session. +/// +internal sealed class Hmp1UdsServerListenerFilter( + string socketPath, + Hmp1PresentationAdapter presentation, + ILogger logger) : IHex1bTerminalPresentationFilter +{ + private CancellationTokenSource? _listenerCts; + private Task? _listenerTask; + + /// + public ValueTask OnSessionStartAsync( + int width, + int height, + DateTimeOffset timestamp, + CancellationToken ct = default) + { + _listenerCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _listenerTask = Task.Run(() => RunListenerAsync(_listenerCts.Token), _listenerCts.Token); + + return ValueTask.CompletedTask; + } + + /// + public ValueTask> OnOutputAsync( + IReadOnlyList appliedTokens, + TimeSpan elapsed, + CancellationToken ct = default) + { + return ValueTask.FromResult>( + appliedTokens.Select(t => t.Token).ToList()); + } + + /// + public ValueTask OnInputAsync( + IReadOnlyList tokens, + TimeSpan elapsed, + CancellationToken ct = default) + { + return ValueTask.CompletedTask; + } + + /// + public ValueTask OnResizeAsync( + int width, + int height, + TimeSpan elapsed, + CancellationToken ct = default) + { + return ValueTask.CompletedTask; + } + + /// + public async ValueTask OnSessionEndAsync(TimeSpan elapsed, CancellationToken ct = default) + { + if (_listenerCts is null) + { + return; + } + + await _listenerCts.CancelAsync().ConfigureAwait(false); + + if (_listenerTask is not null) + { + try + { + await _listenerTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + _listenerCts.Dispose(); + } + + private async Task RunListenerAsync(CancellationToken ct) + { + try + { + await foreach (var stream in Hmp1Transports.ListenUnixSocket(socketPath, ct).ConfigureAwait(false)) + { + _ = AddClientAsync(stream, ct); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + } + } + + private async Task AddClientAsync(Stream stream, CancellationToken ct) + { + try + { + await presentation.AddClient(stream, ct).ConfigureAwait(false); + } + catch (Exception ex) when ( + ex is IOException or ObjectDisposedException or OperationCanceledException or InvalidOperationException) + { + try + { + await stream.DisposeAsync().ConfigureAwait(false); + } + catch (Exception disposeException) when ( + disposeException is IOException or ObjectDisposedException or OperationCanceledException) + { + logger.LogDebug(disposeException, "Failed to dispose an HMP1 consumer stream after its session ended."); + } + } + } +} diff --git a/src/Aspire.TerminalHost/TerminalHostArgs.cs b/src/Aspire.TerminalHost/TerminalHostArgs.cs index 67bf79c2c5f..60a2a442251 100644 --- a/src/Aspire.TerminalHost/TerminalHostArgs.cs +++ b/src/Aspire.TerminalHost/TerminalHostArgs.cs @@ -30,8 +30,8 @@ internal sealed class TerminalHostArgs public required string ProducerUdsPath { get; init; } public required string ConsumerUdsPath { get; init; } public required string ControlUdsPath { get; init; } - public int Columns { get; init; } = 120; - public int Rows { get; init; } = 30; + public int Columns { get; init; } = 132; + public int Rows { get; init; } = 50; /// /// Parses command-line arguments. The argument shape is: @@ -39,8 +39,8 @@ internal sealed class TerminalHostArgs /// --producer-uds PATH (required) — path the host LISTENS on; DCP dials. /// --consumer-uds PATH (required) — path the host LISTENS on; viewers dial. /// --control-uds PATH (required) — path the host LISTENS on; AppHost dials for status/shutdown RPC. - /// --columns N (optional, default 120) - /// --rows N (optional, default 30) + /// --columns N (optional, default 132) + /// --rows N (optional, default 50) /// --shell NAME (optional, accepted for compatibility and ignored) /// /// Every option is single-valued and may only be specified once; duplicates throw @@ -60,9 +60,9 @@ public static TerminalHostArgs Parse(string[] args) "Path the terminal host LISTENS on for the AppHost control RPC channel."); var columnsOption = SingleValueOption("--columns", required: false, - "Initial PTY width in columns (default 120).", defaultValue: 120); + "Initial PTY width in columns (default 132).", defaultValue: 132); var rowsOption = SingleValueOption("--rows", required: false, - "Initial PTY height in rows (default 30).", defaultValue: 30); + "Initial PTY height in rows (default 50).", defaultValue: 50); // Older Aspire.Hosting packages can run with a newer CLI-provided terminal host // and still emit --shell. Accept the argument so that mixed-version AppHosts start, // but ignore it because DCP launches the resource process that owns the PTY. diff --git a/src/Aspire.TerminalHost/TerminalReplica.cs b/src/Aspire.TerminalHost/TerminalReplica.cs index a064bf4284f..4799edcc550 100644 --- a/src/Aspire.TerminalHost/TerminalReplica.cs +++ b/src/Aspire.TerminalHost/TerminalReplica.cs @@ -47,6 +47,7 @@ internal sealed class TerminalReplica : IAsyncDisposable { private readonly ILogger _logger; private readonly ILogger _upstreamLogger; + private readonly ILogger _consumerListenerLogger; private readonly Task _runTask; private readonly CancellationTokenSource _stopCts; private readonly object _gate = new(); @@ -179,6 +180,7 @@ private TerminalReplica( _currentRows = rows; _logger = loggerFactory.CreateLogger(); _upstreamLogger = loggerFactory.CreateLogger(); + _consumerListenerLogger = loggerFactory.CreateLogger(); _stopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _runTask = Task.Run(() => RecycleLoopAsync(_stopCts.Token), _stopCts.Token); } @@ -417,113 +419,129 @@ private Hex1bTerminal BuildTerminal() _logger.LogInformation("DCP producer disconnected; replica will rebind."); }; + // Hex1b's convenience HMP server creates its presentation adapter with an + // 80x24 default, which then overrides WithDimensions during Build(). Construct + // the adapter directly so both its Hello frames and the upstream PTY start with + // the dimensions configured by WithTerminal. + var downstream = CreateDownstream(upstream); + return Hex1bTerminal.CreateBuilder() .WithDimensions(Columns, Rows) .WithWorkload(upstream) - .WithHmp1UdsServer( + .WithPresentation(downstream) + .AddPresentationFilter(new Hmp1UdsServerListenerFilter( ConsumerUdsPath, - srvOpts => - { - // Track every HMP1 peer that connects/disconnects so the host can answer - // "who's currently attached to this replica?" via the control RPC. PeerId is - // assigned by Hex1b at handshake time and is unique per connection; DisplayName - // is the optional ClientHello label (e.g. "aspire.cli:1234", "dashboard:abc12345"). - srvOpts.OnClientConnected = (e, _) => - { - lock (_gate) - { - _peers[e.PeerId] = new TerminalHostPeerInfo - { - PeerId = e.PeerId, - DisplayName = e.DisplayName, - }; - } - // Tag with peer attributes — viewer counts are low (single digits in - // practice: one dashboard tab + maybe a CLI attach), so high-cardinality - // worries don't apply here. Helps diagnose "which viewer is causing the - // resize storm" in the dashboard metric explorer. - var tags = new TagList - { - { "peer.id", e.PeerId }, - { "peer.name", e.DisplayName ?? "" }, - }; - TerminalHostTelemetry.ConsumerConnections.Add(1, tags); - TerminalHostTelemetry.ConsumerPeersActive.Add(1, tags); - _logger.LogInformation( - "Consumer peer connected. PeerId={PeerId}, DisplayName='{DisplayName}'.", - e.PeerId, e.DisplayName); - return Task.CompletedTask; - }; - srvOpts.OnClientDisconnected = (e, _) => - { - string? displayName; - lock (_gate) - { - _peers.TryGetValue(e.PeerId, out var existing); - displayName = existing?.DisplayName; - _peers.Remove(e.PeerId); - } - var tags = new TagList - { - { "peer.id", e.PeerId }, - { "peer.name", displayName ?? "" }, - }; - TerminalHostTelemetry.ConsumerDisconnections.Add(1, tags); - TerminalHostTelemetry.ConsumerPeersActive.Add(-1, tags); - _logger.LogInformation( - "Consumer peer disconnected. PeerId={PeerId}.", e.PeerId); - return Task.CompletedTask; - }; - - // Bridge downstream → upstream resize. The consumer-side multi-head - // server fires OnResized whenever the current primary peer's dims - // change (RequestPrimary or explicit Resize from primary). Forward - // those dims as a raw FrameResize upstream so DCP runs ConPty.Resize - // and the underlying workload sees the new TIOCSWINSZ value. Without - // this hook the consumer-side presentation reflects the new dims but - // the actual PTY stays at whatever DCP started it at. - // - // Also persist the latest dimensions so `aspire terminal ps` and the - // dashboard can report the current grid size without round-tripping to - // every attached viewer. - srvOpts.OnResized = async (e, ct) => - { - lock (_gate) - { - _currentColumns = e.Width; - _currentRows = e.Height; - } - - TerminalHostTelemetry.ResizeRequests.Add(1, new TagList - { - { "direction", "downstream" }, - }); - _logger.LogDebug( - "Downstream resize received from primary peer: {Width}x{Height}.", - e.Width, e.Height); - - try - { - await upstream.ResizeAsync(e.Width, e.Height, ct).ConfigureAwait(false); - _logger.LogDebug( - "Replica: forwarded downstream resize ({Width}x{Height}) to upstream PTY.", - e.Width, e.Height); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - // Recycle loop is shutting down; drop quietly. - } - catch (Exception ex) - { - _logger.LogDebug(ex, - "Replica: forwarding downstream resize ({Width}x{Height}) upstream failed.", - e.Width, e.Height); - } - }; - }) + downstream, + _consumerListenerLogger)) .Build(); } + private Hmp1PresentationAdapter CreateDownstream(DcpUpstreamAdapter upstream) + { + var downstream = new Hmp1PresentationAdapter(Columns, Rows); + + // Track every HMP1 peer that connects/disconnects so the host can answer + // "who's currently attached to this replica?" via the control RPC. PeerId is + // assigned by Hex1b at handshake time and is unique per connection; DisplayName + // is the optional ClientHello label (e.g. "aspire.cli:1234", "dashboard:abc12345"). + downstream.OnClientConnected = (e, _) => + { + lock (_gate) + { + _peers[e.PeerId] = new TerminalHostPeerInfo + { + PeerId = e.PeerId, + DisplayName = e.DisplayName, + }; + } + // Tag with peer attributes — viewer counts are low (single digits in + // practice: one dashboard tab + maybe a CLI attach), so high-cardinality + // worries don't apply here. Helps diagnose "which viewer is causing the + // resize storm" in the dashboard metric explorer. + var tags = new TagList + { + { "peer.id", e.PeerId }, + { "peer.name", e.DisplayName ?? "" }, + }; + TerminalHostTelemetry.ConsumerConnections.Add(1, tags); + TerminalHostTelemetry.ConsumerPeersActive.Add(1, tags); + _logger.LogInformation( + "Consumer peer connected. PeerId={PeerId}, DisplayName='{DisplayName}'.", + e.PeerId, e.DisplayName); + + return Task.CompletedTask; + }; + downstream.OnClientDisconnected = (e, _) => + { + string? displayName; + lock (_gate) + { + _peers.TryGetValue(e.PeerId, out var existing); + displayName = existing?.DisplayName; + _peers.Remove(e.PeerId); + } + var tags = new TagList + { + { "peer.id", e.PeerId }, + { "peer.name", displayName ?? "" }, + }; + TerminalHostTelemetry.ConsumerDisconnections.Add(1, tags); + TerminalHostTelemetry.ConsumerPeersActive.Add(-1, tags); + _logger.LogInformation( + "Consumer peer disconnected. PeerId={PeerId}.", e.PeerId); + + return Task.CompletedTask; + }; + + // Bridge downstream → upstream resize. The consumer-side multi-head + // server fires OnResized whenever the current primary peer's dims + // change (RequestPrimary or explicit Resize from primary). Forward + // those dims as a raw FrameResize upstream so DCP runs ConPty.Resize + // and the underlying workload sees the new TIOCSWINSZ value. Without + // this hook the consumer-side presentation reflects the new dims but + // the actual PTY stays at whatever DCP started it at. + // + // Also persist the latest dimensions so `aspire terminal ps` and the + // dashboard can report the current grid size without round-tripping to + // every attached viewer. + downstream.OnResized = async (e, ct) => + { + lock (_gate) + { + _currentColumns = e.Width; + _currentRows = e.Height; + } + + TerminalHostTelemetry.ResizeRequests.Add(1, new TagList + { + { "direction", "downstream" }, + }); + _logger.LogDebug( + "Downstream resize received from primary peer: {Width}x{Height}.", + e.Width, e.Height); + + try + { + await upstream.ResizeAsync(e.Width, e.Height, ct).ConfigureAwait(false); + _logger.LogDebug( + "Replica: forwarded downstream resize ({Width}x{Height}) to upstream PTY.", + e.Width, e.Height); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Recycle loop is shutting down; drop quietly. + } + catch (Exception ex) + { + _logger.LogDebug(ex, + "Replica: forwarding downstream resize ({Width}x{Height}) upstream failed.", + e.Width, e.Height); + } + }; + + return downstream; + } + public async ValueTask DisposeAsync() { if (_disposed) diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index 6dbb0a90562..2e01e5afaa8 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using System.Threading.Channels; using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Pages; @@ -127,6 +128,7 @@ public async Task TerminalResource_ViewPicker_MarksActiveViewAsChecked() // the live resource defaults to Terminal, so only the Terminal item is // checked. cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Terminal); + Assert.Equal(2, instance.LogsMenuItemsForTest.Count); Assert.Equal(MenuItemRole.MenuItemCheckbox, instance.LogsMenuItemsForTest[0].Role); Assert.Equal(MenuItemRole.MenuItemCheckbox, instance.LogsMenuItemsForTest[1].Role); Assert.False(instance.LogsMenuItemsForTest[0].Checked); @@ -592,12 +594,22 @@ public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitia { builder.Add(p => p.ResourceName, "first-resource"); builder.Add(p => p.ReplicaIndex, 0); + builder.Add(p => p.DecreaseFontSizeLabel, "Decrease font size"); + builder.Add(p => p.IncreaseFontSizeLabel, "Increase font size"); + builder.Add(p => p.TerminalDimensionsLabel, "Terminal dimensions"); + builder.Add(p => p.FitLabel, "Fit"); + builder.Add(p => p.FocusControlsHintLabel, "F6: Focus terminal controls"); }); cut.SetParametersAndRender(builder => { builder.Add(p => p.ResourceName, "second-resource"); builder.Add(p => p.ReplicaIndex, 1); + builder.Add(p => p.DecreaseFontSizeLabel, "Decrease font size"); + builder.Add(p => p.IncreaseFontSizeLabel, "Increase font size"); + builder.Add(p => p.TerminalDimensionsLabel, "Terminal dimensions"); + builder.Add(p => p.FitLabel, "Fit"); + builder.Add(p => p.FocusControlsHintLabel, "F6: Focus terminal controls"); }); initTerminal.SetResult(1); @@ -608,9 +620,15 @@ public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitia var reconnect = Assert.Single(reconnectTerminal.Invocations); var initUrl = Assert.IsType(init.Arguments[1]); var reconnectUrl = Assert.IsType(reconnect.Arguments[1]); + var labels = JsonSerializer.SerializeToElement(init.Arguments[3], JsonSerializerOptions.Web); Assert.Contains("resource=first-resource", initUrl); Assert.Contains("replica=0", initUrl); + Assert.Equal("Decrease font size", labels.GetProperty("decreaseFontSize").GetString()); + Assert.Equal("Increase font size", labels.GetProperty("increaseFontSize").GetString()); + Assert.Equal("Terminal dimensions", labels.GetProperty("terminalDimensions").GetString()); + Assert.Equal("Fit", labels.GetProperty("fit").GetString()); + Assert.Equal("F6: Focus terminal controls", labels.GetProperty("focusControlsHint").GetString()); Assert.Equal(1, reconnect.Arguments[0]); Assert.Contains("resource=second-resource", reconnectUrl); Assert.Contains("replica=1", reconnectUrl); @@ -668,8 +686,6 @@ private void SetupTerminalViewJsInterop() module.Setup("reconnectTerminal", _ => true).SetResult(2); module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); module.SetupVoid("refreshLayout", _ => true).SetVoidResult(); - module.SetupVoid("refreshToolbarState", _ => true).SetVoidResult(); - module.Setup("getSizePresets").SetResult([]); } private static ResourceViewModel CreateTerminalResource(string resourceName, int replicaIndex, int replicaCount, KnownResourceState state = KnownResourceState.Running) diff --git a/tests/Aspire.Hosting.Tests/WithTerminalTests.cs b/tests/Aspire.Hosting.Tests/WithTerminalTests.cs index e7d621e4ad8..0a38183c659 100644 --- a/tests/Aspire.Hosting.Tests/WithTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/WithTerminalTests.cs @@ -43,8 +43,8 @@ public async Task WithTerminalAddsTerminalAnnotation() var annotation = resource.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); - Assert.Equal(120, annotation.Options.Columns); - Assert.Equal(30, annotation.Options.Rows); + Assert.Equal(132, annotation.Options.Columns); + Assert.Equal(50, annotation.Options.Rows); // Until BeforeStartEvent fires the per-replica hosts are not yet materialized: // TerminalHosts is empty and IsInitialized is false. This deferral is what diff --git a/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs b/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs index fce93a14078..9df747f44f1 100644 --- a/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs +++ b/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net.Sockets; +using System.Text.Json; using Aspire.Shared.TerminalHost; using Microsoft.Extensions.Logging.Abstractions; using StreamJsonRpc; @@ -20,7 +21,9 @@ public class TerminalHostAppTests(ITestOutputHelper outputHelper) /// producer/consumer/control UDS path triple. The replica index is opaque to the /// host — callers encode it however they like in the path layout. /// - private (TerminalHostArgs args, TemporaryWorkspace workspace, string controlPath) BuildArgs() + private (TerminalHostArgs args, TemporaryWorkspace workspace, string controlPath) BuildArgs( + int? columns = null, + int? rows = null) { var workspace = TemporaryWorkspace.Create(outputHelper); var dcpDir = Path.Combine(workspace.Path, "dcp"); @@ -34,11 +37,22 @@ public class TerminalHostAppTests(ITestOutputHelper outputHelper) var consumer = Path.Combine(hostDir, "r.sock"); var control = Path.Combine(ctrlDir, "c.sock"); - var args = TerminalHostArgs.Parse([ + var commandLine = new List + { "--producer-uds", producer, "--consumer-uds", consumer, "--control-uds", control, - ]); + }; + if (columns is not null) + { + commandLine.AddRange(["--columns", columns.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)]); + } + if (rows is not null) + { + commandLine.AddRange(["--rows", rows.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)]); + } + + var args = TerminalHostArgs.Parse([.. commandLine]); return (args, workspace, control); } @@ -453,6 +467,56 @@ public async Task SessionSnapshotIncludesNewFields() } } + [Fact] + public async Task ConfiguredDimensionsAreAppliedUpstreamAndReportedToConsumers() + { + const int configuredWidth = 137; + const int configuredHeight = 41; + var (args, workspace, control) = BuildArgs(configuredWidth, configuredHeight); + using var disp = workspace; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + + await using var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(5)); + await producer.SendHelloAsync(80, 24, default); + + await WaitForAsync( + () => app.SnapshotSession().ProducerConnected, + TimeSpan.FromSeconds(5), + "ProducerConnected should flip to true after producer dials in."); + + const byte FrameResize = 0x05; + using var frameCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var (type, payload) = await producer.ReadFrameAsync(frameCts.Token); + + Assert.Equal(FrameResize, type); + Assert.Equal(configuredWidth, BitConverter.ToInt32(payload, 0)); + Assert.Equal(configuredHeight, BitConverter.ToInt32(payload, 4)); + + await WaitForFileAsync(args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); + await using var consumer = await TestHmp1Consumer.ConnectAsync( + args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); + await consumer.SendClientHelloAsync("test-consumer", "secondary", default); + var helloPayload = await consumer.ReceiveHandshakeAsync(TimeSpan.FromSeconds(5)); + + using var hello = JsonDocument.Parse(helloPayload); + Assert.Equal(configuredWidth, hello.RootElement.GetProperty("width").GetInt32()); + Assert.Equal(configuredHeight, hello.RootElement.GetProperty("height").GetInt32()); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + [Fact] public async Task DownstreamPrimaryResizeIsForwardedUpstreamAsRawResizeFrame() { @@ -503,6 +567,7 @@ await WaitForAsync( await using var consumer = await TestHmp1Consumer.ConnectAsync( args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); await consumer.SendClientHelloAsync("test-consumer", "primary", default); + await consumer.ReceiveHandshakeAsync(TimeSpan.FromSeconds(5)); await consumer.SendRequestPrimaryAsync(requestedWidth, requestedHeight, default); // The frame the test producer should observe upstream: @@ -697,6 +762,8 @@ private static async Task ConnectProducerAsync(string socketPa ///
private sealed class TestHmp1Consumer : IAsyncDisposable { + private const byte FrameHello = 0x01; + private const byte FrameStateSync = 0x02; private const byte FrameRequestPrimary = 0x07; private const byte FrameClientHello = 0x0B; @@ -741,12 +808,63 @@ public async Task SendClientHelloAsync(string displayName, string defaultRole, C await SendFrameAsync(FrameClientHello, System.Text.Encoding.UTF8.GetBytes(json), ct).ConfigureAwait(false); } + public async Task ReceiveHandshakeAsync(TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + var (helloType, helloPayload) = await ReadFrameAsync(cts.Token).ConfigureAwait(false); + var (stateSyncType, _) = await ReadFrameAsync(cts.Token).ConfigureAwait(false); + + if (helloType != FrameHello || stateSyncType != FrameStateSync) + { + throw new InvalidDataException( + $"Expected Hello and StateSync frames, received 0x{helloType:X2} and 0x{stateSyncType:X2}."); + } + + return helloPayload; + } + public async Task SendRequestPrimaryAsync(int cols, int rows, CancellationToken ct) { var json = $"{{\"cols\":{cols},\"rows\":{rows}}}"; await SendFrameAsync(FrameRequestPrimary, System.Text.Encoding.UTF8.GetBytes(json), ct).ConfigureAwait(false); } + private async Task<(byte Type, byte[] Payload)> ReadFrameAsync(CancellationToken ct) + { + // HMP1 frames are [type:1B][length:4B LE][payload:N bytes]. + var header = new byte[5]; + await ReadExactlyAsync(header, ct).ConfigureAwait(false); + + var length = header[1] | (header[2] << 8) | (header[3] << 16) | (header[4] << 24); + if (length < 0 || length > 16 * 1024 * 1024) + { + throw new InvalidDataException($"Consumer-side reader received invalid frame length {length}."); + } + + var payload = new byte[length]; + if (payload.Length > 0) + { + await ReadExactlyAsync(payload, ct).ConfigureAwait(false); + } + + return (header[0], payload); + } + + private async Task ReadExactlyAsync(byte[] buffer, CancellationToken ct) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await _stream.ReadAsync(buffer.AsMemory(offset), ct).ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException( + $"Consumer-side reader: stream EOF after {offset} of {buffer.Length} bytes."); + } + offset += read; + } + } + private async Task SendFrameAsync(byte type, byte[] payload, CancellationToken ct) { var header = new byte[5]; diff --git a/tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs b/tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs index 30f2d7ec2f5..389cf63c8c9 100644 --- a/tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs +++ b/tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs @@ -17,8 +17,8 @@ public void ParseAllRequiredArgsSucceeds() Assert.Equal("/tmp/p.sock", args.ProducerUdsPath); Assert.Equal("/tmp/c.sock", args.ConsumerUdsPath); Assert.Equal("/tmp/ctrl.sock", args.ControlUdsPath); - Assert.Equal(120, args.Columns); - Assert.Equal(30, args.Rows); + Assert.Equal(132, args.Columns); + Assert.Equal(50, args.Rows); } [Fact] From 629effa090a5cdb153e9399dfec2707b6494c21d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 31 Aug 2026 14:34:38 +1000 Subject: [PATCH 007/106] Add terminal browser regression tests Cover terminal focus navigation and producer-dimension preservation with Playwright using a lightweight HMP test peer. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 395e0f6a-f4c1-4db8-a3dd-e0fbf5392773 --- .../Infrastructure/DashboardServerFixture.cs | 5 + .../Infrastructure/MockDashboardClient.cs | 14 +- .../TestTerminalConnectionResolver.cs | 150 ++++++++++++++++++ .../Integration/Playwright/TerminalTests.cs | 143 +++++++++++++++++ 4 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs create mode 100644 tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index d3ec48ca5f6..b960b6ecee9 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -23,6 +23,10 @@ public class DashboardServerFixture : IAsyncLifetime protected virtual IReadOnlyList? Resources => null; + protected virtual void ConfigureServices(IServiceCollection services) + { + } + public DashboardServerFixture() { PlaywrightFixture = new PlaywrightFixture(); @@ -60,6 +64,7 @@ public async ValueTask InitializeAsync() { builder.Configuration.AddConfiguration(config); builder.Services.AddSingleton(new MockDashboardClient(Resources)); + ConfigureServices(builder.Services); }); await DashboardApp.StartAsync(); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index 64eca71f62f..baf628087fb 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; using Aspire.Tests.Shared.DashboardModel; @@ -51,8 +52,17 @@ public MockDashboardClient(IReadOnlyList? resources = null) public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); - public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); - public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public async IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + + public async IAsyncEnumerable> GetConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } /// public Task ClearConsoleLogsAsync(IReadOnlyList resourceNames, DateTime clearDate) => Task.CompletedTask; diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs new file mode 100644 index 00000000000..3b6aa868b20 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Threading.Channels; +using Aspire.Dashboard.Terminal; + +namespace Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; + +internal enum TestHmp1FrameType : byte +{ + Hello = 0x01, + StateSync = 0x02, + Input = 0x04, + RequestPrimary = 0x07, + ClientHello = 0x0B, +} + +internal readonly record struct TestHmp1Frame(TestHmp1FrameType Type, byte[] Payload); + +internal sealed class TestTerminalConnectionResolver : ITerminalConnectionResolver +{ + private readonly Channel _connections = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + public async Task ConnectAsync(string resourceName, int replicaIndex, CancellationToken cancellationToken) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + Socket? proxySocket = null; + Socket? testSocket = null; + + try + { + listener.Start(); + + proxySocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + var connectTask = proxySocket.ConnectAsync(listener.LocalEndpoint, cancellationToken); + testSocket = await listener.AcceptSocketAsync(cancellationToken).ConfigureAwait(false); + await connectTask.ConfigureAwait(false); + + var connection = new TestTerminalConnection(testSocket); + testSocket = null; + await _connections.Writer.WriteAsync(connection, cancellationToken).ConfigureAwait(false); + + var stream = new NetworkStream(proxySocket, ownsSocket: true); + proxySocket = null; + return stream; + } + finally + { + listener.Stop(); + proxySocket?.Dispose(); + testSocket?.Dispose(); + } + } + + public Task AcceptConnectionAsync(CancellationToken cancellationToken) + { + return _connections.Reader.ReadAsync(cancellationToken).AsTask(); + } + + public async Task DiscardPendingConnectionsAsync() + { + while (_connections.Reader.TryRead(out var connection)) + { + await connection.DisposeAsync().ConfigureAwait(false); + } + } +} + +internal sealed class TestTerminalConnection : IAsyncDisposable +{ + private const int HeaderLength = 5; + private const int MaximumPayloadLength = 1024 * 1024; + private readonly NetworkStream _stream; + + public TestTerminalConnection(Socket socket) + { + _stream = new NetworkStream(socket, ownsSocket: true); + } + + public async Task ReadFrameAsync(CancellationToken cancellationToken) + { + var header = new byte[HeaderLength]; + await _stream.ReadExactlyAsync(header, cancellationToken).ConfigureAwait(false); + + var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(1)); + if (payloadLength is < 0 or > MaximumPayloadLength) + { + throw new InvalidDataException($"Invalid HMP frame payload length: {payloadLength}."); + } + + var payload = new byte[payloadLength]; + await _stream.ReadExactlyAsync(payload, cancellationToken).ConfigureAwait(false); + return new TestHmp1Frame((TestHmp1FrameType)header[0], payload); + } + + public async Task ReadUntilFrameAsync(TestHmp1FrameType type, CancellationToken cancellationToken) + { + while (true) + { + var frame = await ReadFrameAsync(cancellationToken).ConfigureAwait(false); + if (frame.Type == type) + { + return frame; + } + } + } + + public Task SendHelloAsync(int width, int height, CancellationToken cancellationToken) + { + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + peerId = "dashboard-peer", + primaryPeerId = "existing-primary", + width, + height, + peers = new[] + { + new { peerId = "dashboard-peer", displayName = "aspire-dashboard" }, + new { peerId = "existing-primary", displayName = "existing-primary" }, + }, + }); + + return SendFrameAsync(TestHmp1FrameType.Hello, payload, cancellationToken); + } + + public Task SendStateSyncAsync(CancellationToken cancellationToken) + { + return SendFrameAsync(TestHmp1FrameType.StateSync, [], cancellationToken); + } + + private async Task SendFrameAsync(TestHmp1FrameType type, byte[] payload, CancellationToken cancellationToken) + { + var frame = new byte[HeaderLength + payload.Length]; + frame[0] = (byte)type; + BinaryPrimitives.WriteInt32LittleEndian(frame.AsSpan(1), payload.Length); + payload.CopyTo(frame.AsSpan(HeaderLength)); + await _stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public ValueTask DisposeAsync() + { + return _stream.DisposeAsync(); + } +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs new file mode 100644 index 00000000000..e2d1167722d --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs @@ -0,0 +1,143 @@ +// 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.Text.Json; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Terminal; +using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; +using Aspire.TestUtilities; +using Aspire.Tests.Shared.DashboardModel; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Playwright; +using Xunit; + +namespace Aspire.Dashboard.Tests.Integration.Playwright; + +[RequiresFeature(TestFeature.Playwright)] +public sealed class TerminalTests : PlaywrightTestsBase +{ + private const string ResourceName = "terminal-resource"; + private const int ProducerColumns = 137; + private const int ProducerRows = 41; + private readonly TerminalDashboardServerFixture _dashboardServerFixture; + + public TerminalTests(TerminalDashboardServerFixture dashboardServerFixture) + : base(dashboardServerFixture) + { + _dashboardServerFixture = dashboardServerFixture; + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task TerminalFocusNavigation_MovesToExpectedControlsWithoutForwardingInput() + { + await RunTestAsync(async page => + { + await using var connection = await OpenTerminalAsync(page); + + var terminalScreen = page.Locator(".xterm-screen"); + var decreaseFontButton = page.Locator("#font-minus"); + var settingsButton = page.Locator($"fluent-button[title='{Dashboard.Resources.ConsoleLogs.ConsoleLogsSettings}'][aria-haspopup='menu']").First; + + await Assertions.Expect(terminalScreen).ToBeVisibleAsync(); + await Assertions.Expect(decreaseFontButton).ToBeEnabledAsync(); + + await terminalScreen.ClickAsync(); + await page.Keyboard.PressAsync("F6"); + Assert.Equal("font-minus", await page.EvaluateAsync("() => document.activeElement?.id")); + + var settingsButtonId = await settingsButton.GetAttributeAsync("id"); + Assert.False(string.IsNullOrEmpty(settingsButtonId)); + + await terminalScreen.ClickAsync(); + await page.Keyboard.PressAsync("Shift+F6"); + Assert.Equal(settingsButtonId, await page.EvaluateAsync("() => document.activeElement?.id")); + + // Follow the intercepted F6 events with ordinary input. HMP preserves + // frame ordering, so the first Input frame must be this character; an + // earlier F6 escape sequence would make the assertion fail. + await terminalScreen.ClickAsync(); + await page.Keyboard.TypeAsync("x"); + + var input = await connection.ReadUntilFrameAsync(TestHmp1FrameType.Input, CancellationToken.None).DefaultTimeout(); + Assert.Equal("x", Encoding.UTF8.GetString(input.Payload)); + }); + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task SecondaryTyping_RequestsPrimaryAtProducerDimensions() + { + await RunTestAsync(async page => + { + await using var connection = await OpenTerminalAsync(page); + + var dimensions = page.Locator("#terminal-dims"); + await Assertions.Expect(dimensions).ToHaveValueAsync($"{ProducerColumns}x{ProducerRows}"); + + var terminalScreen = page.Locator(".xterm-screen"); + await terminalScreen.ClickAsync(); + await page.Keyboard.TypeAsync("x"); + + var requestPrimary = await connection.ReadUntilFrameAsync(TestHmp1FrameType.RequestPrimary, CancellationToken.None).DefaultTimeout(); + using var payload = JsonDocument.Parse(requestPrimary.Payload); + Assert.Equal(ProducerColumns, payload.RootElement.GetProperty("cols").GetInt32()); + Assert.Equal(ProducerRows, payload.RootElement.GetProperty("rows").GetInt32()); + + var input = await connection.ReadUntilFrameAsync(TestHmp1FrameType.Input, CancellationToken.None).DefaultTimeout(); + Assert.Equal("x", Encoding.UTF8.GetString(input.Payload)); + }); + } + + private async Task OpenTerminalAsync(IPage page) + { + await _dashboardServerFixture.TerminalResolver.DiscardPendingConnectionsAsync(); + await page.GotoAsync($"/consolelogs/resource/{ResourceName}").DefaultTimeout(); + + var connection = await _dashboardServerFixture.TerminalResolver.AcceptConnectionAsync(CancellationToken.None).DefaultTimeout(); + var clientHello = await connection.ReadUntilFrameAsync(TestHmp1FrameType.ClientHello, CancellationToken.None).DefaultTimeout(); + Assert.NotEmpty(clientHello.Payload); + + await connection.SendHelloAsync(ProducerColumns, ProducerRows, CancellationToken.None).DefaultTimeout(); + await connection.SendStateSyncAsync(CancellationToken.None).DefaultTimeout(); + return connection; + } + + public sealed class TerminalDashboardServerFixture : DashboardServerFixture + { + internal TestTerminalConnectionResolver TerminalResolver { get; } = new(); + + protected override IReadOnlyList Resources => + [ + ModelTestHelpers.CreateResource( + resourceName: ResourceName, + state: KnownResourceState.Running, + properties: new Dictionary + { + [KnownProperties.Terminal.Enabled] = StringProperty(KnownProperties.Terminal.Enabled, "true"), + [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, "0"), + [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "1"), + }) + ]; + + protected override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(TerminalResolver); + } + + private static ResourcePropertyViewModel StringProperty(string name, string value) + { + return new ResourcePropertyViewModel( + name, + new Value { StringValue = value }, + isValueSensitive: false, + knownProperty: null, + sortOrder: 0, + displayName: null, + isHighlighted: false); + } + } +} From f45f70bcf2edacd9990aa3f150dc724ddf069633 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 13:07:37 +1000 Subject: [PATCH 008/106] Fix terminal listener and sizing lifecycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 395e0f6a-f4c1-4db8-a3dd-e0fbf5392773 --- .../Components/Controls/TerminalView.razor.js | 11 +- .../DashboardWebApplication.cs | 4 +- src/Aspire.TerminalHost/DcpUpstreamAdapter.cs | 6 + .../Hmp1UdsServerListenerFilter.cs | 169 ++++++++++++++++-- src/Aspire.TerminalHost/TerminalReplica.cs | 49 +++-- .../Infrastructure/DashboardServerFixture.cs | 5 +- .../Infrastructure/MockDashboardClient.cs | 24 ++- .../TestTerminalConnectionResolver.cs | 8 +- .../Integration/Playwright/TerminalTests.cs | 49 ++++- .../TerminalHostAppTests.cs | 142 +++++++++++++++ 10 files changed, 421 insertions(+), 46 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 7e6a17971fb..b4ad1bf4465 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -699,7 +699,7 @@ function moveFocusFromTerminal(state, reverse) { function attachTerminalFocusNavigation(state, term) { term.attachCustomKeyEventHandler((event) => { - if (event.key !== 'F6') { + if (event.key !== 'F6' || event.ctrlKey || event.altKey || event.metaKey) { return true; } @@ -811,9 +811,9 @@ function getAvailableBodySpace(state) { // Record that grid as fixed sizing state so a later keyboard-driven promotion // keeps the existing resolution. Only an explicit footer action switches back // to Fit or selects another preset. -function adoptProducerDimensions(state) { +function adoptProducerDimensions(state, includePrimary = false) { const client = state.client; - if (!client || client.isPrimary || client.width <= 0 || client.height <= 0) { + if (!client || (!includePrimary && client.isPrimary) || client.width <= 0 || client.height <= 0) { return; } @@ -1586,7 +1586,10 @@ function connectClient(state, wsUrl) { client.onHello = (payload) => { if (myGeneration !== state.reconnect.generation) return; dbg(state, 'client.onHello', payload); - adoptProducerDimensions(state); + // Hello is authoritative for the producer's current grid even when + // this peer is already primary. Otherwise the local default can resize + // an existing producer before the user asks to change its dimensions. + adoptProducerDimensions(state, true); notifyToolbar(state); // Now that we know producer dims + role, apply layout (fits the // role-aware path: secondary locks-and-scales to producer dims; diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index db9e7525f80..fc2e660aabc 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -291,7 +291,7 @@ public DashboardWebApplication( builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(services => services.GetRequiredService()); - builder.Services.AddScoped(); + builder.Services.TryAddScoped(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(TimeProvider.System); @@ -319,7 +319,7 @@ public DashboardWebApplication( builder.Services.AddGrpc(); builder.Services.AddSingleton(); builder.Services.AddSingleton(services => services.GetRequiredService()); - builder.Services.AddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.AddSingleton(services => services.GetRequiredService().Current.TelemetryRepository); // OTLP ingestion and telemetry mutations always target the current dashboard run, even when a browser circuit selects a historical run. builder.Services.AddSingleton(services => diff --git a/src/Aspire.TerminalHost/DcpUpstreamAdapter.cs b/src/Aspire.TerminalHost/DcpUpstreamAdapter.cs index 47ab041d11e..2fda50a42db 100644 --- a/src/Aspire.TerminalHost/DcpUpstreamAdapter.cs +++ b/src/Aspire.TerminalHost/DcpUpstreamAdapter.cs @@ -470,6 +470,12 @@ private void Complete(Exception? error = null) } } + internal void ReportConsumerListenerFailure(Exception error) + { + ArgumentNullException.ThrowIfNull(error); + Complete(error); + } + public async ValueTask DisposeAsync() { if (_disposed) diff --git a/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs b/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs index a005f25ff9f..fe58a12e163 100644 --- a/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs +++ b/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs @@ -4,19 +4,37 @@ using Hex1b; using Hex1b.Tokens; using Microsoft.Extensions.Logging; +using System.Net.Sockets; namespace Aspire.TerminalHost; /// /// Hosts an HMP1 presentation adapter on a Unix domain socket for one terminal session. /// -internal sealed class Hmp1UdsServerListenerFilter( - string socketPath, - Hmp1PresentationAdapter presentation, - ILogger logger) : IHex1bTerminalPresentationFilter +internal sealed class Hmp1UdsServerListenerFilter : IHex1bTerminalPresentationFilter, IDisposable { + private readonly string _socketPath; + private readonly Hmp1PresentationAdapter _presentation; + private readonly ILogger _logger; + private readonly Action _listenerFaulted; + private readonly object _gate = new(); + private readonly HashSet _clientTasks = []; private CancellationTokenSource? _listenerCts; private Task? _listenerTask; + private Socket? _listener; + + public Hmp1UdsServerListenerFilter( + string socketPath, + Hmp1PresentationAdapter presentation, + ILogger logger, + Action listenerFaulted) + { + _socketPath = socketPath; + _presentation = presentation; + _logger = logger; + _listenerFaulted = listenerFaulted; + _listener = BindListener(socketPath); + } /// public ValueTask OnSessionStartAsync( @@ -26,7 +44,9 @@ public ValueTask OnSessionStartAsync( CancellationToken ct = default) { _listenerCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _listenerTask = Task.Run(() => RunListenerAsync(_listenerCts.Token), _listenerCts.Token); + _listenerTask = RunListenerAsync( + _listener ?? throw new ObjectDisposedException(nameof(Hmp1UdsServerListenerFilter)), + _listenerCts.Token); return ValueTask.CompletedTask; } @@ -69,6 +89,7 @@ public async ValueTask OnSessionEndAsync(TimeSpan elapsed, CancellationToken ct } await _listenerCts.CancelAsync().ConfigureAwait(false); + DisposeListener(); if (_listenerTask is not null) { @@ -79,43 +100,159 @@ public async ValueTask OnSessionEndAsync(TimeSpan elapsed, CancellationToken ct catch (OperationCanceledException) { } + catch (ObjectDisposedException) when (_listenerCts.IsCancellationRequested) + { + } + catch (SocketException) when (_listenerCts.IsCancellationRequested) + { + } + } + + Task[] clientTasks; + lock (_gate) + { + clientTasks = [.. _clientTasks]; } + await Task.WhenAll(clientTasks).ConfigureAwait(false); _listenerCts.Dispose(); + TryDeleteSocketFile(); } - private async Task RunListenerAsync(CancellationToken ct) + private async Task RunListenerAsync(Socket listener, CancellationToken ct) { try { - await foreach (var stream in Hmp1Transports.ListenUnixSocket(socketPath, ct).ConfigureAwait(false)) + while (!ct.IsCancellationRequested) { - _ = AddClientAsync(stream, ct); + var socket = await listener.AcceptAsync(ct).ConfigureAwait(false); + var stream = new NetworkStream(socket, ownsSocket: true); + var clientTask = AddClientAsync(stream, ct); + lock (_gate) + { + _clientTasks.Add(clientTask); + } + _ = ObserveClientTaskAsync(clientTask); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (ObjectDisposedException) when (ct.IsCancellationRequested) + { + } + catch (SocketException) when (ct.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "The HMP1 consumer listener failed."); + _listenerFaulted(ex); + } + } + + private async Task ObserveClientTaskAsync(Task clientTask) + { + try + { + await clientTask.ConfigureAwait(false); + } + finally + { + lock (_gate) + { + _clientTasks.Remove(clientTask); + } + } } private async Task AddClientAsync(Stream stream, CancellationToken ct) { try { - await presentation.AddClient(stream, ct).ConfigureAwait(false); + _ = await _presentation.AddClient(stream, ct).ConfigureAwait(false); } catch (Exception ex) when ( ex is IOException or ObjectDisposedException or OperationCanceledException or InvalidOperationException) { - try - { - await stream.DisposeAsync().ConfigureAwait(false); - } - catch (Exception disposeException) when ( - disposeException is IOException or ObjectDisposedException or OperationCanceledException) + await DisposeFailedClientStreamAsync(stream).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Adding an HMP1 consumer failed unexpectedly."); + await DisposeFailedClientStreamAsync(stream).ConfigureAwait(false); + } + } + + private async Task DisposeFailedClientStreamAsync(Stream stream) + { + try + { + await stream.DisposeAsync().ConfigureAwait(false); + } + catch (Exception disposeException) when ( + disposeException is IOException or ObjectDisposedException or OperationCanceledException) + { + _logger.LogDebug(disposeException, "Failed to dispose an HMP1 consumer stream after its session ended."); + } + } + + private static Socket BindListener(string socketPath) + { + var directory = Path.GetDirectoryName(socketPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + + var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + listener.Bind(new UnixDomainSocketEndPoint(socketPath)); + listener.Listen(backlog: 16); + return listener; + } + catch + { + listener.Dispose(); + throw; + } + } + + private void DisposeListener() + { + Socket? listener; + lock (_gate) + { + listener = _listener; + _listener = null; + } + listener?.Dispose(); + } + + private void TryDeleteSocketFile() + { + try + { + if (File.Exists(_socketPath)) { - logger.LogDebug(disposeException, "Failed to dispose an HMP1 consumer stream after its session ended."); + File.Delete(_socketPath); } } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogDebug(ex, "Failed to delete the HMP1 consumer socket file '{SocketPath}'.", _socketPath); + } + } + + public void Dispose() + { + DisposeListener(); + TryDeleteSocketFile(); } } diff --git a/src/Aspire.TerminalHost/TerminalReplica.cs b/src/Aspire.TerminalHost/TerminalReplica.cs index 4799edcc550..1492af0a8f0 100644 --- a/src/Aspire.TerminalHost/TerminalReplica.cs +++ b/src/Aspire.TerminalHost/TerminalReplica.cs @@ -357,6 +357,14 @@ private async Task RecycleLoopAsync(CancellationToken ct) /// private Hex1bTerminal BuildTerminal() { + int currentColumns; + int currentRows; + lock (_gate) + { + currentColumns = _currentColumns; + currentRows = _currentRows; + } + // Pre-delete any stale UDS files at our paths before Hex1b tries to bind. Without // this, a previous host that crashed (or a stuck previous cycle that didn't get // to clean teardown) leaves a file at the same path, and Hmp1Transports.ListenUnixSocket @@ -388,7 +396,7 @@ private Hex1bTerminal BuildTerminal() { _logger.LogInformation( "Awaiting DCP producer connection on '{ProducerUdsPath}' (cols={Cols}, rows={Rows}).", - ProducerUdsPath, Columns, Rows); + ProducerUdsPath, currentColumns, currentRows); await foreach (var stream in Hmp1Transports.ListenUnixSocket(ProducerUdsPath, cct).ConfigureAwait(false)) { int restartCount; @@ -423,22 +431,35 @@ private Hex1bTerminal BuildTerminal() // 80x24 default, which then overrides WithDimensions during Build(). Construct // the adapter directly so both its Hello frames and the upstream PTY start with // the dimensions configured by WithTerminal. - var downstream = CreateDownstream(upstream); - - return Hex1bTerminal.CreateBuilder() - .WithDimensions(Columns, Rows) - .WithWorkload(upstream) - .WithPresentation(downstream) - .AddPresentationFilter(new Hmp1UdsServerListenerFilter( - ConsumerUdsPath, - downstream, - _consumerListenerLogger)) - .Build(); + var downstream = CreateDownstream(upstream, currentColumns, currentRows); + var listener = new Hmp1UdsServerListenerFilter( + ConsumerUdsPath, + downstream, + _consumerListenerLogger, + upstream.ReportConsumerListenerFailure); + + try + { + return Hex1bTerminal.CreateBuilder() + .WithDimensions(currentColumns, currentRows) + .WithWorkload(upstream) + .WithPresentation(downstream) + .AddPresentationFilter(listener) + .Build(); + } + catch + { + listener.Dispose(); + throw; + } } - private Hmp1PresentationAdapter CreateDownstream(DcpUpstreamAdapter upstream) + private Hmp1PresentationAdapter CreateDownstream( + DcpUpstreamAdapter upstream, + int currentColumns, + int currentRows) { - var downstream = new Hmp1PresentationAdapter(Columns, Rows); + var downstream = new Hmp1PresentationAdapter(currentColumns, currentRows); // Track every HMP1 peer that connects/disconnects so the host can answer // "who's currently attached to this replica?" via the control RPC. PeerId is diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index b960b6ecee9..3afe2ab31a5 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -63,7 +63,10 @@ public async ValueTask InitializeAsync() preConfigureBuilder: builder => { builder.Configuration.AddConfiguration(config); - builder.Services.AddSingleton(new MockDashboardClient(Resources)); + var dashboardClient = new MockDashboardClient(Resources); + builder.Services.AddSingleton(dashboardClient); + builder.Services.AddSingleton( + services => new MockRepositoryFactory(services, dashboardClient)); ConfigureServices(builder.Services); }); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index baf628087fb..f34d249c007 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -3,13 +3,14 @@ using System.Runtime.CompilerServices; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Storage; using Aspire.DashboardService.Proto.V1; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.WellKnownTypes; namespace Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; -public sealed class MockDashboardClient : IDashboardClient +public sealed class MockDashboardClient : IDashboardClient, IResourceRepositoryWriter { public static readonly ResourceViewModel TestResource1 = ModelTestHelpers.CreateResource( resourceName: "TestResource", @@ -94,4 +95,25 @@ public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, public ResourceViewModel? GetResource(string resourceName) => null; public IReadOnlyList GetResources() => _resources ?? []; + + public Task ReplaceResourcesAsync(IReadOnlyList resources) => Task.CompletedTask; + + public Task ApplyChangesAsync(IReadOnlyList changes) => Task.CompletedTask; + + public Task MarkConsoleLogsLoadedAsync(string resourceName) => Task.CompletedTask; + + public Task AddConsoleLogsAsync(string resourceName, IReadOnlyList logLines) => Task.CompletedTask; +} + +internal sealed class MockRepositoryFactory( + IServiceProvider serviceProvider, + IResourceRepository resourceRepository) : IRepositoryFactory +{ + private readonly RepositoryFactory _inner = new(serviceProvider); + + public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => + _inner.CreateTelemetryRepository(database); + + public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => + resourceRepository; } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs index 3b6aa868b20..ec6a15bbf1b 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs @@ -110,12 +110,16 @@ public async Task ReadUntilFrameAsync(TestHmp1FrameType type, Can } } - public Task SendHelloAsync(int width, int height, CancellationToken cancellationToken) + public Task SendHelloAsync( + int width, + int height, + CancellationToken cancellationToken, + bool makeClientPrimary = false) { var payload = JsonSerializer.SerializeToUtf8Bytes(new { peerId = "dashboard-peer", - primaryPeerId = "existing-primary", + primaryPeerId = makeClientPrimary ? "dashboard-peer" : "existing-primary", width, height, peers = new[] diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs index e2d1167722d..87d2931170f 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs @@ -40,7 +40,7 @@ await RunTestAsync(async page => var terminalScreen = page.Locator(".xterm-screen"); var decreaseFontButton = page.Locator("#font-minus"); - var settingsButton = page.Locator($"fluent-button[title='{Dashboard.Resources.ConsoleLogs.ConsoleLogsSettings}'][aria-haspopup='menu']").First; + var resourceSelect = page.Locator("[id^='resource-select-']"); await Assertions.Expect(terminalScreen).ToBeVisibleAsync(); await Assertions.Expect(decreaseFontButton).ToBeEnabledAsync(); @@ -49,12 +49,12 @@ await RunTestAsync(async page => await page.Keyboard.PressAsync("F6"); Assert.Equal("font-minus", await page.EvaluateAsync("() => document.activeElement?.id")); - var settingsButtonId = await settingsButton.GetAttributeAsync("id"); - Assert.False(string.IsNullOrEmpty(settingsButtonId)); + var resourceSelectId = await resourceSelect.GetAttributeAsync("id"); + Assert.False(string.IsNullOrEmpty(resourceSelectId)); await terminalScreen.ClickAsync(); await page.Keyboard.PressAsync("Shift+F6"); - Assert.Equal(settingsButtonId, await page.EvaluateAsync("() => document.activeElement?.id")); + Assert.Equal(resourceSelectId, await page.EvaluateAsync("() => document.activeElement?.id")); // Follow the intercepted F6 events with ordinary input. HMP preserves // frame ordering, so the first Input frame must be this character; an @@ -92,7 +92,40 @@ await RunTestAsync(async page => }); } - private async Task OpenTerminalAsync(IPage page) + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task InitialPrimaryHello_UsesProducerDimensions() + { + await RunTestAsync(async page => + { + await using var connection = await OpenTerminalAsync(page, makeClientPrimary: true); + + var dimensions = page.Locator("#terminal-dims"); + await Assertions.Expect(dimensions).ToHaveValueAsync($"{ProducerColumns}x{ProducerRows}"); + }); + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task ModifiedF6_DoesNotMoveFocusFromTerminal() + { + await RunTestAsync(async page => + { + await using var connection = await OpenTerminalAsync(page); + + var terminalScreen = page.Locator(".xterm-screen"); + var terminalInput = page.Locator(".xterm-helper-textarea"); + foreach (var key in new[] { "Control+F6", "Alt+F6", "Meta+F6" }) + { + await terminalScreen.ClickAsync(); + await page.Keyboard.PressAsync(key); + + await Assertions.Expect(terminalInput).ToBeFocusedAsync(); + } + }); + } + + private async Task OpenTerminalAsync(IPage page, bool makeClientPrimary = false) { await _dashboardServerFixture.TerminalResolver.DiscardPendingConnectionsAsync(); await page.GotoAsync($"/consolelogs/resource/{ResourceName}").DefaultTimeout(); @@ -101,7 +134,11 @@ private async Task OpenTerminalAsync(IPage page) var clientHello = await connection.ReadUntilFrameAsync(TestHmp1FrameType.ClientHello, CancellationToken.None).DefaultTimeout(); Assert.NotEmpty(clientHello.Payload); - await connection.SendHelloAsync(ProducerColumns, ProducerRows, CancellationToken.None).DefaultTimeout(); + await connection.SendHelloAsync( + ProducerColumns, + ProducerRows, + CancellationToken.None, + makeClientPrimary).DefaultTimeout(); await connection.SendStateSyncAsync(CancellationToken.None).DefaultTimeout(); return connection; } diff --git a/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs b/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs index 9df747f44f1..3065b0e8128 100644 --- a/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs +++ b/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using System.Text.Json; using Aspire.Shared.TerminalHost; +using Hex1b; using Microsoft.Extensions.Logging.Abstractions; using StreamJsonRpc; @@ -611,6 +612,147 @@ await WaitForAsync( } } + [Fact] + public async Task ConsumerListenerBindFailureIsReportedBeforeTerminalStarts() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var parentFile = Path.Combine(workspace.Path, "not-a-directory"); + await File.WriteAllTextAsync(parentFile, ""); + await using var presentation = new Hmp1PresentationAdapter(); + + Assert.Throws(() => new Hmp1UdsServerListenerFilter( + Path.Combine(parentFile, "consumer.sock"), + presentation, + NullLogger.Instance, + _ => { })); + } + + [Fact] + public async Task ConsumerListenerWaitsForAcceptedClientHandshakeDuringTeardown() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var socketPath = Path.Combine(workspace.Path, "consumer.sock"); + await using var presentation = new Hmp1PresentationAdapter(); + var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + presentation.OnClientConnected = async (_, _) => + { + callbackStarted.TrySetResult(); + await releaseCallback.Task.ConfigureAwait(false); + }; + + using var listener = new Hmp1UdsServerListenerFilter( + socketPath, + presentation, + NullLogger.Instance, + _ => { }); + await listener.OnSessionStartAsync(80, 24, DateTimeOffset.UtcNow); + + Task? endTask = null; + try + { + await using var consumer = await TestHmp1Consumer.ConnectAsync(socketPath, TimeSpan.FromSeconds(5)); + await consumer.SendClientHelloAsync("test-consumer", "secondary", default); + await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + endTask = listener.OnSessionEndAsync(TimeSpan.Zero).AsTask(); + var completed = await Task.WhenAny(endTask, Task.Delay(TimeSpan.FromMilliseconds(200))); + Assert.NotSame(endTask, completed); + } + finally + { + releaseCallback.TrySetResult(); + if (endTask is null) + { + await listener.OnSessionEndAsync(TimeSpan.Zero); + } + else + { + await endTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + } + } + + [Fact] + public async Task CurrentDimensionsArePreservedAcrossProducerRecycle() + { + const int requestedWidth = 123; + const int requestedHeight = 45; + const byte frameResize = 0x05; + var (args, workspace, control) = BuildArgs(); + using var disp = workspace; + + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + var hostTask = app.RunAsync(hostCts.Token); + + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + + await using (var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(5))) + { + await producer.SendHelloAsync(80, 24, default); + await WaitForAsync( + () => app.SnapshotSession().ProducerConnected, + TimeSpan.FromSeconds(5), + "The first producer should connect."); + + await using var consumer = await TestHmp1Consumer.ConnectAsync( + args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); + await consumer.SendClientHelloAsync("test-consumer", "primary", default); + await consumer.ReceiveHandshakeAsync(TimeSpan.FromSeconds(5)); + await consumer.SendRequestPrimaryAsync(requestedWidth, requestedHeight, default); + + _ = await producer.WaitForMatchingFrameAsync( + frameResize, + payload => payload.Length == 8 + && BitConverter.ToInt32(payload, 0) == requestedWidth + && BitConverter.ToInt32(payload, 4) == requestedHeight, + TimeSpan.FromSeconds(10)); + await WaitForAsync( + () => + { + var session = app.SnapshotSession(); + return session.CurrentColumns == requestedWidth && session.CurrentRows == requestedHeight; + }, + TimeSpan.FromSeconds(5), + "The resized dimensions should become authoritative."); + } + + await WaitForAsync( + () => app.SnapshotSession().RestartCount >= 1, + TimeSpan.FromSeconds(10), + "The terminal should recycle after the first producer disconnects."); + + await using var replacementProducer = await ConnectProducerAsync( + args.ProducerUdsPath, TimeSpan.FromSeconds(10)); + await replacementProducer.SendHelloAsync(80, 24, default); + + _ = await replacementProducer.WaitForMatchingFrameAsync( + frameResize, + payload => payload.Length == 8 + && BitConverter.ToInt32(payload, 0) == requestedWidth + && BitConverter.ToInt32(payload, 4) == requestedHeight, + TimeSpan.FromSeconds(10)); + + await using var replacementConsumer = await TestHmp1Consumer.ConnectAsync( + args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); + await replacementConsumer.SendClientHelloAsync("replacement-consumer", "secondary", default); + var helloPayload = await replacementConsumer.ReceiveHandshakeAsync(TimeSpan.FromSeconds(5)); + + using var hello = JsonDocument.Parse(helloPayload); + Assert.Equal(requestedWidth, hello.RootElement.GetProperty("width").GetInt32()); + Assert.Equal(requestedHeight, hello.RootElement.GetProperty("height").GetInt32()); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + /// /// A minimal HMP1 server-role producer for tests. Connects to the UDS path /// the terminal host is listening on (producer side) and writes the bare From 53a9c75c768f93e23d0e0cda80e317c7a7f5603e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 15:13:35 +1000 Subject: [PATCH 009/106] Update dashboard test mocks for the terminal client surface Spike 2 renamed IDashboardClient.AttachInteractionTerminalAsync to AttachTerminalAsync and added SubscribeTerminalsAsync, CreateDockTerminalAsync and CloseTerminalAsync, but the four test doubles were never updated, so Aspire.Dashboard.Tests did not compile. SubscribeTerminalsAsync is written as an async iterator rather than returning AsyncEnumerable.Empty() because these projects target net8.0, which does not ship System.Linq.AsyncEnumerable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Infrastructure/MockDashboardClient.cs | 5 ++++- .../ResourceOutgoingPeerResolverTests.cs | 5 ++++- .../DefaultTerminalConnectionResolverTests.cs | 5 ++++- tests/Shared/TestDashboardClient.cs | 18 +++++++++++++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index f34d249c007..4cfe01659c5 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -52,7 +52,10 @@ public MockDashboardClient(IReadOnlyList? resources = null) public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public async IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index 7df92f569b8..2169d0c7b4a 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -694,7 +694,10 @@ private sealed class MockDashboardClient(Task sub public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public ResourceViewModel? GetResource(string resourceName) => null; public IReadOnlyList GetResources() => []; public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); diff --git a/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs index d2f24a07681..a076f477231 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs @@ -151,7 +151,10 @@ private sealed class DisabledDashboardClient : IDashboardClient public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ClearConsoleLogsAsync(IReadOnlyList resourceNames, DateTime clearDate) => Task.CompletedTask; diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index a5d47db3686..67f24ee85f2 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -85,11 +85,27 @@ public Task UploadFileAsync(Stream fileStream, string fileName, long exp return Task.FromResult(Guid.NewGuid().ToString("N")); } - public Task AttachInteractionTerminalAsync(int interactionId, string inputName, CancellationToken cancellationToken) + public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) { return Task.FromResult(new MemoryStream()); } + public async IAsyncEnumerable SubscribeTerminalsAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + + public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + public async IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { if (_consoleLogsChannelProvider == null) From 21b41585d4929783ccf0e79c7a5dc4e556b16681 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 15:13:43 +1000 Subject: [PATCH 010/106] Show the terminal footer in chromeless terminals The footer carrying the font stepper, the fixed-resolution picker and the F6 focus hint was hidden for every chromeless host, which meant the terminal dock and detached terminal windows lost the sizing controls that were just integrated from the font-controls work. Show the footer everywhere and gate only the resolution picker, via a new ShowDimensionsPicker parameter that maps to a `dimensions-hidden` host class. A detached window is resizable and a resource terminal has a fixed frame, so both keep the picker; a dock pane is sized by the dock splitter and always fits its grid to the available space, so a fixed resolution has nothing to act on there. The font stepper stays in all three, so dock terminals can still be zoomed. The footer is still always constructed regardless of visibility so the control references on the JS state object stay non-null and the wiring is identical in every mode. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.cs | 23 +++++++++-- .../Components/Controls/TerminalView.razor.js | 38 ++++++++++++------- .../Components/Layout/TerminalDock.razor | 2 +- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 16ae0937386..b58077c9fb9 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -99,16 +99,27 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable /// /// Gets or sets a value indicating whether the terminal renders without its surrounding chrome — no card border, - /// titlebar or internal padding, just the xterm grid. + /// titlebar or internal padding, just the xterm grid and its footer. /// /// - /// Used by the terminal dock, which supplies its own tab-strip chrome and title. Chromeless terminals also size - /// their grid to fill the available space at the dashboard's base font size, rather than shrinking the font to fit - /// the producer's grid. + /// Used by the terminal dock and detached terminal windows, which supply their own chrome and title. Chromeless + /// terminals also size their grid to fill the available space at the dashboard's base font size, rather than + /// shrinking the font to fit the producer's grid. /// [Parameter] public bool Chromeless { get; set; } + /// + /// Gets or sets a value indicating whether the footer offers the fixed-resolution picker. + /// + /// + /// Defaults to . The dock sets this to : its panes are sized by the + /// dock splitter and always fit their grid to the available space, so a fixed resolution has nothing to act on. + /// The font stepper stays available regardless, so dock terminals can still be zoomed. + /// + [Parameter] + public bool ShowDimensionsPicker { get; set; } = true; + /// /// Raised when the JS side pushes a fresh terminal state snapshot (role, /// dimensions, font size, etc.) for hosts that need to observe it. @@ -251,6 +262,7 @@ private async Task InitializeTerminalAsync(string endpoint) "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef, new TerminalViewOptions { Chromeless = Chromeless, + ShowDimensions = ShowDimensionsPicker, DecreaseFontSize = DecreaseFontSizeLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarDecreaseFontSize)], IncreaseFontSize = IncreaseFontSizeLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarIncreaseFontSize)], TerminalDimensions = TerminalDimensionsLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSize)], @@ -502,6 +514,9 @@ public sealed record TerminalViewOptions /// Whether to render without the card border, titlebar and internal padding. public bool Chromeless { get; init; } + /// Whether the footer offers the fixed-resolution picker. + public bool ShowDimensions { get; init; } = true; + /// Accessible label for the footer's decrease-font-size button. public required string DecreaseFontSize { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index b4ad1bf4465..0c7138c250e 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -387,13 +387,14 @@ function ensureTerminalStyles() { padding: 0; } /* - * Chromeless terminals auto-fit their grid to the available space at the - * dashboard font size, so the footer's fixed-size picker and font stepper - * have nothing meaningful to control — and the dock/detached window asked - * for the xterm grid alone. Hide the footer rather than special-casing the - * control wiring, which stays identical for both modes. + * The dimension picker only makes sense where the user can act on a fixed + * grid: the resource page and a detached terminal window (which is resizable) + * keep it, while a dock pane is sized by the dock's splitter and always fits + * its grid to the available space. The font stepper stays in both cases so + * dock terminals can still be zoomed. Hidden with a class rather than skipped + * in buildFooter so the control wiring stays identical in every mode. */ -.aspire-terminal-host.chromeless #terminal-footer { +.aspire-terminal-host.dimensions-hidden #terminal-dims { display: none; } @@ -535,7 +536,14 @@ function buildChrome(state) { // it with our own host so we can apply our flex column layout // without disturbing whatever else the parent has set on it. const host = document.createElement('div'); - host.className = state.chromeless ? 'aspire-terminal-host chromeless' : 'aspire-terminal-host'; + const hostClasses = ['aspire-terminal-host']; + if (state.chromeless) { + hostClasses.push('chromeless'); + } + if (!state.showDimensions) { + hostClasses.push('dimensions-hidden'); + } + host.className = hostClasses.join(' '); blazorElement.appendChild(host); // Terminal stage. @@ -568,8 +576,8 @@ function buildChrome(state) { const body = document.createElement('div'); body.id = 'terminal-body'; - // The footer is built even for chromeless hosts so every control - // reference on `state` stays non-null; CSS hides it in that mode. + // The footer is built even when hidden so every control reference on + // `state` stays non-null; CSS decides whether it is visible. const footer = buildFooter(state); if (titlebar) { @@ -1272,10 +1280,10 @@ function resolveDashboardFontPx() { return DEFAULT_FONT_PX; } -// `options` is optional: { chromeless: bool, ...control labels }. Chromeless -// drops the frame, titlebar, footer and padding so only the xterm grid shows -// (used by the terminal dock and detached terminal windows, which supply their -// own chrome). +// `options` is optional: { chromeless: bool, showDimensions: bool, ...control +// labels }. Chromeless drops the frame, titlebar and padding so only the xterm +// grid and its footer show (used by the terminal dock and detached terminal +// windows, which supply their own chrome). export async function initTerminal(element, wsUrl, dotNetRef, options) { await ensureXtermLoaded(); @@ -1303,6 +1311,10 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { }, // Layout / sizing state (per-instance — we never use globals). chromeless, + // Whether the footer's fixed-resolution picker is offered. Dock panes + // are sized by the dock splitter and always fit, so they get the font + // stepper but not the picker. + showDimensions: options?.showDimensions !== false, // Chromeless terminals fill whatever space the dock pane or detached // window gives them at the dashboard's font size, so they start in Fit // mode. Everything else starts at the default fixed resolution and only diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 0ebdfcdcf42..9ffb2c516bb 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -80,7 +80,7 @@ } else { - + } } From cde66986ad2655fa0b48cd8c986ebe35e0e67b43 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 15:30:34 +1000 Subject: [PATCH 011/106] Re-fit chromeless terminals on detach and return to the dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detaching a dock pane into a window left the producer at the dock's grid, and the window shrank its font until that grid fitted — a 208x16 dock pane detached into a 960x600 window rendered at 6px. Returning it to the dock did the mirror image, so a round trip never snapped back. The cause was adoptProducerDimensions, which on Hello pinned the new viewer to the producer's current grid as a fixed size. That is right for a resource terminal, where a CLI viewer elsewhere may legitimately be primary and this peer is a secondary rendering someone else's grid. It is wrong for a chromeless surface: a dock pane and a detached window each own their viewport and never render as a secondary (see the isSecondary guard in applyRoleAwareLayout), so all the inherited grid did was force the font down. Skip the adoption for chromeless surfaces. They now stay in Fit mode, lay out against their own viewport and, because a chromeless terminal already claims primary on attach, push the resulting dimensions upstream — which is the resize the producer needs. Verified end to end: attaching the dock takes primary at 208x16, detaching re-takes it at 124x38, and returning to the dock re-takes it at 208x16. Also remember the font size per surface across remounts, keyed by a new SizeMemoryKey parameter that the dock sets per terminal. Detaching unmounts the pane, so without this a pane the user had zoomed to 15px came back at the 13px base font. Only explicitly chosen sizes are remembered, and the dock pane and the detached window use different keys so returning restores the size the pane had when it was detached rather than whatever the window was left at. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.cs | 17 +++++++ .../Components/Controls/TerminalView.razor.js | 48 ++++++++++++++++--- .../Components/Layout/TerminalDock.razor | 5 +- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index b58077c9fb9..2f36a686188 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -109,6 +109,19 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Parameter] public bool Chromeless { get; set; } + /// + /// Gets or sets an opaque key identifying this terminal surface for font-size persistence. + /// + /// + /// When set, the font size the user selects is remembered for the lifetime of the page and restored if a + /// with the same key is mounted again. The dock uses this so a pane that is detached + /// into a window — which unmounts the pane — comes back at the size it had when it was detached, rather than + /// resetting to the dashboard's base font. Keys are per-surface, so a dock pane and a detached window of the same + /// terminal do not overwrite each other. + /// + [Parameter] + public string? SizeMemoryKey { get; set; } + /// /// Gets or sets a value indicating whether the footer offers the fixed-resolution picker. /// @@ -263,6 +276,7 @@ private async Task InitializeTerminalAsync(string endpoint) { Chromeless = Chromeless, ShowDimensions = ShowDimensionsPicker, + SizeMemoryKey = SizeMemoryKey, DecreaseFontSize = DecreaseFontSizeLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarDecreaseFontSize)], IncreaseFontSize = IncreaseFontSizeLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarIncreaseFontSize)], TerminalDimensions = TerminalDimensionsLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSize)], @@ -517,6 +531,9 @@ public sealed record TerminalViewOptions /// Whether the footer offers the fixed-resolution picker. public bool ShowDimensions { get; init; } = true; + /// Opaque key identifying this surface for font-size persistence, or to not persist. + public string? SizeMemoryKey { get; init; } + /// Accessible label for the footer's decrease-font-size button. public required string DecreaseFontSize { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 0c7138c250e..53059ef93d7 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -15,6 +15,17 @@ const terminals = new Map(); let nextId = 1; const textEncoder = new TextEncoder(); +// Remembers the font size a caller-identified surface was last using, so a +// remount can restore it. The dock uses this: detaching a pane unmounts its +// TerminalView and returning remounts a brand new one, which would otherwise +// come back at the dashboard's base font and lose whatever zoom the user had +// applied. Keyed by the caller's own key (see the sizeMemoryKey option) so a +// dock pane and a detached window of the same terminal stay independent — the +// pane restores the size it had when it was detached, not the size the user +// happened to leave the window at. Module scope means the memory lives as long +// as the page, which is the same lifetime as the dock itself. +const sizeMemory = new Map(); + // Diagnostics gate. Set window.__aspireTerminalDebug = true in DevTools // before loading the page (or before the first terminal is opened) to // emit a structured trace of every lifecycle event. Default off so the @@ -819,9 +830,18 @@ function getAvailableBodySpace(state) { // Record that grid as fixed sizing state so a later keyboard-driven promotion // keeps the existing resolution. Only an explicit footer action switches back // to Fit or selects another preset. +// +// Chromeless surfaces are excluded. They never render as a secondary (see the +// isSecondary guard in applyRoleAwareLayout) — a dock pane and a detached +// window each own their viewport and fit the grid into it. Adopting the +// producer's grid there would make a newly attached viewer inherit whatever +// resolution the *previous* viewer had negotiated and shrink its font to fit +// it, so detaching a dock pane into a large window (or returning it to the +// dock) would keep the old grid at an unreadable font instead of re-fitting +// and pushing the new dimensions upstream. function adoptProducerDimensions(state, includePrimary = false) { const client = state.client; - if (!client || (!includePrimary && client.isPrimary) || client.width <= 0 || client.height <= 0) { + if (state.chromeless || !client || (!includePrimary && client.isPrimary) || client.width <= 0 || client.height <= 0) { return; } @@ -1111,6 +1131,7 @@ function setFontSize(state, newSize) { state.fitFontPx = newSize; state.sizeMode = 'font'; state.fixedDims = null; + rememberFontSize(state); if (state.term) { state.term.options.fontSize = state.currentFontPx; forceFontRemeasure(state.term); @@ -1118,6 +1139,15 @@ function setFontSize(state, newSize) { applyRoleAwareLayout(state); } +// Only explicit user sizing is remembered. An auto-calculated font (the one a +// fixed grid is squeezed into) is a consequence of the current viewport, not a +// preference, so restoring it into a differently sized surface would be wrong. +function rememberFontSize(state) { + if (state.sizeMemoryKey) { + sizeMemory.set(state.sizeMemoryKey, state.fitFontPx); + } +} + function setSizeMode(state, mode, dims) { if (window.__aspireTerminalDebug) { console.log('[TERMDIAG] setSizeMode', { @@ -1280,15 +1310,20 @@ function resolveDashboardFontPx() { return DEFAULT_FONT_PX; } -// `options` is optional: { chromeless: bool, showDimensions: bool, ...control -// labels }. Chromeless drops the frame, titlebar and padding so only the xterm -// grid and its footer show (used by the terminal dock and detached terminal -// windows, which supply their own chrome). +// `options` is optional: { chromeless: bool, showDimensions: bool, +// sizeMemoryKey: string, ...control labels }. Chromeless drops the frame, +// titlebar and padding so only the xterm grid and its footer show (used by the +// terminal dock and detached terminal windows, which supply their own chrome). export async function initTerminal(element, wsUrl, dotNetRef, options) { await ensureXtermLoaded(); const chromeless = !!options?.chromeless; - const initialFontPx = chromeless ? resolveDashboardFontPx() : DEFAULT_FONT_PX; + const sizeMemoryKey = options?.sizeMemoryKey || null; + // A remembered size wins over the default so a surface that is remounted + // (a dock pane returning from a detached window) comes back at the size the + // user had left it at rather than resetting to the dashboard's base font. + const rememberedFontPx = sizeMemoryKey ? sizeMemory.get(sizeMemoryKey) : undefined; + const initialFontPx = rememberedFontPx ?? (chromeless ? resolveDashboardFontPx() : DEFAULT_FONT_PX); const id = nextId++; const state = { @@ -1315,6 +1350,7 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { // are sized by the dock splitter and always fit, so they get the font // stepper but not the picker. showDimensions: options?.showDimensions !== false, + sizeMemoryKey, // Chromeless terminals fill whatever space the dock pane or detached // window gives them at the dashboard's font size, so they start in Fit // mode. Everything else starts at the default fixed resolution and only diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 9ffb2c516bb..e7f07150e03 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -80,7 +80,10 @@ } else { - + } } From 1ec713d7bb3d476cb1b3e215ae953c5640c6ee93 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 16:09:37 +1000 Subject: [PATCH 012/106] Render the interaction terminal chromeless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal input rendered with its own titlebar and frame inside a dialog that already supplies a label and framing, and it stayed locked to the producer's grid until the user typed. Both came from the same place. A non-chromeless terminal adopts the producer's dimensions on attach and renders as a secondary viewer, shrinking its font to fit a grid it does not own; only the auto-promote on first keystroke made it fit the pane. It also draws the frame border and body padding, insetting the grid 16px from the container and leaving it out of line with the dialog's other inputs. Rendering it chromeless addresses all of it: the surface claims the HMP1 primary role on attach and fits its own pane, so the grid fills the container as soon as the dialog opens, and the frame, titlebar and padding are dropped so the terminal sits flush with the label above it. The footer is kept so the terminal can still be zoomed, minus the fixed-resolution picker — the container is a fixed box, so a chosen resolution has nothing to act on, matching the dock. Verified in the Terminals playground: on open the terminal fits 26 rows at the dashboard's 13px base font with the dimension picker reporting auto, and the pane, frame, body, xterm and viewport all span the container exactly, flush with the input label. The first keystroke no longer changes the grid. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.cs | 13 +++++----- .../Components/Controls/TerminalView.razor.js | 25 +++++++++++-------- .../Dialogs/InteractionsInputDialog.razor | 13 ++++++++-- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 2f36a686188..2e23930b8d1 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -102,9 +102,9 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable /// titlebar or internal padding, just the xterm grid and its footer. /// /// - /// Used by the terminal dock and detached terminal windows, which supply their own chrome and title. Chromeless - /// terminals also size their grid to fill the available space at the dashboard's base font size, rather than - /// shrinking the font to fit the producer's grid. + /// Used by the terminal dock, detached terminal windows and the interaction dialog's terminal input, all of which + /// supply their own surrounding chrome. Chromeless terminals also size their grid to fill the available space at + /// the dashboard's base font size, rather than shrinking the font to fit the producer's grid. /// [Parameter] public bool Chromeless { get; set; } @@ -126,9 +126,10 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable /// Gets or sets a value indicating whether the footer offers the fixed-resolution picker. /// /// - /// Defaults to . The dock sets this to : its panes are sized by the - /// dock splitter and always fit their grid to the available space, so a fixed resolution has nothing to act on. - /// The font stepper stays available regardless, so dock terminals can still be zoomed. + /// Defaults to . Surfaces whose size is dictated by their container set this to + /// — a dock pane is sized by the dock splitter and the interaction dialog's terminal by the + /// dialog, so both always fit their grid to the available space and a fixed resolution has nothing to act on. The + /// font stepper stays available regardless, so those terminals can still be zoomed. /// [Parameter] public bool ShowDimensionsPicker { get; set; } = true; diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 53059ef93d7..f0308349ba1 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -400,10 +400,11 @@ function ensureTerminalStyles() { /* * The dimension picker only makes sense where the user can act on a fixed * grid: the resource page and a detached terminal window (which is resizable) - * keep it, while a dock pane is sized by the dock's splitter and always fits - * its grid to the available space. The font stepper stays in both cases so - * dock terminals can still be zoomed. Hidden with a class rather than skipped - * in buildFooter so the control wiring stays identical in every mode. + * keep it, while a dock pane and the interaction dialog's terminal are sized by + * their container and always fit their grid to the available space. The font + * stepper stays in both cases so those terminals can still be zoomed. Hidden + * with a class rather than skipped in buildFooter so the control wiring stays + * identical in every mode. */ .aspire-terminal-host.dimensions-hidden #terminal-dims { display: none; @@ -1313,7 +1314,8 @@ function resolveDashboardFontPx() { // `options` is optional: { chromeless: bool, showDimensions: bool, // sizeMemoryKey: string, ...control labels }. Chromeless drops the frame, // titlebar and padding so only the xterm grid and its footer show (used by the -// terminal dock and detached terminal windows, which supply their own chrome). +// terminal dock, detached terminal windows and the interaction dialog's +// terminal input, which supply their own chrome). export async function initTerminal(element, wsUrl, dotNetRef, options) { await ensureXtermLoaded(); @@ -1643,13 +1645,14 @@ function connectClient(state, wsUrl) { // role-aware path: secondary locks-and-scales to producer dims; // primary fits/computes-font into the available stage). applyRoleAwareLayout(state); - // Chromeless terminals size the grid from the pane rather than from - // the producer, so those dims are only correct once we are primary + // Chromeless terminals size the grid from their own pane rather than + // from the producer, so those dims are only correct once we are primary // and can push them upstream. Unlike a resource terminal — which may - // legitimately be driven by a CLI viewer elsewhere — a dock terminal - // is owned by the AppHost purely to be shown here, so claiming - // primary on attach is the expected behaviour rather than snatching - // control from another user. + // legitimately be driven by a CLI viewer elsewhere — a dock, window or + // interaction terminal is owned by the AppHost purely to be shown here, + // so claiming primary on attach is the expected behaviour rather than + // snatching control from another user. This is also what makes the grid + // fill the pane on open instead of only once the user starts typing. if (state.chromeless) { maybeAutoPromote(state); } diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor index f6dd7dbefa2..d0707cd2da0 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor @@ -200,14 +200,23 @@ case InputType.Terminal: @* The terminal's process is owned by the AppHost, not by an orchestrated resource, so the * session is tunneled over the dashboard gRPC connection instead of the terminal host UDS. - * TerminalView is otherwise identical to the resource console experience. *@ + * + * Rendered chromeless: the dialog already supplies the label and framing, so the terminal's + * own titlebar and frame would be a second border around a control that is meant to line up + * with the dialog's other inputs. Chromeless also makes this a fit-to-pane surface that + * claims the HMP1 primary role on attach, so the grid fills the container as soon as the + * dialog opens rather than staying locked to the producer's grid until the user types. The + * footer is kept so the terminal can still be zoomed, minus the fixed-resolution picker — + * the container is a fixed box, so there is nothing for a chosen resolution to act on. *@ var terminalId = $"{localItem.InputKey}-Terminal";
- +
break; From f208cbe22c5e6862c24c77ea2423c630bfd9560c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 16:12:45 +1000 Subject: [PATCH 013/106] Explain why the terminal playground needs InternalsVisibleTo The comment in Terminals.AppHost.csproj described a consequence of the IVT without saying why the IVT exists, in a file that does not even declare it. Say it plainly in both places instead: the grant is declared in Aspire.Hosting.csproj and is needed by exactly one command, WithDockShellCommand, which resolves TerminalService and drives the IAspireTerminal it returns. Both are internal while the terminal API's shape is still in flux, so there is no public seam the playground can call. Flag that this must not merge as-is and name the two ways out: make the terminal API public, or drop that one command. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Terminals.AppHost.csproj | 23 ++++++++++++++++--- src/Aspire.Hosting/Aspire.Hosting.csproj | 6 +++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj index 41dc27d9755..02cbc9718dc 100644 --- a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj +++ b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj @@ -8,9 +8,26 @@ true - + diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index c3b73219835..772611010c2 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -137,8 +137,10 @@ - + From 9c74e9dc9d2c3b0f9fce43a91e95392b0c231906 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 22:27:37 +1000 Subject: [PATCH 014/106] Make the AppHost terminal API public and experimental TerminalService, IAspireTerminal, TerminalSurface, AspireTerminalKey and TerminalLaunchOptions are now public and tagged [Experimental] under ASPIRETERMINAL002, which removes the InternalsVisibleTo grant the Terminals playground needed. Getting there required removing Hex1b from the API surface first. A new TerminalCommand describes a workload as an executable, arguments, working directory, environment and starting grid; TerminalService translates it into a Hex1b builder in one private method. InteractionInput.Terminal changes from Hex1bTerminalBuilder? to TerminalCommand?, so no Hex1b type is reachable from Aspire's public API. The dock's built-in terminal runs an in-process Hex1b app rather than a child process, which a TerminalCommand cannot describe, so it stays on an internal overload and IDockTerminalFactory now returns an internal DockTerminalDefinition. TerminalService keeps an internal constructor, so it is registered with an explicit factory - the DI container only activates public constructors. Also renders a border around the interaction dialog's terminal so the chromeless surface reads as a field rather than bleeding into the dialog background. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../TerminalInteractionCommands.cs | 27 +++--- .../Terminals.AppHost.csproj | 23 +---- .../Dialogs/InteractionsInputDialog.razor.css | 4 + src/Aspire.Hosting/Aspire.Hosting.csproj | 5 - .../Dashboard/DashboardService.cs | 23 +++-- .../Dashboard/DashboardServiceHost.cs | 2 + .../DistributedApplicationBuilder.cs | 8 +- src/Aspire.Hosting/IInteractionService.cs | 16 ++-- src/Aspire.Hosting/InteractionService.cs | 4 +- .../Terminals/AspireTerminalKey.cs | 32 ++++++- .../Terminals/Hex1bAspireTerminal.cs | 10 +- .../Terminals/IAspireTerminal.cs | 5 +- .../Terminals/IDockTerminalFactory.cs | 19 +++- .../PlaceholderDockTerminalFactory.cs | 15 ++- .../Terminals/TerminalCommand.cs | 95 +++++++++++++++++++ .../Terminals/TerminalDiagnostics.cs | 26 +++++ .../Terminals/TerminalLaunchOptions.cs | 19 ++-- .../Terminals/TerminalService.cs | 87 ++++++++++++++--- .../Terminals/TerminalSurface.cs | 5 +- .../Dashboard/DashboardServiceTests.cs | 2 + tests/Shared/TestTerminalService.cs | 2 + 21 files changed, 329 insertions(+), 100 deletions(-) create mode 100644 src/Aspire.Hosting/Terminals/TerminalCommand.cs create mode 100644 src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 6d1e7c8f5d0..3f1f05113a4 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -2,12 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Terminals; -using Hex1b; using Microsoft.Extensions.DependencyInjection; // InputType.Terminal is an experimental spike. PromptInputsAsync is also experimental. #pragma warning disable ASPIREINTERACTION001 +// AppHost-owned terminals - TerminalService, IAspireTerminal, TerminalCommand - are experimental. +#pragma warning disable ASPIRETERMINAL002 + namespace Terminals.AppHost; /// @@ -39,12 +41,11 @@ public static IResourceBuilder WithAppHostShellCommand(this IResourceBuild { var interactionService = commandContext.Services.GetRequiredService(); - // The input takes a *builder*, not a built terminal: Aspire attaches the HMP1 server transport that - // carries the session over gRPC, and that has to happen before Build(). The caller only describes the - // workload. - var terminal = Hex1bTerminal.CreateBuilder() - .WithDimensions(120, 32) - .WithPtyProcess(OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/bash", OperatingSystem.IsWindows() ? [] : ["-i", "-l"]); + // The input describes the workload only. Aspire owns the terminal: it attaches the HMP1 server + // transport that carries the session over gRPC, then runs and tears down the process. + var terminal = OperatingSystem.IsWindows() + ? new TerminalCommand("cmd.exe") + : new TerminalCommand("/bin/bash", "-i", "-l"); var result = await interactionService.PromptInputsAsync( "AppHost shell", @@ -110,7 +111,7 @@ public static IResourceBuilder WithNodeReplCommand(this IReso /// /// This is the motivating scenario for AppHost-owned terminals: shelling into a container in the app model without /// Aspire orchestrating the exec itself. -it is required so docker allocates a TTY on the container side; - /// Hex1b supplies the PTY on this side. + /// Aspire supplies the PTY on this side. /// private static async Task ExecIntoContainerAsync( ExecuteCommandContext commandContext, @@ -121,9 +122,7 @@ private static async Task ExecIntoContainerAsync( { var interactionService = commandContext.Services.GetRequiredService(); - var terminal = Hex1bTerminal.CreateBuilder() - .WithDimensions(120, 32) - .WithPtyProcess("docker", ["exec", "-it", containerName, .. command]); + var terminal = new TerminalCommand("docker", ["exec", "-it", containerName, .. command]); var result = await interactionService.PromptInputsAsync( title, @@ -153,7 +152,7 @@ private static async Task ExecIntoContainerAsync( /// that created it. It also exercises IAspireTerminal's automation surface — send input, wait for output, /// read the screen — which is how AppHost code can script a terminal it owns. /// - [AspireExportIgnore(Reason = "Uses TerminalService, an internal API, and command handlers that are not ATS-compatible.")] + [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")] public static IResourceBuilder WithDockShellCommand(this IResourceBuilder container) { return container.WithCommand( @@ -169,9 +168,7 @@ public static IResourceBuilder WithDockShellCommand(this IRes var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = container.Resource.Name, - Builder = Hex1bTerminal.CreateBuilder() - .WithDimensions(120, 32) - .WithPtyProcess("docker", ["exec", "-it", containerName, "/bin/sh"]) + Command = new TerminalCommand("docker", "exec", "-it", containerName, "/bin/sh") }); // Reveals the dock in every connected browser and switches it to this tab. diff --git a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj index 02cbc9718dc..5ecd3e29b63 100644 --- a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj +++ b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj @@ -8,26 +8,9 @@ true - + + + diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css index 00249b93515..61d44c71c08 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css @@ -69,4 +69,8 @@ flex: 1 1 auto; min-width: 0; overflow: hidden; + /* The terminal is rendered chromeless, so without this it would be an unbounded dark rectangle bleeding into + the dialog. Matches the stroke the dialog's text inputs use, so the terminal reads as another field. */ + border: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-rest); + border-radius: calc(var(--control-corner-radius) * 1px); } diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 772611010c2..e02db465bf1 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -137,11 +137,6 @@ - - diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 71252ac30c9..621f4cc43ae 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -13,6 +13,15 @@ using Microsoft.Extensions.Logging; using static Aspire.Hosting.Interaction; +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + +// Aspire.Hosting.Terminals cannot be imported wholesale: it declares TerminalDescriptor and TerminalChangeType, +// which collide with the identically named proto types this file converts them into. Alias the individual types +// instead, so the AppHost-side names read cleanly and the proto names stay unqualified. +using AppHostTerminalChangeType = Aspire.Hosting.Terminals.TerminalChangeType; +using AppHostTerminalDescriptor = Aspire.Hosting.Terminals.TerminalDescriptor; +using TerminalService = Aspire.Hosting.Terminals.TerminalService; + namespace Aspire.Hosting.Dashboard; /// @@ -28,7 +37,7 @@ namespace Aspire.Hosting.Dashboard; /// counterparts, and importing both namespaces would make every bare use ambiguous. /// [Authorize(Policy = ResourceServiceApiKeyAuthorization.PolicyName)] -internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime, IConfiguration configuration, ILogger logger, IInteractionFileUploadStore fileUploadStore, Terminals.TerminalService terminalService) +internal sealed partial class DashboardService(DashboardServiceData serviceData, IHostEnvironment hostEnvironment, IHostApplicationLifetime hostApplicationLifetime, IConfiguration configuration, ILogger logger, IInteractionFileUploadStore fileUploadStore, TerminalService terminalService) : Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceBase { // gRPC has a maximum receive size of 4MB. Force logs into batches to avoid exceeding receive size. @@ -710,15 +719,15 @@ public override async Task CloseTerminal( return new CloseTerminalResponse(); } - private static TerminalDescriptor ToProtoDescriptor(Terminals.TerminalDescriptor descriptor) + private static TerminalDescriptor ToProtoDescriptor(AppHostTerminalDescriptor descriptor) => new() { TerminalId = descriptor.Id, Title = descriptor.Title }; - private static TerminalChangeType ToProtoChangeType(Terminals.TerminalChangeType changeType) => changeType switch + private static TerminalChangeType ToProtoChangeType(AppHostTerminalChangeType changeType) => changeType switch { - Terminals.TerminalChangeType.Added => TerminalChangeType.Added, - Terminals.TerminalChangeType.Removed => TerminalChangeType.Removed, - Terminals.TerminalChangeType.Retitled => TerminalChangeType.Retitled, - Terminals.TerminalChangeType.Activated => TerminalChangeType.Activated, + AppHostTerminalChangeType.Added => TerminalChangeType.Added, + AppHostTerminalChangeType.Removed => TerminalChangeType.Removed, + AppHostTerminalChangeType.Retitled => TerminalChangeType.Retitled, + AppHostTerminalChangeType.Activated => TerminalChangeType.Activated, _ => TerminalChangeType.Unspecified }; } diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index de0670f7eb8..8897ea89097 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -19,6 +19,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Dashboard; /// diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 9eb947e19f4..25a11bcffac 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -48,6 +48,8 @@ using OpenTelemetry.Resources; using OpenTelemetry.Trace; +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting; /// @@ -468,7 +470,11 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddSingleton(); + // Constructed explicitly rather than by DI activation: TerminalService is public (so AppHost code can + // resolve it) but its constructor is internal, and the DI container only activates public constructors. + _innerBuilder.Services.AddSingleton(sp => new Terminals.TerminalService( + sp.GetRequiredService>(), + sp.GetRequiredService())); ConfigureHealthChecks(); diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index 7fb8c6e0902..20ee81f02e3 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -5,7 +5,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using Hex1b; +using Aspire.Hosting.Terminals; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -471,20 +471,18 @@ public long? MaxFileSize /// /// /// - /// Configure the builder with the workload to run — for example - /// Hex1bTerminal.CreateBuilder().WithPtyProcess("docker", ["exec", "-it", id, "/bin/sh"]). The AppHost - /// attaches the transport and builds and runs the terminal, so the builder must not be built by the caller. + /// Describes the process the terminal runs — for example + /// new TerminalCommand("docker", "exec", "-it", id, "/bin/sh"). The AppHost owns the terminal: it + /// attaches the transport, runs the workload, and tears it down. /// /// /// The session starts lazily when a client first attaches, so a dialog that is dismissed without opening the /// terminal never starts the underlying process. The session is torn down when the interaction completes. /// - /// - /// This property is experimental and exposes a Hex1b type directly. See the note in Aspire.Hosting.csproj. - /// /// - [AspireExportIgnore(Reason = "Hex1bTerminalBuilder is a live builder object owning a local process; it cannot be serialized to polyglot app hosts.")] - public Hex1bTerminalBuilder? Terminal { get; init; } + [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] + [AspireExportIgnore(Reason = "A terminal is a live local process attached to the AppHost; it cannot be serialized to polyglot app hosts.")] + public TerminalCommand? Terminal { get; init; } /// /// Identifies the AppHost-owned terminal created for this input. Stamped by the interaction service when the diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 6823b1f375f..b497dcb44fb 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting; #pragma warning disable ASPIREINTERACTION001 // PromptProgressAsync and related types are experimental. @@ -221,7 +223,7 @@ public async Task> PromptInputsAsy var terminal = _terminalService.CreateTerminal(new Terminals.TerminalLaunchOptions { Title = string.IsNullOrEmpty(input.Label) ? input.Name : input.Label, - Builder = input.Terminal!, + Command = input.Terminal!, Surface = Terminals.TerminalSurface.Interaction }); input.TerminalId = terminal.Id; diff --git a/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs b/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs index 48cc7a3b761..58653d539c0 100644 --- a/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs +++ b/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs @@ -1,6 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Terminals; /// @@ -12,20 +16,46 @@ namespace Aspire.Hosting.Terminals; /// which keeps the mapping under Aspire's control and avoids leaking a third-party enum through /// . /// -internal enum AspireTerminalKey +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public enum AspireTerminalKey { + /// The Enter key — sends a carriage return. Enter, + + /// The Tab key. Tab, + + /// The Escape key. Escape, + + /// The Backspace key. Backspace, + + /// The Delete key. Delete, + + /// The Up arrow key. Up, + + /// The Down arrow key. Down, + + /// The Left arrow key. Left, + + /// The Right arrow key. Right, + + /// The Home key. Home, + + /// The End key. End, + + /// The Page Up key. PageUp, + + /// The Page Down key. PageDown, /// Ctrl+C — sends the interrupt control character. diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index 8ecf0fed98b..dd105edd7d3 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -7,6 +7,8 @@ using Hex1b.Automation; using Microsoft.Extensions.Logging; +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Terminals; /// @@ -44,14 +46,14 @@ internal sealed class Hex1bAspireTerminal : IAspireTerminal private Task? _runTask; private bool _stopped; - public Hex1bAspireTerminal(TerminalService owner, string id, TerminalLaunchOptions options, ILogger logger) + public Hex1bAspireTerminal(TerminalService owner, string id, string title, TerminalSurface surface, Hex1bTerminalBuilder builder, ILogger logger) { _owner = owner; - _builder = options.Builder; + _builder = builder; _logger = logger; Id = id; - Title = options.Title; - Surface = options.Surface; + Title = title; + Surface = surface; } public string Id { get; } diff --git a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs index 7219fd9fc91..6e21198025e 100644 --- a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Aspire.Hosting.Terminals; /// @@ -23,7 +25,8 @@ namespace Aspire.Hosting.Terminals; /// an interaction are disposed automatically when the interaction completes or is cancelled. /// /// -internal interface IAspireTerminal : IAsyncDisposable +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public interface IAspireTerminal : IAsyncDisposable { /// /// Gets the opaque identifier used to address this terminal over the dashboard connection. diff --git a/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs b/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs index 1320f3c61ae..e83eae367cc 100644 --- a/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs +++ b/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs @@ -1,6 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Hex1b; + +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Terminals; /// @@ -13,9 +17,20 @@ namespace Aspire.Hosting.Terminals; internal interface IDockTerminalFactory { /// - /// Creates the launch options for a new dock terminal. + /// Describes a new dock terminal. /// /// A caller-supplied title, or to use the factory's default. /// A 1-based counter of dock terminals created so far, for default titles. - TerminalLaunchOptions Create(string? title, int ordinal); + DockTerminalDefinition Create(string? title, int ordinal); } + +/// +/// A dock terminal's title and configured workload. +/// +/// +/// Deliberately not . That type is public and describes a workload as a +/// — a child process — precisely so Hex1b stays out of Aspire's public API. +/// The dock's built-in terminal is an in-process Hex1b app rather than a process, so it needs the builder +/// directly, and that has to stay on an internal path. +/// +internal sealed record DockTerminalDefinition(string Title, Hex1bTerminalBuilder Builder); diff --git a/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs b/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs index bff67efb23b..15f72d790b1 100644 --- a/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs +++ b/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs @@ -5,6 +5,8 @@ using Hex1b.Input; using Hex1b.Widgets; +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Terminals; /// @@ -23,17 +25,14 @@ namespace Aspire.Hosting.Terminals; /// internal sealed class PlaceholderDockTerminalFactory : IDockTerminalFactory { - public TerminalLaunchOptions Create(string? title, int ordinal) + public DockTerminalDefinition Create(string? title, int ordinal) { var resolvedTitle = title ?? (ordinal == 1 ? "Aspire" : $"Aspire {ordinal}"); - return new TerminalLaunchOptions - { - Title = resolvedTitle, - Surface = TerminalSurface.Dock, - Builder = Hex1bTerminal.CreateBuilder() - .WithHex1bApp(ctx => BuildPlaceholderApp(ctx, resolvedTitle)) - }; + return new DockTerminalDefinition( + resolvedTitle, + Hex1bTerminal.CreateBuilder() + .WithHex1bApp(ctx => BuildPlaceholderApp(ctx, resolvedTitle))); } private static Hex1bWidget BuildPlaceholderApp(RootContext ctx, string title) diff --git a/src/Aspire.Hosting/Terminals/TerminalCommand.cs b/src/Aspire.Hosting/Terminals/TerminalCommand.cs new file mode 100644 index 00000000000..f66a33c5ff2 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalCommand.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Terminals; + +/// +/// Describes the process a terminal runs, and the grid it starts on. +/// +/// +/// +/// This is Aspire's own description of a terminal workload. It exists so terminals can be created from +/// AppHost code without the underlying terminal library (currently Hex1b) appearing in Aspire's public API. +/// Aspire translates it into whatever the implementation needs and attaches the transport itself. +/// +/// +/// The surface is deliberately narrow — a process, its arguments, and the environment it runs in. Hex1b can +/// also host an in-process TUI app rather than a child process, but that is not projected here because it +/// would put Hex1b's widget model into Aspire's public API, which is exactly what this type exists to avoid. +/// +/// +/// +/// Shell into a running container: +/// +/// var command = new TerminalCommand("docker", "exec", "-it", containerName, "/bin/sh"); +/// +/// +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public sealed class TerminalCommand +{ + /// + /// The grid a terminal starts on before any viewer attaches. + /// + /// + /// Chosen to be comfortably wider than the 80x24 default so output that assumes a modern terminal is not + /// wrapped in the moments before a viewer attaches and negotiates the real size. + /// + private const int DefaultColumns = 120; + private const int DefaultRows = 32; + + /// + /// Initializes a new instance of the class. + /// + /// The executable to run. Resolved against PATH when not fully qualified. + /// The arguments passed to . + public TerminalCommand(string executable, params string[] arguments) + { + ArgumentException.ThrowIfNullOrEmpty(executable); + ArgumentNullException.ThrowIfNull(arguments); + + Executable = executable; + Arguments = [.. arguments]; + } + + /// + /// Gets the executable to run. + /// + public string Executable { get; } + + /// + /// Gets the arguments passed to . + /// + public IList Arguments { get; } + + /// + /// Gets or sets the working directory the process starts in. Defaults to the AppHost's working directory. + /// + public string? WorkingDirectory { get; set; } + + /// + /// Gets environment variables applied to the process on top of the AppHost's own environment. + /// + /// + /// The process inherits the AppHost's environment and these are layered over it. That matters for + /// interactive workloads, which generally need an inherited PATH, HOME and TERM + /// to behave like a normal shell. + /// + public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets or sets the number of columns the terminal starts with. + /// + /// + /// This is only the initial grid. A viewer that attaches renegotiates the size to fit the space it has, + /// so this matters mainly for terminals driven by automation before anyone attaches. + /// + public int Columns { get; set; } = DefaultColumns; + + /// + /// Gets or sets the number of rows the terminal starts with. + /// + /// + public int Rows { get; set; } = DefaultRows; +} diff --git a/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs b/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs new file mode 100644 index 00000000000..66fc2561bb7 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Terminals; + +/// +/// Diagnostic ids for the experimental AppHost-owned terminal API. +/// +internal static class TerminalDiagnostics +{ + /// + /// Terminals owned by the AppHost process — , + /// and the types they take. + /// + /// + /// Distinct from ASPIRETERMINAL001, which covers WithTerminal — terminals for DCP-owned + /// resource processes. The two are separate features with separate lifetimes and separate transports, so + /// suppressing one should not silently opt into the other. + /// + public const string AppHostTerminals = "ASPIRETERMINAL002"; + + /// + /// The documentation link format shared by Aspire's experimental diagnostics. + /// + public const string UrlFormat = "https://aka.ms/aspire/diagnostics/{0}"; +} diff --git a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs index d06db2d99f9..294135d5165 100644 --- a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs +++ b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs @@ -1,31 +1,26 @@ // 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 System.Diagnostics.CodeAnalysis; namespace Aspire.Hosting.Terminals; /// /// Describes a terminal to be created by . /// -/// -/// takes a Hex1b type directly. That is a deliberate spike shortcut: it keeps the -/// workload description expressive without designing an Aspire-shaped equivalent up front. It is also the -/// last remaining Hex1b leak on this path — already hides Hex1b from -/// everything downstream, so closing this one is what would make publishable. -/// -internal sealed class TerminalLaunchOptions +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public sealed class TerminalLaunchOptions { /// - /// Gets or sets the title shown on the terminal's dock tab. + /// Gets or sets the title shown on the terminal's dock tab, and in the title bar when the terminal is + /// detached into its own window. /// public required string Title { get; set; } /// - /// Gets or sets the configured workload. Aspire attaches the transport itself, so callers must not - /// call WithHmp1Server or Build on the builder. + /// Gets or sets the process the terminal runs. /// - public required Hex1bTerminalBuilder Builder { get; set; } + public required TerminalCommand Command { get; set; } /// /// Gets or sets the surface the terminal is displayed on. Defaults to . diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index a5c26641f57..2c17e585352 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -6,8 +6,11 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading.Channels; +using Hex1b; using Microsoft.Extensions.Logging; +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Terminals; /// @@ -24,12 +27,14 @@ namespace Aspire.Hosting.Terminals; /// Those are owned by the resource, reachable over a Unix domain socket, and are not tracked here. /// /// -/// The service is internal for now. Making it public requires first replacing -/// with an Aspire-shaped workload description, since that is the -/// only remaining place a Hex1b type is visible. +/// Resolve it from the AppHost's service provider: +/// builder.Services.GetRequiredService<TerminalService>(). Only creation and lookup are public; +/// the members the dashboard uses to attach transports and watch the dock's tab list are internal, because +/// they are transport plumbing rather than something an AppHost author calls. /// /// -internal sealed class TerminalService : IAsyncDisposable +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public sealed class TerminalService : IAsyncDisposable { private readonly ConcurrentDictionary _terminals = new(StringComparer.Ordinal); private readonly ILogger _logger; @@ -39,7 +44,7 @@ internal sealed class TerminalService : IAsyncDisposable private int _disposed; private int _dockTerminalCount; - public TerminalService(ILogger logger, IDockTerminalFactory dockTerminalFactory) + internal TerminalService(ILogger logger, IDockTerminalFactory dockTerminalFactory) { _logger = logger; _dockTerminalFactory = dockTerminalFactory; @@ -48,29 +53,79 @@ public TerminalService(ILogger logger, IDockTerminalFactory doc /// /// Creates a terminal for the dashboard's terminal dock using the configured dock terminal factory. /// - public IAspireTerminal CreateDockTerminal(string? title = null) + internal IAspireTerminal CreateDockTerminal(string? title = null) { - var options = _dockTerminalFactory.Create(title, Interlocked.Increment(ref _dockTerminalCount)); - options.Surface = TerminalSurface.Dock; - return CreateTerminal(options); + var definition = _dockTerminalFactory.Create(title, Interlocked.Increment(ref _dockTerminalCount)); + return CreateTerminal(definition.Title, TerminalSurface.Dock, definition.Builder); } /// /// Creates a terminal. The workload does not start until something needs it: the first viewer attaching, /// or the first automation call. /// + /// Describes the terminal to create. + /// + /// The terminal. Disposing it cancels the workload and removes the terminal from the dashboard; a dock + /// terminal that is meant to outlive the call that created it should be left undisposed, and is torn down + /// when the AppHost shuts down. + /// public IAspireTerminal CreateTerminal(TerminalLaunchOptions options) { ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(options.Command); + + return CreateTerminal(options.Title, options.Surface, CreateBuilder(options.Command)); + } + + /// + /// Translates Aspire's terminal description into a configured Hex1b builder. + /// + /// + /// This is the single point where Hex1b enters the picture, which is what keeps it out of the public API. + /// The process options overload is used rather than WithPtyProcess(file, args) so the working + /// directory and environment can be set; InheritEnvironment is left at its default of + /// , so layers over the AppHost's + /// environment rather than replacing it. Interactive workloads need an inherited PATH/HOME/TERM to behave + /// like a normal shell. + /// + private static Hex1bTerminalBuilder CreateBuilder(TerminalCommand command) + { + return Hex1bTerminal.CreateBuilder() + .WithDimensions(command.Columns, command.Rows) + .WithPtyProcess(process => + { + process.FileName = command.Executable; + process.Arguments = [.. command.Arguments]; + process.WorkingDirectory = command.WorkingDirectory; + + if (command.EnvironmentVariables.Count > 0) + { + process.Environment = new Dictionary(command.EnvironmentVariables, StringComparer.Ordinal); + } + }); + } + + /// + /// Creates a terminal from an already-configured Hex1b builder. + /// + /// + /// Internal because the builder is a Hex1b type. This is the path used by workloads that a + /// cannot describe — notably the dock's built-in terminal, which runs an + /// in-process Hex1b app rather than a child process. + /// + internal IAspireTerminal CreateTerminal(string title, TerminalSurface surface, Hex1bTerminalBuilder builder) + { + ArgumentNullException.ThrowIfNull(title); + ArgumentNullException.ThrowIfNull(builder); ObjectDisposedException.ThrowIf(_disposed != 0, this); // Terminal ids are opaque to the dashboard and appear in websocket query strings, so use a // non-guessable value rather than a sequence number. var id = Guid.NewGuid().ToString("n"); - var terminal = new Hex1bAspireTerminal(this, id, options, _logger); + var terminal = new Hex1bAspireTerminal(this, id, title, surface, builder, _logger); _terminals[id] = terminal; - _logger.LogDebug("Created {Surface} terminal {TerminalId} ({Title}).", options.Surface, id, options.Title); + _logger.LogDebug("Created {Surface} terminal {TerminalId} ({Title}).", surface, id, title); if (terminal.Surface == TerminalSurface.Dock) { @@ -87,7 +142,7 @@ public IAspireTerminal CreateTerminal(TerminalLaunchOptions options) /// A task that completes when the terminal ends or is signalled. /// Callers keep their transport open until it completes. /// - public Task AttachAsync(string terminalId, Stream clientStream, CancellationToken cancellationToken) + internal Task AttachAsync(string terminalId, Stream clientStream, CancellationToken cancellationToken) { if (!_terminals.TryGetValue(terminalId, out var terminal)) { @@ -100,6 +155,9 @@ public Task AttachAsync(string terminalId, Stream clientStream, CancellationToke /// /// Gets a terminal by id. /// + /// The of the terminal to find. + /// The terminal, if one with that id exists. + /// if the terminal was found. public bool TryGetTerminal(string terminalId, [NotNullWhen(true)] out IAspireTerminal? terminal) { if (_terminals.TryGetValue(terminalId, out var found)) @@ -119,7 +177,7 @@ public bool TryGetTerminal(string terminalId, [NotNullWhen(true)] out IAspireTer /// The snapshot and the subscription are produced under the same lock so a terminal created concurrently /// is either in the snapshot or in the change stream, never dropped and never duplicated. /// - public TerminalSubscription SubscribeDockTerminals() + internal TerminalSubscription SubscribeDockTerminals() { lock (_syncLock) { @@ -212,6 +270,9 @@ private void Publish(TerminalChange change) } } + /// + /// Tears down every terminal this service owns. + /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) diff --git a/src/Aspire.Hosting/Terminals/TerminalSurface.cs b/src/Aspire.Hosting/Terminals/TerminalSurface.cs index b7719506a6e..d3cc6019f35 100644 --- a/src/Aspire.Hosting/Terminals/TerminalSurface.cs +++ b/src/Aspire.Hosting/Terminals/TerminalSurface.cs @@ -1,12 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Aspire.Hosting.Terminals; /// /// Identifies where a terminal is displayed in the dashboard. /// -internal enum TerminalSurface +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public enum TerminalSurface { /// /// The terminal is a tab in the dashboard's terminal dock, and is listed by the terminal watch stream. diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index b933bacf8c2..4cda1d74228 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -25,6 +25,8 @@ using DashboardServiceImpl = Aspire.Hosting.Dashboard.DashboardService; using Resource = Aspire.Hosting.ApplicationModel.Resource; +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Tests.Dashboard; [Trait("Partition", "3")] diff --git a/tests/Shared/TestTerminalService.cs b/tests/Shared/TestTerminalService.cs index 380bd3e3c56..3ebc3ccd7c2 100644 --- a/tests/Shared/TestTerminalService.cs +++ b/tests/Shared/TestTerminalService.cs @@ -4,6 +4,8 @@ using Aspire.Hosting.Terminals; using Microsoft.Extensions.Logging.Abstractions; +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Utils; /// From d6e9e7b8834e598c5124ef13650c41b3498722b5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 4 Sep 2026 23:34:52 +1000 Subject: [PATCH 015/106] Add automated number-guess terminal demo to Terminals playground Demonstrates the point of the interaction-service terminal input: an interactive command-line process that prompts for input, driven entirely by AppHost automation while the user watches. The `repl` resource gains a "Number guess (automated terminal)" command that runs a two-stage interaction: 1. A number input prompts for the game's upper limit (default 100). 2. A terminal input runs `dotnet run --file Scripts/numberguess.cs`, and the AppHost bisects the range by typing guesses into the PTY and reading the replies back off the screen, pausing two seconds between guesses so the flow is observable. Once it wins, the command cancels the dialog and replaces it with a message box reporting the number and the guess count. To make this possible, `InteractionInput` gains a `TerminalSession` property. `InteractionInput.Terminal` is only a *description* of a terminal - the interaction service creates the real one and stamps an internal id onto the input, so AppHost code had no way to obtain the `IAspireTerminal` it needs to automate. `TerminalSession` lets the caller create the terminal up front and hand it over instead. Exactly one of the two must be set, and a supplied session must use `TerminalSurface.Interaction` so a dock terminal is never adopted (and then disposed) by a dialog. The script tags every reply with its attempt number (`>> #3: 42 is too low`) so the reader can distinguish a fresh reply from the previous guess still on screen, and cannot match a partially written line. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Terminals/Terminals.AppHost/AppHost.cs | 5 +- .../Terminals.AppHost/Scripts/numberguess.cs | 67 +++++ .../TerminalInteractionCommands.cs | 246 ++++++++++++++++++ .../Terminals.AppHost.csproj | 11 + src/Aspire.Hosting/IInteractionService.cs | 53 +++- src/Aspire.Hosting/InteractionService.cs | 28 +- 6 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 playground/Terminals/Terminals.AppHost/Scripts/numberguess.cs diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index 2ecc176abbe..a216e2ca14f 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -24,7 +24,10 @@ }) // Opens a shell owned by the AppHost rather than orchestrated by Aspire. It has nothing to do with `repl`; // commands just need a host resource to hang off. - .WithAppHostShellCommand(); + .WithAppHostShellCommand() + // Drives an interactive console program from AppHost code: the terminal is shown in a dialog, but the guesses + // are typed by the AppHost, which reads each reply back off the screen and bisects until it wins. + .WithNumberGuessCommand(); // Long-running container that the "Shell into container" interaction command execs into. Aspire is not orchestrating // the exec — the AppHost shells out to `docker exec` — so the container needs a stable, predictable name. diff --git a/playground/Terminals/Terminals.AppHost/Scripts/numberguess.cs b/playground/Terminals/Terminals.AppHost/Scripts/numberguess.cs new file mode 100644 index 00000000000..9a6a32a5f66 --- /dev/null +++ b/playground/Terminals/Terminals.AppHost/Scripts/numberguess.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// A deliberately old-fashioned interactive console game, run as a .NET file-based app +// (`dotnet run --file numberguess.cs -- `). +// +// It exists to demonstrate driving an interactive process from AppHost code: the AppHost shows this program in an +// InputType.Terminal interaction, then plays it by typing guesses and reading the replies back off the terminal +// screen. Nothing here knows it is being automated - it is an ordinary Console.ReadLine app. +// +// The reply format is the contract the automation relies on, so it is deliberately unambiguous: +// +// Guess #1: 50 <- the prompt, plus the tty's echo of what was typed +// >> #1: 50 is too high <- the reply, tagged with the attempt number +// +// Tagging each reply with its attempt number means the automation can wait for ">> #3:" and be certain it is +// reading the response to its third guess rather than a stale line still on screen from an earlier one. + +var limit = args.Length > 0 && int.TryParse(args[0], out var parsed) && parsed > 1 ? parsed : 100; +var secret = Random.Shared.Next(1, limit + 1); + +Console.WriteLine(); +Console.WriteLine("+------------------------------------------+"); +Console.WriteLine("| N U M B E R G U E S S |"); +Console.WriteLine("+------------------------------------------+"); +Console.WriteLine(); +Console.WriteLine($"I'm thinking of a number between 1 and {limit}."); +Console.WriteLine("Type a guess and press Enter. I'll tell you if it's too high or too low."); +Console.WriteLine(); + +for (var attempt = 1; ; attempt++) +{ + Console.Write($"Guess #{attempt}: "); + + var line = Console.ReadLine(); + if (line is null) + { + // stdin closed - the terminal went away. + return 1; + } + + if (!int.TryParse(line.Trim(), out var guess)) + { + Console.WriteLine($" >> #{attempt}: '{line.Trim()}' is not a number"); + continue; + } + + if (guess < secret) + { + Console.WriteLine($" >> #{attempt}: {guess} is too low"); + } + else if (guess > secret) + { + Console.WriteLine($" >> #{attempt}: {guess} is too high"); + } + else + { + Console.WriteLine($" >> #{attempt}: {guess} is correct"); + Console.WriteLine(); + Console.WriteLine($"Got it in {attempt} {(attempt == 1 ? "guess" : "guesses")}. The number was {secret}."); + + // Block rather than exit so the final screen stays live until whoever owns the terminal tears it down. + // Exiting immediately would race the automation's "let the human read the result" pause. + Console.ReadLine(); + return 0; + } +} diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 3f1f05113a4..a382cb30248 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using Aspire.Hosting.Terminals; using Microsoft.Extensions.DependencyInjection; @@ -25,6 +26,15 @@ namespace Terminals.AppHost; /// internal static class TerminalInteractionCommands { + /// The upper limit the number guess dialog starts on. + private const int DefaultUpperLimit = 100; + + /// How long to pause between guesses so the game is watchable rather than instantaneous. + private static readonly TimeSpan s_guessInterval = TimeSpan.FromSeconds(2); + + /// How long to wait for the game to print a prompt or a reply before giving up. + private static readonly TimeSpan s_promptTimeout = TimeSpan.FromSeconds(30); + /// /// Adds a command that opens an interactive shell running as a child process of the AppHost. /// @@ -190,6 +200,232 @@ public static IResourceBuilder WithDockShellCommand(this IRes }); } + /// + /// Adds a command that plays a terminal-based guessing game by driving the process from AppHost code. + /// + /// + /// + /// This is the "automate an interactive prompt" scenario. Plenty of tools an AppHost needs to invoke are only + /// available as interactive console programs — they log in, prompt for confirmation, ask which subscription to + /// use — and there is no API to call instead. An input plus + /// 's automation members lets AppHost code answer those prompts itself while the + /// human watches it happen, and step in whenever it cannot. + /// + /// + /// The flow is: prompt for the game's upper limit, open a terminal running numberguess.cs, then bisect — + /// type a guess, read the reply back off the screen, halve the range — until the number is found. The dialog is + /// then closed from code and replaced with the answer. + /// + /// + [AspireExportIgnore(Reason = "Uses TerminalService, interaction service callbacks, and command handlers that are not ATS-compatible.")] + public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilder resource) where T : IResource + { + return resource.WithCommand( + "terminal-number-guess", + "Number guess (automated terminal)", + executeCommand: async commandContext => + { + var interactionService = commandContext.Services.GetRequiredService(); + var terminalService = commandContext.Services.GetRequiredService(); + + var limitResult = await interactionService.PromptInputsAsync( + "Number guess", + "Pick an upper limit. The AppHost will then play the game itself by typing into a terminal and reading the replies back off the screen.", + [ + new InteractionInput + { + Name = "limit", + Label = "Upper limit", + InputType = InputType.Number, + Value = DefaultUpperLimit.ToString(CultureInfo.InvariantCulture), + Required = true + } + ], + cancellationToken: commandContext.CancellationToken); + + if (limitResult.Canceled) + { + return CommandResults.Failure("Canceled"); + } + + // The dialog's number input only guarantees "a number", so clamp rather than trust it. Below 2 there + // is nothing to bisect, and the upper bound just keeps the game short enough to sit and watch. + if (!int.TryParse(limitResult.Data["limit"].Value, CultureInfo.InvariantCulture, out var limit)) + { + limit = DefaultUpperLimit; + } + limit = Math.Clamp(limit, 2, 1_000_000); + + // Created here rather than by the interaction service, because this command needs the handle in order + // to drive the game. The interaction still owns teardown once the dialog is raised. + var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + { + Title = "Number guess", + Command = BuildNumberGuessCommand(limit), + Surface = TerminalSurface.Interaction + }); + + using var gameCts = CancellationTokenSource.CreateLinkedTokenSource(commandContext.CancellationToken); + + // Start the dialog before playing so a browser can attach while `dotnet run --file` is still + // compiling the script — otherwise the human misses the opening moves. + var dialogTask = interactionService.PromptInputsAsync( + "Number guess", + $"Guessing a number between 1 and {limit}. Every keystroke below is being typed by the AppHost.", + [ + new InteractionInput + { + Name = "game", + Label = "Number guess", + InputType = InputType.Terminal, + TerminalSession = terminal + } + ], + cancellationToken: gameCts.Token); + + var playTask = PlayNumberGuessAsync(terminal, limit, gameCts.Token); + + // If the human closes the dialog first the terminal is torn down underneath us, so stop playing. + if (await Task.WhenAny(dialogTask, playTask).ConfigureAwait(false) == dialogTask) + { + await gameCts.CancelAsync(); + return CommandResults.Failure("Canceled"); + } + + int number; + int attempts; + try + { + (number, attempts) = await playTask; + } + catch (OperationCanceledException) + { + return CommandResults.Failure("Canceled"); + } + catch (Exception ex) + { + await gameCts.CancelAsync(); + return CommandResults.Failure(ex.Message); + } + + // Leave the winning line on screen long enough to read before the dialog disappears. + await Task.Delay(TimeSpan.FromSeconds(2), commandContext.CancellationToken); + + // Cancelling the token the prompt was started with is how code dismisses its own dialog. That also + // disposes the terminal, so the result replaces the terminal rather than stacking on top of it. + await gameCts.CancelAsync(); + await dialogTask; + + await interactionService.PromptMessageBoxAsync( + "Number guess", + $"Found it. The number was {number}, in {attempts} {(attempts == 1 ? "guess" : "guesses")}.", + cancellationToken: commandContext.CancellationToken); + + return CommandResults.Success(); + }); + } + + /// + /// Plays numberguess.cs to completion by bisecting, and returns the number found and how many guesses it took. + /// + /// + /// Bisection needs at most ceil(log2(limit)) guesses, so the loop is bounded by construction. The guard on an + /// exhausted range only fires if the game stops answering consistently, which would otherwise spin forever. + /// + private static async Task<(int Number, int Attempts)> PlayNumberGuessAsync(IAspireTerminal terminal, int limit, CancellationToken cancellationToken) + { + // Generous: this is the first automation call, so it is what starts the workload, and a cold + // `dotnet run --file` has to compile the script before the game prints anything. + await terminal.WaitForTextAsync($"between 1 and {limit}", TimeSpan.FromMinutes(2), cancellationToken); + + var low = 1; + var high = limit; + + for (var attempt = 1; low <= high; attempt++) + { + await terminal.WaitForTextAsync($"Guess #{attempt}: ", s_promptTimeout, cancellationToken); + + // The whole point of the demo is watching it play, so slow it down to human speed. + await Task.Delay(s_guessInterval, cancellationToken); + + var guess = low + ((high - low) / 2); + await terminal.SendTextAsync($"{guess.ToString(CultureInfo.InvariantCulture)}\r", cancellationToken); + + switch (await ReadReplyAsync(terminal, attempt, guess, cancellationToken)) + { + case NumberGuessReply.Correct: + return (guess, attempt); + case NumberGuessReply.TooLow: + low = guess + 1; + break; + case NumberGuessReply.TooHigh: + high = guess - 1; + break; + } + } + + throw new InvalidOperationException("The game ruled out every number in the range without accepting a guess."); + } + + /// + /// Waits for the game's reply to a guess and reads it off the terminal screen. + /// + /// + /// The script tags each reply with its attempt number — >> #3: 42 is too high — so this can match on + /// the whole reply rather than a prefix. That matters: waiting for "#3: 42 is " and then reading the screen + /// would race the rest of the line being written. Polling for one of the three complete replies has no such race, + /// and the attempt number keeps an earlier reply still on screen from being misread as this one. + /// + private static async Task ReadReplyAsync(IAspireTerminal terminal, int attempt, int guess, CancellationToken cancellationToken) + { + var prefix = $">> #{attempt.ToString(CultureInfo.InvariantCulture)}: {guess.ToString(CultureInfo.InvariantCulture)} is "; + var deadline = DateTime.UtcNow + s_promptTimeout; + + while (true) + { + var screen = terminal.GetScreenText(); + + if (screen.Contains(prefix + "correct", StringComparison.Ordinal)) + { + return NumberGuessReply.Correct; + } + + if (screen.Contains(prefix + "too low", StringComparison.Ordinal)) + { + return NumberGuessReply.TooLow; + } + + if (screen.Contains(prefix + "too high", StringComparison.Ordinal)) + { + return NumberGuessReply.TooHigh; + } + + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException($"The game did not reply to guess #{attempt} ({guess}) within {s_promptTimeout.TotalSeconds} seconds."); + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + } + } + + /// + /// Builds the command that runs the numberguess.cs file-based app. + /// + /// + /// The script is copied next to the AppHost binary (see the Scripts\ item group in the project file) so it + /// can be found without knowing where the source tree is. DOTNET_HOST_PATH is preferred over a bare + /// dotnet so the game runs on the same SDK as the AppHost when one is pinned; file-based apps need .NET 10 + /// or later, which whatever is first on PATH may not be. + /// + private static TerminalCommand BuildNumberGuessCommand(int limit) + { + var scriptPath = Path.Combine(AppContext.BaseDirectory, "Scripts", "numberguess.cs"); + var dotnet = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } hostPath ? hostPath : "dotnet"; + + return new TerminalCommand(dotnet, "run", "--file", scriptPath, "--", limit.ToString(CultureInfo.InvariantCulture)); + } + /// /// Resolves the name docker knows this container by. /// @@ -204,4 +440,14 @@ private static string ResolveContainerName(ContainerResource container) ? annotation.Name : container.Name; } + + /// + /// The game's answer to a single guess. + /// + private enum NumberGuessReply + { + TooLow, + TooHigh, + Correct + } } diff --git a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj index 5ecd3e29b63..e4d5aa647a2 100644 --- a/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj +++ b/playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj @@ -12,6 +12,17 @@ + + + + + + diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index 20ee81f02e3..dcec77cb534 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -466,8 +466,8 @@ public long? MaxFileSize public InteractionFileCollection GetFiles() => _files; /// - /// Gets the terminal session to run for an input. Required for terminal inputs - /// and ignored by every other input type. + /// Gets the terminal session to run for an input. Ignored by every other input + /// type. /// /// /// @@ -479,11 +479,60 @@ public long? MaxFileSize /// The session starts lazily when a client first attaches, so a dialog that is dismissed without opening the /// terminal never starts the underlying process. The session is torn down when the interaction completes. /// + /// + /// Exactly one of this property and must be set on a terminal input. Set this one + /// when the dialog simply needs to show a process; set when the AppHost also needs + /// to drive that process. + /// /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] [AspireExportIgnore(Reason = "A terminal is a live local process attached to the AppHost; it cannot be serialized to polyglot app hosts.")] public TerminalCommand? Terminal { get; init; } + /// + /// Gets an already-created terminal to display for an input. Ignored by every + /// other input type. + /// + /// + /// + /// Use this instead of when the AppHost needs a handle on the terminal — typically to + /// script it through 's automation members while the dialog is open. Create the + /// terminal with TerminalService.CreateTerminal, passing + /// , then hand it to the input: + /// + /// + /// + /// var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + /// { + /// Title = "Setup", + /// Command = new TerminalCommand("./setup.sh"), + /// Surface = TerminalSurface.Interaction + /// }); + /// + /// var dialog = interactionService.PromptInputsAsync( + /// "Setup", + /// "Running setup.", + /// [new InteractionInput { Name = "setup", InputType = InputType.Terminal, TerminalSession = terminal }], + /// cancellationToken: cts.Token); + /// + /// await terminal.WaitForTextAsync("Continue? "); + /// await terminal.SendTextAsync("y\r"); + /// + /// + /// + /// The terminal's must be ; a dock + /// terminal would also appear as a tab, and the dialog would tear it out from under the dock when it closes. + /// + /// + /// The interaction still owns teardown: the terminal is disposed when the dialog completes or is cancelled, so + /// the caller does not dispose it. Cancelling the token passed to the prompt is therefore how automation code + /// closes the dialog and ends the session once it is done. + /// + /// + [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] + [AspireExportIgnore(Reason = "A terminal is a live local process attached to the AppHost; it cannot be serialized to polyglot app hosts.")] + public IAspireTerminal? TerminalSession { get; init; } + /// /// Identifies the AppHost-owned terminal created for this input. Stamped by the interaction service when the /// dialog is raised and sent to the dashboard so it can open the tunnel. diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index b497dcb44fb..d4082d953f6 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Threading.Channels; +using Aspire.Hosting.Terminals; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -30,9 +31,9 @@ internal class InteractionService : IInteractionService private readonly IServiceProvider _serviceProvider; private readonly IConfiguration _configuration; private readonly IInteractionFileUploadStore _fileUploadStore; - private readonly Terminals.TerminalService _terminalService; + private readonly TerminalService _terminalService; - public InteractionService(ILogger logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider, IConfiguration configuration, IInteractionFileUploadStore fileUploadStore, Terminals.TerminalService terminalService) + public InteractionService(ILogger logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider, IConfiguration configuration, IInteractionFileUploadStore fileUploadStore, TerminalService terminalService) { _logger = logger; _distributedApplicationOptions = distributedApplicationOptions; @@ -170,9 +171,19 @@ public async Task> PromptInputsAsy for (var i = 0; i < inputs.Count; i++) { var input = inputs[i]; - if (input.InputType == InputType.Terminal && input.Terminal is null) + if (input.InputType == InputType.Terminal) { - throw new InvalidOperationException($"The input '{input.Name}' is a {nameof(InputType.Terminal)} input but does not set {nameof(InteractionInput.Terminal)}."); + if (input.Terminal is null == input.TerminalSession is null) + { + throw new InvalidOperationException($"The input '{input.Name}' is a {nameof(InputType.Terminal)} input, so exactly one of {nameof(InteractionInput.Terminal)} and {nameof(InteractionInput.TerminalSession)} must be set."); + } + + // A dock terminal is listed as a tab and is expected to outlive whatever created it, but the dialog + // disposes its terminal on close. Showing one here would rip it out from under the dock. + if (input.TerminalSession is { } session && session.Surface != TerminalSurface.Interaction) + { + throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.TerminalSession)} to a terminal whose {nameof(IAspireTerminal.Surface)} is {session.Surface}. Terminals shown by an interaction must be created with {nameof(TerminalSurface)}.{nameof(TerminalSurface.Interaction)}."); + } } if (input.DynamicLoading is { } dynamic) @@ -220,13 +231,14 @@ public async Task> PromptInputsAsy { if (input.InputType == InputType.Terminal) { - var terminal = _terminalService.CreateTerminal(new Terminals.TerminalLaunchOptions + // A caller-supplied session is already created — the caller needed the handle so it could + // drive the terminal. Either way the interaction owns teardown from here on. + input.TerminalId = input.TerminalSession?.Id ?? _terminalService.CreateTerminal(new TerminalLaunchOptions { Title = string.IsNullOrEmpty(input.Label) ? input.Name : input.Label, Command = input.Terminal!, - Surface = Terminals.TerminalSurface.Interaction - }); - input.TerminalId = terminal.Id; + Surface = TerminalSurface.Interaction + }).Id; } } } From f09fb4309fe4fe58ce8c1e79a99e7716e9f93361 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 12:50:20 +1000 Subject: [PATCH 016/106] Add diagnostic logging to silent terminal catch handlers Audit the terminal change set for catch blocks that swallowed exceptions without logging, and classify each as expected or unexpected in both the comment and the log level. AttachTerminal and WatchTerminals were entirely silent on the AppHost side: neither routes through ExecuteAsync, so a failed attach only ever reached the browser and a broken dock watch was invisible. Both now log expected teardown at Debug and add an unexpected-error handler at Error. WatchTerminals also leaked its subscription. The change channel is registered eagerly in SubscribeDockTerminals, but was only released from the StreamChanges iterator's finally, which never runs if enumeration does not start -- and the snapshot write sat outside the try, so a disconnect in that window abandoned an unbounded channel that every later change would accumulate in. TerminalSubscription is now disposable so the registration is released deterministically. Also make TerminalCommand's only mandatory constructor argument the executable, with arguments set as a property afterwards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../TerminalInteractionCommands.cs | 25 ++++++++-- .../Components/Layout/TerminalDock.razor.cs | 1 + .../Components/Pages/TerminalWindow.razor.cs | 1 + .../ServiceClient/GrpcTerminalClientStream.cs | 3 +- .../Dashboard/DashboardService.cs | 46 +++++++++++++++---- src/Aspire.Hosting/IInteractionService.cs | 2 +- .../Terminals/Hex1bAspireTerminal.cs | 7 ++- .../Terminals/TerminalCommand.cs | 14 +++--- .../Terminals/TerminalService.cs | 25 +++++++++- .../Hmp1UdsServerListenerFilter.cs | 34 ++++++++------ 10 files changed, 116 insertions(+), 42 deletions(-) diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index a382cb30248..72c28d3bc15 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -4,6 +4,7 @@ using System.Globalization; using Aspire.Hosting.Terminals; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; // InputType.Terminal is an experimental spike. PromptInputsAsync is also experimental. #pragma warning disable ASPIREINTERACTION001 @@ -55,7 +56,7 @@ public static IResourceBuilder WithAppHostShellCommand(this IResourceBuild // transport that carries the session over gRPC, then runs and tears down the process. var terminal = OperatingSystem.IsWindows() ? new TerminalCommand("cmd.exe") - : new TerminalCommand("/bin/bash", "-i", "-l"); + : new TerminalCommand("/bin/bash") { Arguments = ["-i", "-l"] }; var result = await interactionService.PromptInputsAsync( "AppHost shell", @@ -132,7 +133,10 @@ private static async Task ExecIntoContainerAsync( { var interactionService = commandContext.Services.GetRequiredService(); - var terminal = new TerminalCommand("docker", ["exec", "-it", containerName, .. command]); + var terminal = new TerminalCommand("docker") + { + Arguments = ["exec", "-it", containerName, .. command] + }; var result = await interactionService.PromptInputsAsync( title, @@ -178,7 +182,10 @@ public static IResourceBuilder WithDockShellCommand(this IRes var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = container.Resource.Name, - Command = new TerminalCommand("docker", "exec", "-it", containerName, "/bin/sh") + Command = new TerminalCommand("docker") + { + Arguments = ["exec", "-it", containerName, "/bin/sh"] + } }); // Reveals the dock in every connected browser and switches it to this tab. @@ -304,6 +311,13 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde } catch (Exception ex) { + // Unexpected. Surface the message in the dialog, but log the full exception too: the failure is + // otherwise reduced to a one-line string with no stack trace, which is the hardest kind of + // demo failure to diagnose. + commandContext.Services.GetRequiredService() + .CreateLogger(nameof(TerminalInteractionCommands)) + .LogError(ex, "The number guess automation failed unexpectedly."); + await gameCts.CancelAsync(); return CommandResults.Failure(ex.Message); } @@ -423,7 +437,10 @@ private static TerminalCommand BuildNumberGuessCommand(int limit) var scriptPath = Path.Combine(AppContext.BaseDirectory, "Scripts", "numberguess.cs"); var dotnet = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } hostPath ? hostPath : "dotnet"; - return new TerminalCommand(dotnet, "run", "--file", scriptPath, "--", limit.ToString(CultureInfo.InvariantCulture)); + return new TerminalCommand(dotnet) + { + Arguments = ["run", "--file", scriptPath, "--", limit.ToString(CultureInfo.InvariantCulture)] + }; } /// diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index a4f409d0868..9c0c9d3234b 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -423,6 +423,7 @@ public async ValueTask DisposeAsync() } catch (OperationCanceledException) { + // Expected. We cancelled _cts immediately above, so the watch task ends by design. } } diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs index ee08f92cee6..3c007ae635e 100644 --- a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs @@ -166,6 +166,7 @@ public async ValueTask DisposeAsync() } catch (OperationCanceledException) { + // Expected. We cancelled _cts immediately above, so the watch task ends by design. } } diff --git a/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs b/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs index 46d218be86d..d4647db7b6b 100644 --- a/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs +++ b/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs @@ -154,7 +154,8 @@ protected override void Dispose(bool disposing) } catch { - // Nothing useful to do; the connection is going away regardless. + // Expected on a call that is already faulted or cancelled, which is the common case here. There is + // nothing to report: the connection is going away regardless, and the caller is disposing. } _writeLock.Dispose(); diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 621f4cc43ae..12e38832130 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -639,19 +639,34 @@ public override async Task AttachTerminal( try { + logger.LogDebug("Attaching terminal {TerminalId} to a dashboard viewer.", selector.TerminalId); + // Returns once the terminal ends or the caller disconnects. Holding the call open for that whole time is // what keeps the tunnel alive, so this must not be fire-and-forget. await terminalService.AttachAsync(selector.TerminalId, stream, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException ex) { - // The terminal was disposed, or never existed; the dashboard may still be holding a stale dialog or dock - // tab open, so report it as a precondition failure rather than faulting the whole connection. + // Expected. The terminal was disposed, or never existed; the dashboard may still be holding a stale dialog + // or dock tab open, so report it as a precondition failure rather than faulting the whole connection. + // Debug rather than Warning because a stale tab reattaching is routine and the client already receives the + // reason in the status -- anything louder would be noise an operator cannot act on. + logger.LogDebug(ex, "Terminal {TerminalId} is not available to attach. The dashboard is likely holding a view of a terminal that has already ended.", selector.TerminalId); + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // The dashboard closed the tunnel, typically because the browser tab or dialog went away. + // Expected. The dashboard closed the tunnel, typically because the browser tab or dialog went away. + logger.LogDebug("Terminal {TerminalId} tunnel closed by the dashboard.", selector.TerminalId); + } + catch (Exception ex) + { + // Unexpected. Nothing else logs this call: AttachTerminal deliberately does not route through + // ExecuteAsync, because that would log the expected FailedPrecondition above as an error. + logger.LogError(ex, "Unexpected error while tunnelling terminal {TerminalId} to the dashboard.", selector.TerminalId); + + throw; } } @@ -665,15 +680,17 @@ public override async Task WatchTerminals( // Subscribe before writing the snapshot. SubscribeDockTerminals captures both under one lock, so a terminal // created concurrently lands in exactly one of them. - var (initial, changes) = terminalService.SubscribeDockTerminals(); - - var snapshot = new TerminalDescriptorList(); - snapshot.Terminals.AddRange(initial.Select(ToProtoDescriptor)); - await responseStream.WriteAsync(new WatchTerminalsUpdate { Snapshot = snapshot }, cancellationToken).ConfigureAwait(false); + using var subscription = terminalService.SubscribeDockTerminals(); try { - await foreach (var change in changes.WithCancellation(cancellationToken).ConfigureAwait(false)) + // The snapshot write belongs inside the try: if the dashboard disconnects in the window between + // subscribing and the first write, this throws, and letting it escape would skip the disposal above. + var snapshot = new TerminalDescriptorList(); + snapshot.Terminals.AddRange(subscription.InitialState.Select(ToProtoDescriptor)); + await responseStream.WriteAsync(new WatchTerminalsUpdate { Snapshot = snapshot }, cancellationToken).ConfigureAwait(false); + + await foreach (var change in subscription.Subscription.WithCancellation(cancellationToken).ConfigureAwait(false)) { await responseStream.WriteAsync( new WatchTerminalsUpdate @@ -689,7 +706,16 @@ await responseStream.WriteAsync( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // The dashboard disconnected or the AppHost is shutting down. + // Expected. The dashboard disconnected or the AppHost is shutting down. + logger.LogDebug("Terminal dock watch stream closed."); + } + catch (Exception ex) + { + // Unexpected. Without this the dock silently stops updating, because WatchTerminals does not route + // through ExecuteAsync and so has no ambient error logging. + logger.LogError(ex, "Unexpected error while watching dock terminals. The dashboard terminal dock will stop receiving updates."); + + throw; } } diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index dcec77cb534..a36729fc5d5 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -472,7 +472,7 @@ public long? MaxFileSize /// /// /// Describes the process the terminal runs — for example - /// new TerminalCommand("docker", "exec", "-it", id, "/bin/sh"). The AppHost owns the terminal: it + /// new TerminalCommand("docker") { Arguments = ["exec", "-it", id, "/bin/sh"] }. The AppHost owns the terminal: it /// attaches the transport, runs the workload, and tears it down. /// /// diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index dd105edd7d3..ef147345983 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -164,11 +164,14 @@ private async Task RunTerminalAsync(Hex1bTerminal terminal) } catch (OperationCanceledException) { - // Expected when the terminal is disposed while the workload is still running. + // Expected when the terminal is disposed while the workload is still running. Logged so the lifecycle + // reads end-to-end alongside the "Starting terminal" entry above -- otherwise a cancelled workload is + // indistinguishable from one that is still running. + _logger.LogDebug("Terminal {TerminalId} ({Title}) workload was cancelled because the terminal is being disposed.", Id, Title); } catch (Exception ex) { - _logger.LogError(ex, "Terminal {TerminalId} ({Title}) failed.", Id, Title); + _logger.LogError(ex, "Terminal {TerminalId} ({Title}) failed unexpectedly and its session has ended.", Id, Title); } finally { diff --git a/src/Aspire.Hosting/Terminals/TerminalCommand.cs b/src/Aspire.Hosting/Terminals/TerminalCommand.cs index f66a33c5ff2..f4b4adee123 100644 --- a/src/Aspire.Hosting/Terminals/TerminalCommand.cs +++ b/src/Aspire.Hosting/Terminals/TerminalCommand.cs @@ -23,7 +23,10 @@ namespace Aspire.Hosting.Terminals; /// /// Shell into a running container: /// -/// var command = new TerminalCommand("docker", "exec", "-it", containerName, "/bin/sh"); +/// var command = new TerminalCommand("docker") +/// { +/// Arguments = ["exec", "-it", containerName, "/bin/sh"] +/// }; /// /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] @@ -43,14 +46,11 @@ public sealed class TerminalCommand /// Initializes a new instance of the class. /// /// The executable to run. Resolved against PATH when not fully qualified. - /// The arguments passed to . - public TerminalCommand(string executable, params string[] arguments) + public TerminalCommand(string executable) { ArgumentException.ThrowIfNullOrEmpty(executable); - ArgumentNullException.ThrowIfNull(arguments); Executable = executable; - Arguments = [.. arguments]; } /// @@ -59,9 +59,9 @@ public TerminalCommand(string executable, params string[] arguments) public string Executable { get; } /// - /// Gets the arguments passed to . + /// Gets or sets the arguments passed to . /// - public IList Arguments { get; } + public IList Arguments { get; set; } = []; /// /// Gets or sets the working directory the process starts in. Defaults to the AppHost's working directory. diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 2c17e585352..a1c9fe90dce 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -191,7 +191,15 @@ internal TerminalSubscription SubscribeDockTerminals() .Select(t => t.Descriptor) .ToImmutableArray(); - return new TerminalSubscription(initial, StreamChanges()); + return new TerminalSubscription(initial, StreamChanges()) + { + // The channel is registered above, before the caller has a chance to enumerate. StreamChanges is an + // async iterator, so its finally only runs once someone calls MoveNextAsync -- a caller that faults + // before it starts enumerating would otherwise leave the channel registered forever, and because it + // is unbounded every later change would accumulate in it. Unsubscribe gives callers a deterministic + // way to release the registration on that path. Removing twice is harmless. + Unsubscribe = () => ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Remove(c), channel) + }; async IAsyncEnumerable StreamChanges([EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -301,6 +309,19 @@ public async ValueTask DisposeAsync() /// /// The current set of dock terminals plus a stream of subsequent changes. /// +/// +/// Dispose when the subscription is no longer needed. Enumerating to completion also +/// releases the registration, so disposing only matters on paths that abandon the subscription without ever +/// starting to enumerate it. +/// internal sealed record TerminalSubscription( ImmutableArray InitialState, - IAsyncEnumerable Subscription); + IAsyncEnumerable Subscription) : IDisposable +{ + /// + /// Releases the change-stream registration held by this subscription. Safe to call more than once. + /// + public required Action Unsubscribe { get; init; } + + public void Dispose() => Unsubscribe(); +} diff --git a/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs b/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs index fe58a12e163..cb94a7bcb96 100644 --- a/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs +++ b/src/Aspire.TerminalHost/Hmp1UdsServerListenerFilter.cs @@ -97,14 +97,11 @@ public async ValueTask OnSessionEndAsync(TimeSpan elapsed, CancellationToken ct { await _listenerTask.ConfigureAwait(false); } - catch (OperationCanceledException) - { - } - catch (ObjectDisposedException) when (_listenerCts.IsCancellationRequested) - { - } - catch (SocketException) when (_listenerCts.IsCancellationRequested) + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException) { + // Expected. The listener was cancelled and disposed immediately above, and an accept already in + // flight surfaces that teardown as any of these three depending on how far it had progressed. + _logger.LogDebug(ex, "The HMP1 consumer listener ended during session teardown."); } } @@ -135,18 +132,15 @@ private async Task RunListenerAsync(Socket listener, CancellationToken ct) _ = ObserveClientTaskAsync(clientTask); } } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - } - catch (ObjectDisposedException) when (ct.IsCancellationRequested) - { - } - catch (SocketException) when (ct.IsCancellationRequested) + catch (Exception ex) when ((ex is OperationCanceledException or ObjectDisposedException or SocketException) && ct.IsCancellationRequested) { + // Expected. The session is ending: a pending AcceptAsync surfaces the cancel-and-dispose as any of these + // three depending on whether cancellation or the socket disposal won the race. + _logger.LogDebug(ex, "The HMP1 consumer listener stopped accepting because the session is ending."); } catch (Exception ex) { - _logger.LogError(ex, "The HMP1 consumer listener failed."); + _logger.LogError(ex, "The HMP1 consumer listener failed unexpectedly. No further terminal consumers can attach to this session."); _listenerFaulted(ex); } } @@ -157,6 +151,13 @@ private async Task ObserveClientTaskAsync(Task clientTask) { await clientTask.ConfigureAwait(false); } + catch (Exception ex) + { + // Unexpected: AddClientAsync is written to handle its own failures, so reaching here means its cleanup + // path itself threw. This task is fire-and-forget, so without this catch the exception would be + // unobserved and invisible. + _logger.LogError(ex, "Unexpected error while observing an HMP1 consumer."); + } finally { lock (_gate) @@ -175,6 +176,9 @@ private async Task AddClientAsync(Stream stream, CancellationToken ct) catch (Exception ex) when ( ex is IOException or ObjectDisposedException or OperationCanceledException or InvalidOperationException) { + // Expected. The consumer hung up, or the session ended, between accepting the socket and handing it to + // the presentation adapter. + _logger.LogDebug(ex, "An HMP1 consumer disconnected before it could be attached."); await DisposeFailedClientStreamAsync(stream).ConfigureAwait(false); } catch (Exception ex) From 0ed98fd5ec5f7a005a8ded7cc71ab4a2bc1ba6e5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 15:35:01 +1000 Subject: [PATCH 017/106] Harden terminal validation, teardown, and diagnostics Reducing TerminalCommand's constructor to executable-only moved Arguments, Columns, and Rows onto settable properties but left them unvalidated: a null Arguments faulted later inside CreateBuilder, and a zero or negative Columns or Rows reached Hex1b as a bad terminal size. Validate all three in their setters instead. WaitForTextAsync abandoned the underlying Hex1b wait when the caller's token won first, so its later WaitUntilTimeoutException surfaced as an unobserved task exception. Observe it before rethrowing. PromptInputsAsync created terminals before publishing the interaction but only tore them down in CompleteInteractionCore, so an escape between the two left them registered for the AppHost's lifetime. Clean them up in the existing finally as well; the two paths are idempotent. TerminalWebSocketProxy swallowed upstream dispose failures at four sites, which hid the reason a tunnel failed to close. Route them through a shared helper that logs at Debug. Adds Aspire.Hosting.Tests/Terminals covering TerminalCommand validation, the AspireTerminalKey sequence table, TerminalService lifetime and dock subscriptions, and terminal creation and teardown through InteractionService. The dock subscription tests assert on the channel registration directly, because a leaked channel is otherwise silent -- both fail without the Unsubscribe hook they cover. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Terminal/TerminalWebSocketProxy.cs | 30 ++- src/Aspire.Hosting/InteractionService.cs | 16 ++ .../Terminals/Hex1bAspireTerminal.cs | 9 + .../Terminals/TerminalCommand.cs | 38 ++- .../Dashboard/DashboardServiceTests.cs | 3 +- .../AspireTerminalKeySequencesTests.cs | 60 +++++ .../InteractionServiceTerminalTests.cs | 210 +++++++++++++++ .../Terminals/TerminalCommandTests.cs | 102 ++++++++ .../Terminals/TerminalServiceTests.cs | 241 ++++++++++++++++++ 9 files changed, 701 insertions(+), 8 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Terminals/AspireTerminalKeySequencesTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Terminals/TerminalCommandTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index ea81e4b52f2..47ee16e400e 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -180,7 +180,7 @@ internal static async Task HandleAppHostTerminalAsync(HttpContext context, catch (Exception ex) { logger.LogWarning(ex, "Failed to accept AppHost terminal WebSocket for {TerminalId}.", terminalId); - try { upstream.Dispose(); } catch { /* swallow */ } + DisposeUpstream(upstream, logger, connectionId); return; } @@ -193,7 +193,7 @@ internal static async Task HandleAppHostTerminalAsync(HttpContext context, finally { // Disposing ends the gRPC call, which is how the AppHost learns this viewer is gone. - try { upstream.Dispose(); } catch { /* swallow */ } + DisposeUpstream(upstream, logger, connectionId); logger.LogInformation("AppHost terminal WS closed for {TerminalId} ({ConnectionId}).", terminalId, connectionId); } @@ -333,7 +333,7 @@ internal static async Task HandleAsync(HttpContext context, catch (Exception ex) { logger.LogWarning(ex, "Failed to accept terminal WebSocket for {Resource}/{Replica}.", resourceName, replicaIndex); - try { upstream.Dispose(); } catch { /* swallow */ } + DisposeUpstream(upstream, logger, connectionId); return; } @@ -350,7 +350,7 @@ internal static async Task HandleAsync(HttpContext context, } finally { - try { upstream.Dispose(); } catch { /* swallow */ } + DisposeUpstream(upstream, logger, connectionId); logger.LogInformation("Terminal WS closed for {Resource}/{Replica} ({ConnectionId}).", resourceName, replicaIndex, connectionId); } @@ -372,6 +372,28 @@ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, } } + /// + /// Tears down the upstream transport, which is what tells the terminal's owner that this viewer is gone. + /// + /// + /// Disposal is best effort because it runs on teardown paths that are already unwinding — the caller has + /// nothing left to do about a failure, and letting it propagate out of a finally would mask the + /// original error. It is logged rather than swallowed so a transport that consistently fails to shut down + /// is still visible when diagnosing terminals that linger after their viewer disconnects. + /// + private static void DisposeUpstream(Stream upstream, ILogger logger, string connectionId) + { + try + { + upstream.Dispose(); + } + catch (Exception ex) + { + // Unexpected, but not actionable on this path: the connection is going away regardless. + logger.LogDebug(ex, "Failed to dispose the upstream terminal transport for {ConnectionId}.", connectionId); + } + } + /// /// Two-task duplex pump: WS→upstream and upstream→WS. Either side /// closing/erroring cancels the other. The first task completing is diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index d4082d953f6..3e31fe759ee 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -297,6 +297,22 @@ public async Task> PromptInputsAsy } finally { + // Terminals are created before the interaction is tracked, so any escape between creation and + // CompleteInteractionCore — a throw from AddInteractionUpdate, or dynamic input loading — would + // otherwise leave them registered with TerminalService for the lifetime of the AppHost. The normal + // path has already nulled TerminalId, which makes this a no-op rather than a double teardown. + if (hasTerminalInputs) + { + foreach (var input in inputs) + { + if (input.InputType == InputType.Terminal && input.TerminalId is { } orphanedTerminalId) + { + _terminalService.RemoveAndDisposeInBackground(orphanedTerminalId); + input.TerminalId = null; + } + } + } + interactionCts.Cancel(); } } diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index ef147345983..a772f6e00d3 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -222,6 +222,15 @@ public async Task WaitForTextAsync(string text, TimeSpan? timeout = null, Cancel var completed = await Task.WhenAny(wait, cancelled.Task).ConfigureAwait(false); if (completed != wait) { + // The wait is abandoned rather than awaited, so nothing would observe the WaitUntilTimeoutException it + // raises when its own timeout later elapses. An unobserved faulted task surfaces on + // TaskScheduler.UnobservedTaskException, which is a process-wide event an AppHost may treat as fatal. + _ = wait.ContinueWith( + static t => _ = t.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + cancellationToken.ThrowIfCancellationRequested(); } diff --git a/src/Aspire.Hosting/Terminals/TerminalCommand.cs b/src/Aspire.Hosting/Terminals/TerminalCommand.cs index f4b4adee123..a45b8c5e58f 100644 --- a/src/Aspire.Hosting/Terminals/TerminalCommand.cs +++ b/src/Aspire.Hosting/Terminals/TerminalCommand.cs @@ -61,7 +61,19 @@ public TerminalCommand(string executable) /// /// Gets or sets the arguments passed to . /// - public IList Arguments { get; set; } = []; + /// is . + public IList Arguments + { + get; + set + { + // Validate on assignment rather than when the terminal is created. The arguments are not read until + // TerminalService translates this command into a process, which is far enough away that a null here + // would otherwise surface as an unattributed NullReferenceException inside terminal creation. + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = []; /// /// Gets or sets the working directory the process starts in. Defaults to the AppHost's working directory. @@ -85,11 +97,31 @@ public TerminalCommand(string executable) /// This is only the initial grid. A viewer that attaches renegotiates the size to fit the space it has, /// so this matters mainly for terminals driven by automation before anyone attaches. /// - public int Columns { get; set; } = DefaultColumns; + /// is less than one. + public int Columns + { + get; + set + { + // A zero or negative grid is not a terminal the emulator can render into, and the failure would + // otherwise appear deep inside the terminal library rather than at the assignment that caused it. + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + field = value; + } + } = DefaultColumns; /// /// Gets or sets the number of rows the terminal starts with. /// /// - public int Rows { get; set; } = DefaultRows; + /// is less than one. + public int Rows + { + get; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + field = value; + } + } = DefaultRows; } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 4cda1d74228..198d14a8817 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -10,6 +10,7 @@ using Aspire.Hosting.Tests.Helpers; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Tests.Utils.Grpc; +using Aspire.Hosting.Terminals; using Aspire.Hosting.Utils; using Aspire.Shared.ConsoleLogs; using Google.Protobuf; @@ -1278,7 +1279,7 @@ private static DashboardServiceImpl CreateDashboardService( IConfiguration? configuration = null, ILogger? logger = null, IInteractionFileUploadStore? fileUploadStore = null, - Terminals.TerminalService? terminalService = null) + TerminalService? terminalService = null) { return new DashboardServiceImpl( dashboardServiceData, diff --git a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalKeySequencesTests.cs b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalKeySequencesTests.cs new file mode 100644 index 00000000000..eb3cebba7ac --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalKeySequencesTests.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +/// +/// Guards the to control-sequence mapping. The sequences are a wire contract with +/// whatever workload the terminal is running, so a wrong byte here does not fail loudly — it silently sends the +/// wrong key, which surfaces as automation that hangs waiting for output that will never come. +/// +[Trait("Partition", "2")] +public class AspireTerminalKeySequencesTests +{ + [Theory] + // Control characters. + [InlineData(AspireTerminalKey.Enter, "\r")] + [InlineData(AspireTerminalKey.Tab, "\t")] + [InlineData(AspireTerminalKey.Escape, "\u001b")] + [InlineData(AspireTerminalKey.CtrlC, "\u0003")] + [InlineData(AspireTerminalKey.CtrlD, "\u0004")] + // DEL rather than BS, which is what emulators send on Unix and what readline-based shells expect. + [InlineData(AspireTerminalKey.Backspace, "\u007f")] + // Normal-mode (CSI) cursor keys rather than the SS3 forms an application-cursor-keys workload would use. + [InlineData(AspireTerminalKey.Up, "\u001b[A")] + [InlineData(AspireTerminalKey.Down, "\u001b[B")] + [InlineData(AspireTerminalKey.Right, "\u001b[C")] + [InlineData(AspireTerminalKey.Left, "\u001b[D")] + [InlineData(AspireTerminalKey.Home, "\u001b[H")] + [InlineData(AspireTerminalKey.End, "\u001b[F")] + // PC-style editing keys, which are tilde-terminated and numbered. + [InlineData(AspireTerminalKey.Delete, "\u001b[3~")] + [InlineData(AspireTerminalKey.PageUp, "\u001b[5~")] + [InlineData(AspireTerminalKey.PageDown, "\u001b[6~")] + public void Get_ReturnsExpectedSequence(AspireTerminalKey key, string expected) + { + Assert.Equal(expected, AspireTerminalKeySequences.Get(key)); + } + + [Fact] + public void Get_CoversEveryDeclaredKey() + { + // A key added to the enum without a switch arm compiles cleanly and only fails when someone presses it, + // so assert the mapping is total rather than relying on the arms enumerated above staying in sync. + foreach (var key in Enum.GetValues()) + { + Assert.NotEmpty(AspireTerminalKeySequences.Get(key)); + } + } + + [Fact] + public void Get_UndefinedKey_Throws() + { + var ex = Assert.Throws(() => AspireTerminalKeySequences.Get((AspireTerminalKey)int.MaxValue)); + Assert.Equal("key", ex.ParamName); + } +} diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs new file mode 100644 index 00000000000..0b558c9875a --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -0,0 +1,210 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +/// +/// Guards how validates and owns terminal-typed inputs. The interaction owns +/// teardown for every terminal it shows, so the tests here are as much about the terminal not outliving the +/// dialog as they are about the validation messages. +/// +[Trait("Partition", "2")] +public class InteractionServiceTerminalTests +{ + [Fact] + public async Task PromptInputsAsync_TerminalInputWithNeitherCommandNorSession_Throws() + { + var (interactionService, _) = CreateInteractionService(); + + var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal }; + + var ex = await Assert.ThrowsAsync( + () => interactionService.PromptInputsAsync("Title", "Message", [input])).DefaultTimeout(); + + Assert.Contains("exactly one of", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task PromptInputsAsync_TerminalInputWithBothCommandAndSession_Throws() + { + var (interactionService, terminalService) = CreateInteractionService(); + + var session = CreateTerminal(terminalService, TerminalSurface.Interaction); + var input = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + Terminal = new TerminalCommand("bash"), + TerminalSession = session + }; + + var ex = await Assert.ThrowsAsync( + () => interactionService.PromptInputsAsync("Title", "Message", [input])).DefaultTimeout(); + + Assert.Contains("exactly one of", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task PromptInputsAsync_TerminalSessionOnTheDockSurface_Throws() + { + var (interactionService, terminalService) = CreateInteractionService(); + + // A dock terminal is listed as a tab and outlives whatever created it. The dialog disposes the terminal it + // shows, so accepting one here would rip a tab out from under the dock when the dialog closed. + var dockTerminal = CreateTerminal(terminalService, TerminalSurface.Dock); + var input = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + TerminalSession = dockTerminal + }; + + var ex = await Assert.ThrowsAsync( + () => interactionService.PromptInputsAsync("Title", "Message", [input])).DefaultTimeout(); + + Assert.Contains(nameof(TerminalSurface.Dock), ex.Message, StringComparison.Ordinal); + + // The dock tab must survive the rejected prompt. + Assert.True(terminalService.TryGetTerminal(dockTerminal.Id, out _)); + } + + [Fact] + public async Task PromptInputsAsync_TerminalInputWithCommand_CreatesTerminalBeforeTheDialogIsShown() + { + var (interactionService, terminalService) = CreateInteractionService(); + + var input = new InteractionInput + { + Name = "shell", + Label = "Shell", + InputType = InputType.Terminal, + Terminal = new TerminalCommand("bash") + }; + + var resultTask = interactionService.PromptInputsAsync("Title", "Message", [input]); + + // The dialog carries a terminal id, so the terminal has to exist by the time the interaction is published. + Assert.NotNull(input.TerminalId); + Assert.True(terminalService.TryGetTerminal(input.TerminalId, out var terminal)); + Assert.Equal("Shell", terminal.Title); + Assert.Equal(TerminalSurface.Interaction, terminal.Surface); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + await CancelInteractionAsync(interactionService, interaction.InteractionId); + + await resultTask.DefaultTimeout(); + } + + [Fact] + public async Task PromptInputsAsync_Cancelled_DisposesTheTerminalItCreated() + { + var (interactionService, terminalService) = CreateInteractionService(); + + var input = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + Terminal = new TerminalCommand("bash") + }; + + var resultTask = interactionService.PromptInputsAsync("Title", "Message", [input]); + var terminalId = input.TerminalId; + Assert.NotNull(terminalId); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + await CancelInteractionAsync(interactionService, interaction.InteractionId); + + var result = await resultTask.DefaultTimeout(); + + // Unlike an uploaded file, nothing about a terminal survives the dialog for the caller to consume, so a + // dismissed dialog must still stop the workload rather than leaving it registered for the AppHost's life. + Assert.True(result.Canceled); + Assert.False(terminalService.TryGetTerminal(terminalId, out _)); + Assert.Null(input.TerminalId); + } + + [Fact] + public async Task PromptInputsAsync_CallerTokenCancelled_DisposesTheTerminalItCreated() + { + var (interactionService, terminalService) = CreateInteractionService(); + + var terminalInput = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + Terminal = new TerminalCommand("bash") + }; + + using var cts = new CancellationTokenSource(); + var resultTask = interactionService.PromptInputsAsync("Title", "Message", [terminalInput], cancellationToken: cts.Token); + + var terminalId = terminalInput.TerminalId; + Assert.NotNull(terminalId); + + // Cancelling the caller's token unwinds the prompt through OnInteractionCancellation rather than through a + // dashboard-driven completion. Both routes end in CompleteInteractionCore, and the finally in + // PromptInputsAsync then runs over inputs whose TerminalId has already been cleared -- so this also covers + // the backstop being idempotent rather than tearing a terminal down twice. + cts.Cancel(); + + var result = await resultTask.DefaultTimeout(); + + Assert.True(result.Canceled); + Assert.False(terminalService.TryGetTerminal(terminalId, out _)); + Assert.Null(terminalInput.TerminalId); + } + + /// + /// Dismisses the dialog the way the dashboard does when the user closes it without submitting. + /// + /// + /// + /// Complete = true with a null State is the dismiss signal, not Complete = false: + /// "not complete" means the dialog stays open, which is how a validation failure is reported. + /// PromptInputsAsync maps a completion whose state is not an input list onto a cancelled result. + /// + /// + /// The callback returns the state directly instead of routing through + /// DashboardServiceData.ProcessInputs. These tests are about the terminal's lifetime rather than + /// input marshalling, and the terminal teardown they assert on happens in CompleteInteractionCore + /// regardless of how the input values were produced. + /// + /// + private static Task CancelInteractionAsync(InteractionService interactionService, int interactionId) + => interactionService.ProcessInteractionFromClientAsync( + interactionId, + (_, _, _) => new InteractionCompletionState { Complete = true }, + CancellationToken.None); + + private static IAspireTerminal CreateTerminal(TerminalService service, TerminalSurface surface) + => service.CreateTerminal(new TerminalLaunchOptions + { + Title = "Terminal", + Command = new TerminalCommand("bash"), + Surface = surface + }); + + private static (InteractionService InteractionService, TerminalService TerminalService) CreateInteractionService() + { + var terminalService = TestTerminalService.Create(); + var interactionService = new InteractionService( + NullLogger.Instance, + new DistributedApplicationOptions(), + new ServiceCollection().BuildServiceProvider(), + new ConfigurationBuilder().Build(), + new TestInteractionFileUploadStore(), + terminalService); + + return (interactionService, terminalService); + } +} diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalCommandTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalCommandTests.cs new file mode 100644 index 00000000000..82a192b51ae --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalCommandTests.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +/// +/// Guards validation on . Everything here fails at the assignment that caused it +/// rather than later, inside terminal creation: the command is not translated into a process until +/// runs, which is far enough away from the +/// property set that an unvalidated value would surface as an unattributed failure from the terminal library. +/// +[Trait("Partition", "2")] +public class TerminalCommandTests +{ + [Fact] + public void Constructor_NullExecutable_Throws() + { + Assert.Throws(() => new TerminalCommand(null!)); + } + + [Fact] + public void Constructor_EmptyExecutable_Throws() + { + Assert.Throws(() => new TerminalCommand(string.Empty)); + } + + [Fact] + public void Constructor_OnlyRequiresExecutable() + { + var command = new TerminalCommand("bash"); + + Assert.Equal("bash", command.Executable); + Assert.Empty(command.Arguments); + Assert.Empty(command.EnvironmentVariables); + Assert.Null(command.WorkingDirectory); + } + + [Fact] + public void Arguments_Null_Throws() + { + var command = new TerminalCommand("bash"); + + var ex = Assert.Throws(() => command.Arguments = null!); + Assert.Equal("value", ex.ParamName); + } + + [Fact] + public void Arguments_SupportsSpreadAssignment() + { + string[] shell = ["/bin/sh"]; + var command = new TerminalCommand("docker") + { + Arguments = ["exec", "-it", "my-container", .. shell] + }; + + Assert.Equal(["exec", "-it", "my-container", "/bin/sh"], command.Arguments); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Columns_NotPositive_Throws(int value) + { + var command = new TerminalCommand("bash"); + + var ex = Assert.Throws(() => command.Columns = value); + Assert.Equal("value", ex.ParamName); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Rows_NotPositive_Throws(int value) + { + var command = new TerminalCommand("bash"); + + var ex = Assert.Throws(() => command.Rows = value); + Assert.Equal("value", ex.ParamName); + } + + [Fact] + public void Dimensions_DefaultToAModernGrid() + { + var command = new TerminalCommand("bash"); + + Assert.Equal(120, command.Columns); + Assert.Equal(32, command.Rows); + } + + [Fact] + public void Dimensions_AcceptPositiveValues() + { + var command = new TerminalCommand("bash") { Columns = 80, Rows = 24 }; + + Assert.Equal(80, command.Columns); + Assert.Equal(24, command.Rows); + } +} diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs new file mode 100644 index 00000000000..d2bbbb96a3a --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -0,0 +1,241 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Reflection; +using System.Threading.Channels; +using Aspire.Hosting.Terminals; +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +/// +/// Guards 's registry and dock change fan-out. No test here starts a workload: +/// terminals are lazy, so creation, lookup, removal, and the dock subscription can all be exercised without a +/// PTY, which is what keeps these tests fast and platform-independent. +/// +[Trait("Partition", "2")] +public class TerminalServiceTests +{ + [Fact] + public void CreateTerminal_NullOptions_Throws() + { + var service = TestTerminalService.Create(); + + Assert.Throws(() => service.CreateTerminal(null!)); + } + + [Fact] + public void CreateTerminal_NullCommand_Throws() + { + var service = TestTerminalService.Create(); + + Assert.Throws(() => service.CreateTerminal(new TerminalLaunchOptions + { + Title = "Shell", + Command = null! + })); + } + + [Fact] + public void CreateTerminal_RegistersTerminalUnderANonGuessableId() + { + var service = TestTerminalService.Create(); + + var terminal = CreateInteractionTerminal(service, "Shell"); + + Assert.Equal("Shell", terminal.Title); + Assert.Equal(TerminalSurface.Interaction, terminal.Surface); + + // Ids appear in websocket query strings, so they must not be a sequence number a caller could walk. + Assert.Equal(32, terminal.Id.Length); + Assert.True(Guid.TryParseExact(terminal.Id, "N", out _)); + + Assert.True(service.TryGetTerminal(terminal.Id, out var found)); + Assert.Same(terminal, found); + } + + [Fact] + public void CreateTerminal_TwoTerminals_GetDistinctIds() + { + var service = TestTerminalService.Create(); + + var first = CreateInteractionTerminal(service, "First"); + var second = CreateInteractionTerminal(service, "Second"); + + Assert.NotEqual(first.Id, second.Id); + } + + [Fact] + public void TryGetTerminal_UnknownId_ReturnsFalse() + { + var service = TestTerminalService.Create(); + + Assert.False(service.TryGetTerminal("does-not-exist", out var terminal)); + Assert.Null(terminal); + } + + [Fact] + public async Task DisposeAsync_RemovesTerminalFromRegistry() + { + var service = TestTerminalService.Create(); + var terminal = CreateInteractionTerminal(service, "Shell"); + + await terminal.DisposeAsync().DefaultTimeout(); + + Assert.False(service.TryGetTerminal(terminal.Id, out _)); + } + + [Fact] + public async Task AttachAsync_UnknownTerminal_Throws() + { + var service = TestTerminalService.Create(); + using var stream = new MemoryStream(); + + await Assert.ThrowsAsync( + () => service.AttachAsync("does-not-exist", stream, CancellationToken.None)).DefaultTimeout(); + } + + [Fact] + public void SubscribeDockTerminals_SnapshotExcludesInteractionTerminals() + { + var service = TestTerminalService.Create(); + var dock = service.CreateDockTerminal("Dock"); + CreateInteractionTerminal(service, "Dialog"); + + using var subscription = service.SubscribeDockTerminals(); + + // An interaction terminal lives and dies with its dialog, so it must never appear as a dock tab. + var descriptor = Assert.Single(subscription.InitialState); + Assert.Equal(dock.Id, descriptor.Id); + } + + [Fact] + public async Task SubscribeDockTerminals_PublishesAddedDockTerminal() + { + var service = TestTerminalService.Create(); + using var subscription = service.SubscribeDockTerminals(); + + Assert.Empty(subscription.InitialState); + + var dock = service.CreateDockTerminal("Dock"); + + await using var changes = subscription.Subscription.GetAsyncEnumerator(CancellationToken.None); + Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout()); + + Assert.Equal(TerminalChangeType.Added, changes.Current.ChangeType); + Assert.Equal(dock.Id, changes.Current.Terminal.Id); + } + + [Fact] + public async Task SubscribeDockTerminals_DoesNotPublishInteractionTerminal() + { + var service = TestTerminalService.Create(); + using var subscription = service.SubscribeDockTerminals(); + + CreateInteractionTerminal(service, "Dialog"); + var dock = service.CreateDockTerminal("Dock"); + + // The interaction terminal was created first, so if it were published at all it would arrive first. + await using var changes = subscription.Subscription.GetAsyncEnumerator(CancellationToken.None); + Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout()); + + Assert.Equal(dock.Id, changes.Current.Terminal.Id); + } + + [Fact] + public void SubscribeDockTerminals_DisposedWithoutEnumerating_ReleasesItsChannelRegistration() + { + var service = TestTerminalService.Create(); + + // The subscription registers an unbounded channel eagerly, but StreamChanges is an async iterator whose + // finally only runs once someone calls MoveNextAsync. A caller that faults before it starts enumerating -- + // a viewer that disconnects while the snapshot is being written, for example -- would otherwise leave a + // channel registered that every subsequent change accumulates into for the lifetime of the AppHost. + var subscription = service.SubscribeDockTerminals(); + Assert.Single(GetOutgoingChannels(service)); + + subscription.Dispose(); + Assert.Empty(GetOutgoingChannels(service)); + + // Removal is idempotent, so the iterator's finally and an explicit Dispose can both run. + subscription.Dispose(); + Assert.Empty(GetOutgoingChannels(service)); + } + + [Fact] + public async Task SubscribeDockTerminals_DisposedWithoutEnumerating_StopsReceivingChanges() + { + var service = TestTerminalService.Create(); + + var abandoned = service.SubscribeDockTerminals(); + abandoned.Dispose(); + + for (var i = 0; i < 5; i++) + { + service.CreateDockTerminal($"Dock {i}"); + } + + // Nothing was written to the released channel, so the fan-out no longer holds those changes anywhere. + Assert.Empty(GetOutgoingChannels(service)); + + // A subscription taken afterwards still works, and sees the dock terminals in its snapshot rather than + // replaying them as changes. + using var live = service.SubscribeDockTerminals(); + Assert.Equal(5, live.InitialState.Length); + + var afterwards = service.CreateDockTerminal("Later"); + + await using var changes = live.Subscription.GetAsyncEnumerator(CancellationToken.None); + Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Equal(afterwards.Id, changes.Current.Terminal.Id); + } + + [Fact] + public async Task DisposeAsync_TearsDownRegisteredTerminals() + { + var service = TestTerminalService.Create(); + var terminal = CreateInteractionTerminal(service, "Shell"); + + await service.DisposeAsync().DefaultTimeout(); + + Assert.False(service.TryGetTerminal(terminal.Id, out _)); + } + + [Fact] + public async Task CreateTerminal_AfterDispose_Throws() + { + var service = TestTerminalService.Create(); + await service.DisposeAsync().DefaultTimeout(); + + Assert.Throws(() => CreateInteractionTerminal(service, "Shell")); + } + + private static IAspireTerminal CreateInteractionTerminal(TerminalService service, string title) + => service.CreateTerminal(new TerminalLaunchOptions + { + Title = title, + Command = new TerminalCommand("bash"), + Surface = TerminalSurface.Interaction + }); + + /// + /// Reads the private channel set the dock fan-out writes to. + /// + /// + /// Registration is deliberately invisible from the public surface: a leaked channel is silent, and the only + /// observable symptom is unbounded memory growth over the AppHost's lifetime. Asserting on the set directly is + /// what makes the leak regression detectable at all -- a test that only checks a later subscription still + /// receives changes passes whether or not the abandoned channel was released. + /// + private static ImmutableHashSet> GetOutgoingChannels(TerminalService service) + { + var field = typeof(TerminalService).GetField("_outgoingChannels", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + + return (ImmutableHashSet>)field.GetValue(service)!; + } +} From 527d20f66f1713c0282c72c65f83bd52f8bf09a3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 15:36:11 +1000 Subject: [PATCH 018/106] Explain why the dashboard client and repository factory use TryAdd Both registrations sit among plain Add* calls with nothing to explain the difference. The Playwright fixture substitutes a mock IDashboardClient and a matching IRepositoryFactory from preConfigureBuilder, which runs before ConfigureServices, so a plain Add* here would silently override them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- src/Aspire.Dashboard/DashboardWebApplication.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index fc2e660aabc..4c8de668c4e 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -291,6 +291,9 @@ public DashboardWebApplication( builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(services => services.GetRequiredService()); + // TryAdd, so a preConfigureBuilder callback can substitute the client. That callback runs before this method, + // and the last registration wins, so a plain AddScoped here would silently override the substitute. The + // Playwright fixture relies on this to serve a mock AppHost. builder.Services.TryAddScoped(); builder.Services.TryAddSingleton(); @@ -319,6 +322,8 @@ public DashboardWebApplication( builder.Services.AddGrpc(); builder.Services.AddSingleton(); builder.Services.AddSingleton(services => services.GetRequiredService()); + // TryAdd for the same reason as IDashboardClient above: the factory decides which resource repository the + // dashboard reads from, so a substituted client is only actually reachable if its factory survives too. builder.Services.TryAddSingleton(); builder.Services.AddSingleton(services => services.GetRequiredService().Current.TelemetryRepository); // OTLP ingestion and telemetry mutations always target the current dashboard run, even when a browser circuit selects a historical run. From fb073eda4cd397aae427a655fa66f69de84ebc48 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 16:33:39 +1000 Subject: [PATCH 019/106] Replace the dock's REPL placeholder with an informational panel Pressing + in the terminal dock used to spin up an in-process Hex1b REPL as a stand-in for a real terminal. That REPL was never a useful thing to hand a user: it is not a shell, it is not attached to anything in the app model, and it made the dock look like it owned a terminal that nobody asked for. Replace it with a static panel that explains where dock terminals actually come from. + now selects the panel instead of creating a terminal, so it behaves like a tab rather than an action, and carries aria-pressed to say so. Because + was the only caller, the whole creation chain is now dead and is removed: the CreateDockTerminal rpc, IDashboardClient.CreateDockTerminalAsync, TerminalService.CreateDockTerminal, IDockTerminalFactory, and PlaceholderDockTerminalFactory. AppHost code can still put a terminal on the dock through the public CreateTerminal(TerminalLaunchOptions) with Surface = Dock, which is what the playground commands use. Removing an rpc is safe here only because the terminal rpc block is new on this branch and has never shipped. The panel is absolutely positioned over the panes rather than swapped in for them, because panes are hidden with visibility rather than display so xterm keeps real dimensions; verified in a browser that a pane keeps its exact rect and its live shell session while the panel is up. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor | 24 +++-- .../Components/Layout/TerminalDock.razor.cs | 87 +++++++++---------- .../Components/Layout/TerminalDock.razor.css | 45 +++++++++- .../Resources/Layout.Designer.cs | 24 ++++- src/Aspire.Dashboard/Resources/Layout.resx | 12 ++- .../Resources/xlf/Layout.cs.xlf | 20 +++-- .../Resources/xlf/Layout.de.xlf | 20 +++-- .../Resources/xlf/Layout.es.xlf | 20 +++-- .../Resources/xlf/Layout.fr.xlf | 20 +++-- .../Resources/xlf/Layout.it.xlf | 20 +++-- .../Resources/xlf/Layout.ja.xlf | 20 +++-- .../Resources/xlf/Layout.ko.xlf | 20 +++-- .../Resources/xlf/Layout.pl.xlf | 20 +++-- .../Resources/xlf/Layout.pt-BR.xlf | 20 +++-- .../Resources/xlf/Layout.ru.xlf | 20 +++-- .../Resources/xlf/Layout.tr.xlf | 20 +++-- .../Resources/xlf/Layout.zh-Hans.xlf | 20 +++-- .../Resources/xlf/Layout.zh-Hant.xlf | 20 +++-- .../ServiceClient/DashboardClient.cs | 15 ---- .../ServiceClient/IDashboardClient.cs | 5 -- .../ServiceClient/SelectedDashboardClient.cs | 6 -- .../Dashboard/DashboardService.cs | 12 --- .../Dashboard/proto/dashboard_service.proto | 10 --- .../DistributedApplicationBuilder.cs | 4 +- .../Terminals/IDockTerminalFactory.cs | 36 -------- .../PlaceholderDockTerminalFactory.cs | 69 --------------- .../Terminals/TerminalService.cs | 14 +-- .../Infrastructure/MockDashboardClient.cs | 1 - .../ResourceOutgoingPeerResolverTests.cs | 1 - .../DefaultTerminalConnectionResolverTests.cs | 1 - .../Terminals/TerminalServiceTests.cs | 18 ++-- tests/Shared/TestDashboardClient.cs | 5 -- tests/Shared/TestTerminalService.cs | 2 +- 33 files changed, 344 insertions(+), 307 deletions(-) delete mode 100644 src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs delete mode 100644 src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index e7f07150e03..bcc2a7bc254 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -11,7 +11,7 @@
@foreach (var terminal in _terminals) { -
@terminal.Title @@ -25,10 +25,11 @@
} + aria-pressed="@(IsPanelVisible ? "true" : "false")" + OnClick="@ShowPanel">
@@ -40,7 +41,7 @@ Class="terminal-dock-detach" Title="@Loc[nameof(Resources.Layout.TerminalDockDetach)]" aria-label="@Loc[nameof(Resources.Layout.TerminalDockDetach)]" - Disabled="@(_activeTerminalId is null || _detachedTerminalIds.Contains(_activeTerminalId))" + Disabled="@(IsPanelVisible || _activeTerminalId is null || _detachedTerminalIds.Contains(_activeTerminalId))" OnClick="@DetachActiveAsync"> @@ -57,7 +58,7 @@ { @* Inactive panes use visibility rather than display so they keep real dimensions — xterm measures its grid from the element box, and a display:none pane would refit to zero columns. *@ -
@if (_detachedTerminalIds.Contains(terminal.TerminalId)) { @@ -87,9 +88,18 @@ }
} - @if (_terminals.Count == 0) + @* Rendered last so it stacks over the panes. The panes stay mounted underneath, which is what lets a + terminal keep its xterm buffer and its socket while the panel is on screen. *@ + @if (IsPanelVisible) { -
@Loc[nameof(Resources.Layout.TerminalDockEmpty)]
+
+
+ +

@Loc[nameof(Resources.Layout.TerminalDockPanelHeading)]

+

@Loc[nameof(Resources.Layout.TerminalDockPanelBody)]

+

@Loc[nameof(Resources.Layout.TerminalDockPanelHint)]

+
+
}
diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 9c0c9d3234b..943a2038013 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -32,9 +32,19 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener private bool _hasBeenOpened; private bool _isVisible; private string? _activeTerminalId; + + /// + /// Whether the user asked for the panel with the + button while terminals exist. + /// + /// + /// Sticky on purpose. A terminal arriving on the watch stream selects itself when nothing is selected, so + /// without this flag any AppHost activity would yank the panel away from under the user. Only an explicit + /// tab click, or the AppHost revealing a terminal through IAspireTerminal.Show(), dismisses it. + /// + private bool _panelRequested; + private int _heightPx = DefaultHeightPx; private Task? _watchTask; - private readonly TaskCompletionSource _firstUpdateReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); private IJSObjectReference? _jsModule; private DotNetObjectReference? _selfRef; private ElementReference _dockElement; @@ -94,41 +104,25 @@ public Task OnPageKeyDownAsync(AspireKeyboardShortcut shortcut) /// Ctrl+` reaches the page depends on the browser, the OS window manager, and any extensions the user has /// installed, so the dock needs an affordance that cannot be intercepted. /// - public async Task ToggleAsync() + public Task ToggleAsync() { if (_isVisible) { Hide(); - return; } - - try + else { - await ShowAsync().ConfigureAwait(true); - } - catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) - { - // The dock is already on screen; it will populate if and when the watch stream recovers. - Logger.LogDebug(ex, "Timed out waiting for the initial terminal list."); + Show(); } + + return Task.CompletedTask; } - private async Task ShowAsync() + private void Show() { _hasBeenOpened = true; _isVisible = true; StateHasChanged(); - - // Wait for the first update before deciding whether the dock is empty. Terminals live in the AppHost, so a - // dock opened for the first time in a second browser (or after a reload) already has tabs, and creating one - // off a not-yet-populated list would spawn a redundant terminal. - await _firstUpdateReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), _cts.Token).ConfigureAwait(true); - - // First open with nothing running gets the built-in terminal, so the dock is never an empty shell. - if (_terminals.Count == 0) - { - await CreateTerminalAsync().ConfigureAwait(true); - } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -163,9 +157,22 @@ private void Hide() private void Activate(string terminalId) { _activeTerminalId = terminalId; + _panelRequested = false; StateHasChanged(); } + /// + /// Whether the panel is covering the terminal panes, either because the user asked for it or because there is + /// no terminal to show. + /// + private bool IsPanelVisible => _panelRequested || _terminals.Count == 0; + + /// + /// Whether a terminal is the one currently on screen. False for every terminal while the panel is up, which is + /// what keeps the tab strip from showing a selected tab whose pane is hidden. + /// + private bool IsPaneActive(string terminalId) => !IsPanelVisible && terminalId == _activeTerminalId; + private TerminalWindowLauncher WindowLauncher => _windowLauncher ??= new TerminalWindowLauncher(JS, OnDetachedWindowClosedAsync); @@ -252,22 +259,18 @@ private Task OnDetachedWindowClosedAsync(string terminalId) return Task.CompletedTask; } - private async Task CreateTerminalAsync() + /// + /// Shows the panel that stands in for a terminal when there is nothing to show, or nothing selected. + /// + /// + /// The + button deliberately does not create anything. Terminals are owned by the AppHost process, not + /// by the browser, so there is no meaningful workload the dashboard could pick on the user's behalf; the panel + /// is where launch actions will go once there is something to launch. + /// + private void ShowPanel() { - try - { - var descriptor = await DashboardClient.CreateDockTerminalAsync(title: null, _cts.Token).ConfigureAwait(true); - - // Select eagerly rather than waiting for the watch stream so the new tab is focused immediately even if - // the notification is still in flight. Apply/Activate are both idempotent by terminal id. - Apply(TerminalChangeType.Added, descriptor); - _activeTerminalId = descriptor.TerminalId; - StateHasChanged(); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Logger.LogWarning(ex, "Failed to create a dock terminal."); - } + _panelRequested = true; + StateHasChanged(); } private async Task CloseTerminalAsync(string terminalId) @@ -304,7 +307,6 @@ private async Task WatchTerminalsAsync(CancellationToken cancellationToken) } } - _firstUpdateReceived.TrySetResult(); await InvokeAsync(StateHasChanged).ConfigureAwait(false); } } @@ -316,11 +318,6 @@ private async Task WatchTerminalsAsync(CancellationToken cancellationToken) { Logger.LogWarning(ex, "Terminal dock watch stream ended unexpectedly."); } - finally - { - // Unblocks a concurrent ShowAsync so a broken stream degrades to an empty dock rather than a hang. - _firstUpdateReceived.TrySetResult(); - } } /// @@ -365,6 +362,8 @@ private async Task WatchTerminalsAsync(CancellationToken cancellationToken) _terminals.Add(descriptor); } _activeTerminalId = descriptor.TerminalId; + // The AppHost is asking for this terminal specifically, which outranks a panel the user opened. + _panelRequested = false; _hasBeenOpened = true; _isVisible = true; break; diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css index f3a9f1f5846..83d79ecff17 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css @@ -50,6 +50,15 @@ color: #58a6ff; } +/* The + selects the panel rather than performing an action, so it needs the same pressed affordance a tab gets. + ::deep because FluentButton renders the element the class lands on, and ::part(control) because the web + component paints an opaque background on its internal control element, which would otherwise hide anything + set on the host. */ +::deep .terminal-dock-new.active::part(control) { + background-color: #0d1117; + border-radius: 4px 4px 0 0; +} + .terminal-dock-tab-title { overflow: hidden; text-overflow: ellipsis; @@ -76,13 +85,45 @@ visibility: hidden; } -.terminal-dock-empty { +/* Stacked over the panes rather than replacing them, so a terminal underneath keeps its xterm buffer and socket. + Opaque for the same reason: the panes are only hidden with visibility, so anything translucent would show them. */ +.terminal-dock-panel { + position: absolute; + inset: 0; display: flex; align-items: center; justify-content: center; - height: 100%; + background-color: #0d1117; + overflow: auto; +} + +.terminal-dock-panel-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + max-width: 420px; + padding: 24px; + text-align: center; color: #8b949e; +} + +.terminal-dock-panel-heading { + margin: 0; + color: #c9d1d9; + font-size: 14px; + font-weight: 600; +} + +.terminal-dock-panel-body, +.terminal-dock-panel-hint { + margin: 0; font-size: 12px; + line-height: 1.5; +} + +.terminal-dock-panel-hint { + color: #6e7681; } .terminal-dock-resize-handle { diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 68d4ef6be8f..fd4402d13c4 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -115,11 +115,29 @@ public static string TerminalDockCloseTab { } /// - /// Looks up a localized string similar to No terminals are open.. + /// Looks up a localized string similar to Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool.. /// - public static string TerminalDockEmpty { + public static string TerminalDockPanelBody { get { - return ResourceManager.GetString("TerminalDockEmpty", resourceCulture); + return ResourceManager.GetString("TerminalDockPanelBody", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No terminal selected. + /// + public static string TerminalDockPanelHeading { + get { + return ResourceManager.GetString("TerminalDockPanelHeading", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Press Ctrl+` to hide this panel.. + /// + public static string TerminalDockPanelHint { + get { + return ResourceManager.GetString("TerminalDockPanelHint", resourceCulture); } } diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 8548b4593a8..20d291e7f91 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -174,9 +174,6 @@ Close terminal - - No terminals are open. - Open terminal in a new window @@ -195,6 +192,15 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + No terminal selected + + + Press Ctrl+` to hide this panel. + Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index 64d7e45e66d..c066e5cc962 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 397df951e66..1890377e2e7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index dbdf12f4c5e..9521ca19c93 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 07d1245a5af..5ecc7231273 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 56e825710aa..8e7aa391bfc 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index b8250640caf..ce4c3f86e86 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 145a04998ab..df5f2f4bae7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 1d98de808d6..e4d13122ef8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index cdc742c918b..676b1d077ae 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 20de56e93b2..64e010789e8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index dca73534598..cde78cd1f01 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 9bcbdda1b5b..4098cde7dcf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 0b283cd6b5e..aef6472bd71 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -167,11 +167,6 @@ This terminal is running in a separate window. - - No terminals are open. - No terminals are open. - - Focus window Focus window @@ -187,6 +182,21 @@ New terminal + + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. + + + + No terminal selected + No terminal selected + + + + Press Ctrl+` to hide this panel. + Press Ctrl+` to hide this panel. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index f02abd77568..f5558824876 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -1166,21 +1166,6 @@ public async IAsyncEnumerable SubscribeTerminalsAsync([Enu } } - public async Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) - { - EnsureInitialized(); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); - var request = new CreateDockTerminalRequest(); - if (!string.IsNullOrWhiteSpace(title)) - { - request.Title = title; - } - - var response = await _client!.CreateDockTerminalAsync(request, headers: _headers, cancellationToken: cts.Token).ConfigureAwait(false); - return response.Terminal; - } - public async Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) { EnsureInitialized(); diff --git a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs index 50d0dcbc515..955aa9d7680 100644 --- a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs @@ -84,11 +84,6 @@ public interface IDashboardClient : IResourceRepository, IAsyncDisposable /// IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken); - /// - /// Asks the AppHost to create a new dock terminal. - /// - Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken); - /// /// Asks the AppHost to close a terminal, terminating its workload. /// diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs index 4a6819b0c73..cd21744b5b3 100644 --- a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -79,12 +79,6 @@ public Task AttachTerminalAsync(string terminalId, CancellationToken can public IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken) => IsReadOnly ? EmptyTerminalsAsync() : currentClient.SubscribeTerminalsAsync(cancellationToken); - public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) - { - EnsureWritable(); - return currentClient.CreateDockTerminalAsync(title, cancellationToken); - } - public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) { EnsureWritable(); diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 12e38832130..126b693eef8 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -719,18 +719,6 @@ await responseStream.WriteAsync( } } - public override Task CreateDockTerminal( - CreateDockTerminalRequest request, - ServerCallContext context) - { - var terminal = terminalService.CreateDockTerminal(string.IsNullOrWhiteSpace(request.Title) ? null : request.Title); - - return Task.FromResult(new CreateDockTerminalResponse - { - Terminal = new TerminalDescriptor { TerminalId = terminal.Id, Title = terminal.Title } - }); - } - public override async Task CloseTerminal( CloseTerminalRequest request, ServerCallContext context) diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 928aac139d9..4ee51620f6c 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -546,15 +546,6 @@ message WatchTerminalsUpdate { } } -message CreateDockTerminalRequest { - // Optional title for the new terminal. The AppHost picks a default when empty. - string title = 1; -} - -message CreateDockTerminalResponse { - TerminalDescriptor terminal = 1; -} - message CloseTerminalRequest { string terminal_id = 1; } @@ -573,6 +564,5 @@ service DashboardService { rpc UploadFile(stream UploadFileChunk) returns (UploadFileResponse); rpc AttachTerminal(stream TerminalClientFrame) returns (stream TerminalServerFrame); rpc WatchTerminals(WatchTerminalsRequest) returns (stream WatchTerminalsUpdate); - rpc CreateDockTerminal(CreateDockTerminalRequest) returns (CreateDockTerminalResponse); rpc CloseTerminal(CloseTerminalRequest) returns (CloseTerminalResponse); } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 25a11bcffac..05822e2b495 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -469,12 +469,10 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddSingleton(); // Constructed explicitly rather than by DI activation: TerminalService is public (so AppHost code can // resolve it) but its constructor is internal, and the DI container only activates public constructors. _innerBuilder.Services.AddSingleton(sp => new Terminals.TerminalService( - sp.GetRequiredService>(), - sp.GetRequiredService())); + sp.GetRequiredService>())); ConfigureHealthChecks(); diff --git a/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs b/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs deleted file mode 100644 index e83eae367cc..00000000000 --- a/src/Aspire.Hosting/Terminals/IDockTerminalFactory.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Hex1b; - -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. - -namespace Aspire.Hosting.Terminals; - -/// -/// Produces the terminal that opens when the dashboard's terminal dock creates a new tab. -/// -/// -/// Indirected through an interface so the dock's default experience can change (today a built-in TUI, -/// later a real Aspire REPL) without knowing anything about it. -/// -internal interface IDockTerminalFactory -{ - /// - /// Describes a new dock terminal. - /// - /// A caller-supplied title, or to use the factory's default. - /// A 1-based counter of dock terminals created so far, for default titles. - DockTerminalDefinition Create(string? title, int ordinal); -} - -/// -/// A dock terminal's title and configured workload. -/// -/// -/// Deliberately not . That type is public and describes a workload as a -/// — a child process — precisely so Hex1b stays out of Aspire's public API. -/// The dock's built-in terminal is an in-process Hex1b app rather than a process, so it needs the builder -/// directly, and that has to stay on an internal path. -/// -internal sealed record DockTerminalDefinition(string Title, Hex1bTerminalBuilder Builder); diff --git a/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs b/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs deleted file mode 100644 index 15f72d790b1..00000000000 --- a/src/Aspire.Hosting/Terminals/PlaceholderDockTerminalFactory.cs +++ /dev/null @@ -1,69 +0,0 @@ -// 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.Input; -using Hex1b.Widgets; - -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. - -namespace Aspire.Hosting.Terminals; - -/// -/// The default dock terminal: a small in-process TUI that stands in for the built-in Aspire REPL. -/// -/// -/// -/// This is a placeholder. It exists to prove the dock end-to-end — that the AppHost can create a terminal, -/// that the dashboard discovers it over the watch stream, that the HMP1 tunnel renders it in xterm.js, and -/// that keystrokes travel back — without also having to design what an Aspire REPL should actually do. -/// -/// -/// Because it runs as a Hex1b app rather than a PTY process, there is no child process to manage and it -/// behaves identically on every platform. -/// -/// -internal sealed class PlaceholderDockTerminalFactory : IDockTerminalFactory -{ - public DockTerminalDefinition Create(string? title, int ordinal) - { - var resolvedTitle = title ?? (ordinal == 1 ? "Aspire" : $"Aspire {ordinal}"); - - return new DockTerminalDefinition( - resolvedTitle, - Hex1bTerminal.CreateBuilder() - .WithHex1bApp(ctx => BuildPlaceholderApp(ctx, resolvedTitle))); - } - - private static Hex1bWidget BuildPlaceholderApp(RootContext ctx, string title) - { - var body = ctx.Center( - ctx.Border(b => - [ - b.VStack(v => - [ - v.Text(""), - v.Text(" The built-in Aspire REPL lives here. "), - v.Text(""), - v.Text(" This placeholder proves the dock, the "), - v.Text(" watch stream, and the HMP1 tunnel. "), - v.Text(""), - ]) - ]).Title($" {title} ")); - - var info = ctx.InfoBar(s => - [ - s.Section(title), - s.Spacer(), - s.Section("placeholder"), - ]).Divider(" "); - - // Bind a key so the terminal visibly accepts focus and input even though the placeholder has - // nothing to do with it. Without a binding the app never requests a redraw, which makes a working - // tunnel look indistinguishable from a dead one. - return ctx.VStack(v => [body.Fill(), info]).InputBindings(bindings => - { - bindings.Key(Hex1bKey.Enter).Action(_ => { }, "Refresh"); - }); - } -} diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index a1c9fe90dce..5b05a122be8 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -38,25 +38,13 @@ public sealed class TerminalService : IAsyncDisposable { private readonly ConcurrentDictionary _terminals = new(StringComparer.Ordinal); private readonly ILogger _logger; - private readonly IDockTerminalFactory _dockTerminalFactory; private readonly object _syncLock = new(); private ImmutableHashSet> _outgoingChannels = []; private int _disposed; - private int _dockTerminalCount; - internal TerminalService(ILogger logger, IDockTerminalFactory dockTerminalFactory) + internal TerminalService(ILogger logger) { _logger = logger; - _dockTerminalFactory = dockTerminalFactory; - } - - /// - /// Creates a terminal for the dashboard's terminal dock using the configured dock terminal factory. - /// - internal IAspireTerminal CreateDockTerminal(string? title = null) - { - var definition = _dockTerminalFactory.Create(title, Interlocked.Increment(ref _dockTerminalCount)); - return CreateTerminal(definition.Title, TerminalSurface.Dock, definition.Builder); } /// diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index 4cfe01659c5..30f98e49848 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -54,7 +54,6 @@ public MockDashboardClient(IReadOnlyList? resources = null) public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public async IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index 2169d0c7b4a..f4faaba0e9b 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -696,7 +696,6 @@ private sealed class MockDashboardClient(Task sub public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public ResourceViewModel? GetResource(string resourceName) => null; public IReadOnlyList GetResources() => []; diff --git a/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs index a076f477231..1bcd8cf3c27 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs @@ -153,7 +153,6 @@ private sealed class DisabledDashboardClient : IDashboardClient public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable SubscribeTerminalsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index d2bbbb96a3a..15e9ea0dfd5 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -103,7 +103,7 @@ await Assert.ThrowsAsync( public void SubscribeDockTerminals_SnapshotExcludesInteractionTerminals() { var service = TestTerminalService.Create(); - var dock = service.CreateDockTerminal("Dock"); + var dock = CreateDockTerminal(service, "Dock"); CreateInteractionTerminal(service, "Dialog"); using var subscription = service.SubscribeDockTerminals(); @@ -121,7 +121,7 @@ public async Task SubscribeDockTerminals_PublishesAddedDockTerminal() Assert.Empty(subscription.InitialState); - var dock = service.CreateDockTerminal("Dock"); + var dock = CreateDockTerminal(service, "Dock"); await using var changes = subscription.Subscription.GetAsyncEnumerator(CancellationToken.None); Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout()); @@ -137,7 +137,7 @@ public async Task SubscribeDockTerminals_DoesNotPublishInteractionTerminal() using var subscription = service.SubscribeDockTerminals(); CreateInteractionTerminal(service, "Dialog"); - var dock = service.CreateDockTerminal("Dock"); + var dock = CreateDockTerminal(service, "Dock"); // The interaction terminal was created first, so if it were published at all it would arrive first. await using var changes = subscription.Subscription.GetAsyncEnumerator(CancellationToken.None); @@ -176,7 +176,7 @@ public async Task SubscribeDockTerminals_DisposedWithoutEnumerating_StopsReceivi for (var i = 0; i < 5; i++) { - service.CreateDockTerminal($"Dock {i}"); + CreateDockTerminal(service, $"Dock {i}"); } // Nothing was written to the released channel, so the fan-out no longer holds those changes anywhere. @@ -187,7 +187,7 @@ public async Task SubscribeDockTerminals_DisposedWithoutEnumerating_StopsReceivi using var live = service.SubscribeDockTerminals(); Assert.Equal(5, live.InitialState.Length); - var afterwards = service.CreateDockTerminal("Later"); + var afterwards = CreateDockTerminal(service, "Later"); await using var changes = live.Subscription.GetAsyncEnumerator(CancellationToken.None); Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout()); @@ -222,6 +222,14 @@ private static IAspireTerminal CreateInteractionTerminal(TerminalService service Surface = TerminalSurface.Interaction }); + private static IAspireTerminal CreateDockTerminal(TerminalService service, string title) + => service.CreateTerminal(new TerminalLaunchOptions + { + Title = title, + Command = new TerminalCommand("bash"), + Surface = TerminalSurface.Dock + }); + /// /// Reads the private channel set the dock fan-out writes to. /// diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index 67f24ee85f2..af0e7e6587d 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -96,11 +96,6 @@ public async IAsyncEnumerable SubscribeTerminalsAsync([Enu yield break; } - public Task CreateDockTerminalAsync(string? title, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/tests/Shared/TestTerminalService.cs b/tests/Shared/TestTerminalService.cs index 3ebc3ccd7c2..29886b6d2ad 100644 --- a/tests/Shared/TestTerminalService.cs +++ b/tests/Shared/TestTerminalService.cs @@ -22,5 +22,5 @@ namespace Aspire.Hosting.Utils; internal static class TestTerminalService { public static TerminalService Create() - => new(NullLogger.Instance, new PlaceholderDockTerminalFactory()); + => new(NullLogger.Instance); } From c484fc16cfe64f669d84f94e0841ebca0f740135 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 16:58:15 +1000 Subject: [PATCH 020/106] Toggle the terminal dock with Shift+` instead of Ctrl+` Ctrl+` is a popular chord: window managers, terminals, and desktop apps (the Copilot app among them) claim it before the browser ever sees it, so the dashboard shortcut was unreliable on real machines. Shift+` is not intercepted, but it is `~`, which people type constantly, both in a terminal for a home directory and in any text field. So it cannot be claimed unconditionally. Move it out of its special case above the isActiveElementInput guard and into calculateShortcut with the other Shift shortcuts, which means a focused input keeps the keystroke and the dock only toggles when focus is elsewhere. To toggle from inside a terminal, F6 moves focus to the terminal controls first, which is the affordance the terminal already advertises. Also delete the xterm custom key handler that used to let Ctrl+` escape to the document. It was dead: attachCustomKeyEventHandler assigns a single field rather than accumulating, and attachTerminalFocusNavigation ran afterwards and replaced it, so Ctrl+` was being swallowed and sent to the PTY the whole time. Comment attachTerminalFocusNavigation as the sole permitted caller so a second handler does not silently disable F6 the same way. Verified in a browser: Ctrl+` no longer toggles, Shift+` toggles when focus is outside a terminal, typing `~` in a focused terminal reaches the shell and leaves the dock open, and F6 followed by Shift+` closes it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Terminals/Terminals.AppHost/AppHost.cs | 2 +- .../TerminalInteractionCommands.cs | 2 +- .../Components/Controls/TerminalView.razor.js | 12 ++--------- .../Components/Layout/TerminalDock.razor.cs | 8 +++---- .../Resources/Layout.Designer.cs | 6 +++--- src/Aspire.Dashboard/Resources/Layout.resx | 6 +++--- .../Resources/xlf/Layout.cs.xlf | 12 +++++------ .../Resources/xlf/Layout.de.xlf | 12 +++++------ .../Resources/xlf/Layout.es.xlf | 12 +++++------ .../Resources/xlf/Layout.fr.xlf | 12 +++++------ .../Resources/xlf/Layout.it.xlf | 12 +++++------ .../Resources/xlf/Layout.ja.xlf | 12 +++++------ .../Resources/xlf/Layout.ko.xlf | 12 +++++------ .../Resources/xlf/Layout.pl.xlf | 12 +++++------ .../Resources/xlf/Layout.pt-BR.xlf | 12 +++++------ .../Resources/xlf/Layout.ru.xlf | 12 +++++------ .../Resources/xlf/Layout.tr.xlf | 12 +++++------ .../Resources/xlf/Layout.zh-Hans.xlf | 12 +++++------ .../Resources/xlf/Layout.zh-Hant.xlf | 12 +++++------ src/Aspire.Dashboard/wwwroot/js/app.js | 21 +++++++------------ 20 files changed, 100 insertions(+), 113 deletions(-) diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index a216e2ca14f..76e24289872 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -35,7 +35,7 @@ .WithContainerName("terminals-playground-shellbox") .WithArgs("sleep", "infinity") .WithContainerShellCommand() - // Same shell, but delivered as a tab in the dashboard's terminal dock (Ctrl+`) rather than a modal dialog. + // Same shell, but delivered as a tab in the dashboard's terminal dock (Shift+`) rather than a modal dialog. .WithDockShellCommand(); // Latest Node.js image, kept alive so the "Node REPL" interaction command can exec into it. The Node REPL is a diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 72c28d3bc15..718b13d898f 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -162,7 +162,7 @@ private static async Task ExecIntoContainerAsync( /// /// /// This is the counterpart to the interaction-input commands above. Instead of a modal dialog bound to a single - /// dialog lifetime, the terminal becomes a tab in the dashboard's terminal dock (Ctrl+`) that outlives the command + /// dialog lifetime, the terminal becomes a tab in the dashboard's terminal dock (Shift+`) that outlives the command /// that created it. It also exercises IAspireTerminal's automation surface — send input, wait for output, /// read the screen — which is how AppHost code can script a terminal it owns. /// diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index f0308349ba1..a15e54d6c5c 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -717,6 +717,8 @@ function moveFocusFromTerminal(state, reverse) { return false; } +// xterm keeps a single custom key event handler (attachCustomKeyEventHandler assigns, it does not accumulate), so +// this must stay the only caller. A second call anywhere would silently replace this one and break F6 navigation. function attachTerminalFocusNavigation(state, term) { term.attachCustomKeyEventHandler((event) => { if (event.key !== 'F6' || event.ctrlKey || event.altKey || event.metaKey) { @@ -1435,16 +1437,6 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { term.loadAddon(fitAddon); term.open(state.terminalBody); - // Let Ctrl+` reach the document so the global keydown listener can toggle the terminal dock. Returning false - // tells xterm not to handle the event; without this xterm swallows it and the dock cannot be closed from a - // focused terminal. Everything else is still handled by xterm as usual. - term.attachCustomKeyEventHandler((e) => { - if (e.ctrlKey && !e.altKey && !e.metaKey && (e.key === '`' || e.key === '~' || e.code === 'Backquote')) { - return false; - } - return true; - }); - state.term = term; state.fitAddon = fitAddon; attachTerminalFocusNavigation(state, term); diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 943a2038013..edfa3fe86a3 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -10,7 +10,7 @@ namespace Aspire.Dashboard.Components.Layout; /// -/// A collapsible, tabbed dock of terminals owned by the AppHost process, toggled with Ctrl+`. +/// A collapsible, tabbed dock of terminals owned by the AppHost process, toggled with Shift+`. /// /// /// @@ -100,9 +100,9 @@ public Task OnPageKeyDownAsync(AspireKeyboardShortcut shortcut) /// Shows the dock, or hides it if it is already showing. /// /// - /// Public so the header button can drive the dock. The keyboard chord alone is not enough: whether - /// Ctrl+` reaches the page depends on the browser, the OS window manager, and any extensions the user has - /// installed, so the dock needs an affordance that cannot be intercepted. + /// Public so the header button can drive the dock. The keyboard chord alone is not enough: Shift+` is + /// suppressed whenever focus is in a terminal or any other text input, because it types ~ there, so the + /// dock needs an affordance that works regardless of where focus happens to be. /// public Task ToggleAsync() { diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index fd4402d13c4..2900b9ea0ed 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -133,7 +133,7 @@ public static string TerminalDockPanelHeading { } /// - /// Looks up a localized string similar to Press Ctrl+` to hide this panel.. + /// Looks up a localized string similar to Press Shift+` to hide this panel.. /// public static string TerminalDockPanelHint { get { @@ -178,7 +178,7 @@ public static string TerminalDockFocusWindow { } /// - /// Looks up a localized string similar to Hide terminal panel (Ctrl+`). + /// Looks up a localized string similar to Hide terminal panel (Shift+`). /// public static string TerminalDockHide { get { @@ -241,7 +241,7 @@ public static string MainLayoutAspireRepoLink { } /// - /// Looks up a localized string similar to Toggle terminal (Ctrl+`). + /// Looks up a localized string similar to Toggle terminal (Shift+`). /// public static string MainLayoutToggleTerminalDock { get { diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 20d291e7f91..47a54852396 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -124,7 +124,7 @@ Help - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) Settings @@ -187,7 +187,7 @@ Focus window - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) New terminal @@ -199,7 +199,7 @@ No terminal selected - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index c066e5cc962..447b76eb31c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 1890377e2e7..5850debb986 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 9521ca19c93..df09005de54 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 5ecc7231273..5ab2bc30d2b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 8e7aa391bfc..9c630858f71 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index ce4c3f86e86..fd3e3b0e8c9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index df5f2f4bae7..6abba65e531 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index e4d13122ef8..145e610b45c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index 676b1d077ae..a3f36178a59 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 64e010789e8..961c71baf9d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index cde78cd1f01..a81c2775c9c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 4098cde7dcf..4fd916bd830 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index aef6472bd71..610f43aeb8e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -73,8 +73,8 @@ - Toggle terminal (Ctrl+`) - Toggle terminal (Ctrl+`) + Toggle terminal (Shift+`) + Toggle terminal (Shift+`) @@ -173,8 +173,8 @@ - Hide terminal panel (Ctrl+`) - Hide terminal panel (Ctrl+`) + Hide terminal panel (Shift+`) + Hide terminal panel (Shift+`) @@ -193,8 +193,8 @@ - Press Ctrl+` to hide this panel. - Press Ctrl+` to hide this panel. + Press Shift+` to hide this panel. + Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index 20e8155499a..b96c0b254c7 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -246,12 +246,6 @@ window.registerGlobalKeydownListener = function (shortcutManager) { return !keyboardEvent.altKey && !keyboardEvent.ctrlKey && !keyboardEvent.metaKey; } - // Ctrl+` toggles the terminal dock. This is the only shortcut that survives a focused input, because the - // terminal itself is a focused input — without this the dock could be opened but never closed from the keyboard. - function isTerminalDockShortcut(e) { - return e.ctrlKey && !e.altKey && !e.metaKey && (e.key === "`" || e.key === "~" || e.code === "Backquote"); - } - function calculateShortcut(e) { if (modifierKeysExceptShiftNotPressed(e)) { /* general shortcuts */ @@ -273,6 +267,14 @@ window.registerGlobalKeydownListener = function (shortcutManager) { case "_": // decrease panel size case "-": return 340; + + // Shift+` toggles the terminal dock. Deliberately handled here, below the isActiveElementInput guard, + // rather than as a special case above it: Shift+` is `~`, which users legitimately type in a terminal + // (~/ for home) and in any text field, so it must reach the focused element instead of being claimed + // as a shortcut. To toggle the dock from a focused terminal, press F6 first to move focus to the + // terminal controls. Ctrl+` would not need that, but window managers and desktop apps intercept it. + case "~": + return 400; } } @@ -295,13 +297,6 @@ window.registerGlobalKeydownListener = function (shortcutManager) { } const keydownListener = function (e) { - // Checked before the input guard on purpose: see isTerminalDockShortcut. - if (isTerminalDockShortcut(e)) { - e.preventDefault(); - shortcutManager.invokeMethodAsync('OnGlobalKeyDown', 400); - return; - } - if (isActiveElementInput()) { return; } From 99e4da7cf34225b4179595f84a17124c9d284077 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 17:06:02 +1000 Subject: [PATCH 021/106] Fix unstyled terminal dock buttons in light theme The dock always paints a dark surface, but its FluentUI descendants resolved the ambient theme's neutral ramp. In light theme that made --neutral-fill-stealth-rest #f7f7f7, so the "stealth" tabstrip buttons painted a near-white block onto the dark strip, and Color.Neutral icons rendered #1a1a1a on it. The empty-state panel icon was worse: near-black on #0d1117, about 1.1:1. Pin the neutral ramp on .terminal-dock so the whole subtree resolves the dock's own GitHub-dark palette. Redefining the custom properties rather than writing rules is what fixes the icons: FluentIcon emits an inline fill: var(--neutral-foreground-rest), which no plain rule can override without !important, but the inline var() resolves against these values. Stealth becomes genuinely transparent so the tabstrip and the active-tab lift show through, and the focus ring switches off Fluent's purple, which is close to unreadable on #0d1117. Also raise the panel hint above the AA floor; it measured 4.12:1. Measured after the change: all four tabstrip buttons transparent with 11.21:1 icons, panel icon 12.26:1, hint 5.07:1, and the + pressed affordance still lifts to #0d1117. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor.css | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css index 83d79ecff17..ac19d1e1dbc 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css @@ -12,6 +12,27 @@ background-color: #0d1117; border-top: 1px solid var(--neutral-stroke-divider-rest); transition: transform 120ms ease-out; + /* The dock always paints a dark terminal surface, whatever theme the rest of the dashboard is in, so it has + to pin the Fluent neutral ramp its descendants read. Otherwise they resolve the ambient theme: in light + theme `--neutral-fill-stealth-rest` is #f7f7f7, so the "stealth" tabstrip buttons paint a near-white block + on the dark strip, and `Color.Neutral` icons - which FluentIcon emits as an inline + `fill: var(--neutral-foreground-rest)` that no plain rule can override - render near-black on near-black. + Redefining the custom properties fixes both without !important, because the inline var() resolves here. + Values track the dock's own GitHub-dark palette rather than Fluent's dark theme so the buttons match the + surrounding surface, tabs, and accent exactly. */ + --neutral-foreground-rest: #c9d1d9; + --neutral-foreground-hover: #f0f6fc; + --neutral-foreground-active: #8b949e; + /* Stealth must be genuinely transparent so the tabstrip and active-tab colours show through. */ + --neutral-fill-stealth-rest: transparent; + --neutral-fill-stealth-hover: #21262d; + --neutral-fill-stealth-active: #30363d; + --neutral-fill-rest: #21262d; + --neutral-fill-hover: #30363d; + --neutral-fill-active: #282e33; + --neutral-stroke-divider-rest: #30363d; + /* Fluent's purple focus ring is close to unreadable on #0d1117; reuse the dock's own accent instead. */ + --focus-stroke-outer: #58a6ff; } .terminal-dock.visible { @@ -123,7 +144,8 @@ } .terminal-dock-panel-hint { - color: #6e7681; + /* Dimmer than the body copy, but not below the 4.5:1 AA floor against #0d1117 (#6e7681 measured 4.12:1). */ + color: #7d8590; } .terminal-dock-resize-handle { From 6821678e00f87d826dc996ecc716f73d6ce7a932 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 17:30:27 +1000 Subject: [PATCH 022/106] Authenticate detached terminal windows via the login endpoint A detached terminal is opened with window.open, and the new window does not always inherit the opener's cookies. Embedded browsers -- the webviews inside editors and desktop apps -- commonly give each view its own cookie jar, and a window handed off to the OS browser starts from that browser's jar instead. The popup then landed on the login page asking for a token the user has no way to see, in a window deliberately opened without an address bar or menus. Route the popup through /login?t=&returnUrl= when the frontend is in BrowserToken mode, so the window authenticates itself into whichever jar it ended up in. No detection of the hosting environment is needed and both cases are fixed the same way, and the middleware redirects to returnUrl on success so the address bar settles on the terminal URL rather than the token. This is the same handoff the AppHost already uses for the dashboard's own resource URL, so the token is not exposed anywhere it is not exposed today. Because the login middleware requires a root-relative returnUrl, OpenAsync now takes the path and resolves it itself, which also keeps the handoff at a single choke point that neither detach call site can bypass. The resolved URL is read from AbsoluteUri rather than ToString(), because ToString() returns the unescaped form and would let a '&' or '#' in the token or path split the query apart. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor.cs | 11 +- .../Components/Pages/ConsoleLogs.razor.cs | 11 +- .../Model/TerminalWindowLauncher.cs | 60 +++++++- .../Terminal/TerminalWindowLauncherTests.cs | 144 ++++++++++++++++++ 4 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 tests/Aspire.Dashboard.Tests/Terminal/TerminalWindowLauncherTests.cs diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index edfa3fe86a3..e89e47b8c3e 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -1,10 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Layout; @@ -78,6 +80,9 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener [Inject] public required NavigationManager NavigationManager { get; init; } + [Inject] + public required IOptionsMonitor DashboardOptions { get; init; } + public IReadOnlySet SubscribedShortcuts { get; } = new HashSet { AspireKeyboardShortcut.ToggleTerminalDock @@ -174,7 +179,7 @@ private void Activate(string terminalId) private bool IsPaneActive(string terminalId) => !IsPanelVisible && terminalId == _activeTerminalId; private TerminalWindowLauncher WindowLauncher - => _windowLauncher ??= new TerminalWindowLauncher(JS, OnDetachedWindowClosedAsync); + => _windowLauncher ??= new TerminalWindowLauncher(JS, NavigationManager, DashboardOptions, OnDetachedWindowClosedAsync); /// /// Pops the active terminal out into its own window. @@ -190,8 +195,8 @@ private async Task DetachActiveAsync() try { - var url = NavigationManager.ToAbsoluteUri($"/terminal-window/apphost/{Uri.EscapeDataString(terminalId)}").ToString(); - var result = await WindowLauncher.OpenAsync(terminalId, url).ConfigureAwait(true); + var path = $"/terminal-window/apphost/{Uri.EscapeDataString(terminalId)}"; + var result = await WindowLauncher.OpenAsync(terminalId, path).ConfigureAwait(true); if (result is TerminalWindowOpenResult.Blocked) { diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 4400d9f69a8..e824f0ad196 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -73,6 +73,13 @@ public void Cancel() [Inject] public required IOptions Options { get; init; } + /// + /// Used to authenticate detached terminal windows. Monitored rather than snapshotted so the token handed to a + /// new window is always the one the login middleware will validate it against. + /// + [Inject] + public required IOptionsMonitor OptionsMonitor { get; init; } + [Inject] public required IDashboardClient DashboardClient { get; init; } @@ -1373,7 +1380,7 @@ private Task HandleViewChangedAsync(string? newView) // Resource terminals never reattach, so the close callback has nothing to do: the inline view was live the // whole time the window was open. private TerminalWindowLauncher TerminalWindowLauncher - => _terminalWindowLauncher ??= new TerminalWindowLauncher(JS, _ => Task.CompletedTask); + => _terminalWindowLauncher ??= new TerminalWindowLauncher(JS, NavigationManager, OptionsMonitor, _ => Task.CompletedTask); /// /// Opens the selected resource's terminal in its own resizable window. @@ -1395,7 +1402,7 @@ private async Task OpenTerminalWindowAsync() var path = $"/terminal-window/resource/{Uri.EscapeDataString(resourceName)}/{_terminalReplicaIndex}"; var result = await TerminalWindowLauncher.OpenAsync( key: $"resource:{resourceName}:{_terminalReplicaIndex}", - url: NavigationManager.ToAbsoluteUri(path).ToString()).ConfigureAwait(true); + path: path).ConfigureAwait(true); if (result is TerminalWindowOpenResult.Blocked) { diff --git a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs index 6f600bda773..73df0e7b052 100644 --- a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs +++ b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Configuration; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; using Microsoft.JSInterop; namespace Aspire.Dashboard.Model; @@ -48,6 +51,8 @@ public sealed class TerminalWindowLauncher : IAsyncDisposable private const int DefaultWindowHeightPx = 600; private readonly IJSRuntime _js; + private readonly NavigationManager _navigationManager; + private readonly IOptionsMonitor _dashboardOptions; private readonly Func _onWindowClosed; private readonly HashSet _tracked = []; @@ -58,39 +63,49 @@ public sealed class TerminalWindowLauncher : IAsyncDisposable /// Initializes a new instance of the class. /// /// The JS runtime for the owning component's circuit. + /// Used to resolve the terminal path against the dashboard's base URI. + /// Used to authenticate the new window when the frontend requires a browser token. /// /// Invoked with the terminal key when the user closes a detached window. Not raised for windows closed through /// , because the caller already knows about those. /// - public TerminalWindowLauncher(IJSRuntime js, Func onWindowClosed) + public TerminalWindowLauncher( + IJSRuntime js, + NavigationManager navigationManager, + IOptionsMonitor dashboardOptions, + Func onWindowClosed) { ArgumentNullException.ThrowIfNull(js); + ArgumentNullException.ThrowIfNull(navigationManager); + ArgumentNullException.ThrowIfNull(dashboardOptions); ArgumentNullException.ThrowIfNull(onWindowClosed); _js = js; + _navigationManager = navigationManager; + _dashboardOptions = dashboardOptions; _onWindowClosed = onWindowClosed; } /// - /// Opens in a window dedicated to the terminal identified by , or - /// focuses the existing window if one is already open for it. + /// Opens the terminal at in a window dedicated to the terminal identified by + /// , or focuses the existing window if one is already open for it. /// /// /// An opaque, page-stable identifier for the terminal — a dock terminal id, or a resource name and replica index. /// - /// The dashboard URL that renders the detached terminal. + /// The root-relative dashboard path that renders the detached terminal. /// Requested window width, in pixels. /// Requested window height, in pixels. public async Task OpenAsync( string key, - string url, + string path, int widthPx = DefaultWindowWidthPx, int heightPx = DefaultWindowHeightPx) { var module = await GetModuleAsync().ConfigureAwait(false); var result = await module.InvokeAsync( - "openTerminalWindow", key, url, widthPx, heightPx, _selfRef).ConfigureAwait(false); + "openTerminalWindow", key, BuildWindowUrl(path), widthPx, heightPx, _selfRef).ConfigureAwait(false); if (result is not "blocked") { @@ -105,6 +120,39 @@ public async Task OpenAsync( }; } + /// + /// Resolves the absolute URL to open, routing it through the login endpoint when the frontend is token-protected. + /// + /// + /// A detached terminal is opened with window.open, and the window does not always inherit the opener's + /// cookies: embedded browsers — the webviews inside editors and desktop apps — commonly give each view its own + /// cookie jar, and a window handed off to the OS browser starts from that browser's jar instead. The popup would + /// then land on the login page asking for a token the user has no way to see, in a window deliberately opened + /// without address bar or menus. + /// + /// Routing through /login makes the window authenticate itself into whichever jar it ended up in, so no + /// detection of the hosting environment is needed and both cases are fixed the same way. The middleware redirects + /// to returnUrl on success, so the address bar settles on the terminal URL rather than the token. This is + /// the same handoff the AppHost already uses for the dashboard's own resource URL, so the token is not exposed + /// anywhere it is not exposed today. + /// + /// + private string BuildWindowUrl(string path) + { + var frontend = _dashboardOptions.CurrentValue.Frontend; + + // Only BrowserToken can be handed over in a URL. An OIDC popup re-runs the authorization code flow against + // the identity provider on its own, and an unsecured frontend has nothing to hand over. + if (frontend.AuthMode is FrontendAuthMode.BrowserToken && frontend.BrowserToken is { Length: > 0 } token) + { + path = $"/login?t={Uri.EscapeDataString(token)}&returnUrl={Uri.EscapeDataString(path)}"; + } + + // AbsoluteUri, not ToString(): Uri.ToString() returns the *unescaped* form, which would undo the escaping + // above and let a '&' or '#' in the token or path split the query apart. + return _navigationManager.ToAbsoluteUri(path).AbsoluteUri; + } + /// /// Brings the window for to the front. Returns if no window is open /// for it, which the caller can treat as a cue to reattach. diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWindowLauncherTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWindowLauncherTests.cs new file mode 100644 index 00000000000..2c61c84c34b --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWindowLauncherTests.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; +using Xunit; + +namespace Aspire.Dashboard.Tests.Terminal; + +public class TerminalWindowLauncherTests +{ + private const string TerminalPath = "/terminal-window/apphost/abc123"; + + [Fact] + public async Task OpenAsync_BrowserToken_RoutesThroughLoginSoTheWindowAuthenticatesItself() + { + var js = new TestJSRuntime(); + var launcher = CreateLauncher(js, CreateOptions(FrontendAuthMode.BrowserToken, "s3cret token")); + + await launcher.OpenAsync(key: "terminal:abc123", path: TerminalPath).DefaultTimeout(); + + // The token and the return path are both escaped, so a token containing URL-significant characters cannot + // truncate the query or smuggle in extra parameters. + Assert.Equal( + "http://localhost/login?t=s3cret%20token&returnUrl=%2Fterminal-window%2Fapphost%2Fabc123", + js.LastWindowUrl); + } + + [Fact] + public async Task OpenAsync_TokenAndPathContainQueryDelimiters_EscapingSurvivesUriResolution() + { + var js = new TestJSRuntime(); + var launcher = CreateLauncher(js, CreateOptions(FrontendAuthMode.BrowserToken, "a&b=c#d")); + + await launcher.OpenAsync(key: "terminal:abc123", path: "/terminal-window/resource/my&app/0").DefaultTimeout(); + + // Resolving against the base URI must not decode the escapes, or the '&' and '#' would split the query and + // the login middleware would see a truncated token and a truncated returnUrl. + Assert.Equal( + "http://localhost/login?t=a%26b%3Dc%23d&returnUrl=%2Fterminal-window%2Fresource%2Fmy%26app%2F0", + js.LastWindowUrl); + } + + [Theory] + [InlineData(FrontendAuthMode.Unsecured, null)] + [InlineData(FrontendAuthMode.OpenIdConnect, null)] + // OIDC re-runs the authorization code flow in the new window, so a token would be meaningless even if configured. + [InlineData(FrontendAuthMode.OpenIdConnect, "ignored-token")] + // A BrowserToken frontend without a token cannot hand anything over; the empty token must not reach the URL. + [InlineData(FrontendAuthMode.BrowserToken, "")] + public async Task OpenAsync_NoTokenToHandOver_OpensTheTerminalDirectly(FrontendAuthMode authMode, string? token) + { + var js = new TestJSRuntime(); + var launcher = CreateLauncher(js, CreateOptions(authMode, token)); + + await launcher.OpenAsync(key: "terminal:abc123", path: TerminalPath).DefaultTimeout(); + + Assert.Equal("http://localhost/terminal-window/apphost/abc123", js.LastWindowUrl); + } + + [Theory] + [InlineData("opened", TerminalWindowOpenResult.Opened)] + [InlineData("focused", TerminalWindowOpenResult.Focused)] + [InlineData("blocked", TerminalWindowOpenResult.Blocked)] + public async Task OpenAsync_MapsJavaScriptOutcome(string jsResult, TerminalWindowOpenResult expected) + { + var js = new TestJSRuntime { OpenResult = jsResult }; + var launcher = CreateLauncher(js, CreateOptions(FrontendAuthMode.Unsecured, token: null)); + + var result = await launcher.OpenAsync(key: "terminal:abc123", path: TerminalPath).DefaultTimeout(); + + Assert.Equal(expected, result); + } + + private static TerminalWindowLauncher CreateLauncher(IJSRuntime js, IOptionsMonitor options) + => new(js, new TestNavigationManager(), options, _ => Task.CompletedTask); + + private static IOptionsMonitor CreateOptions(FrontendAuthMode authMode, string? token) + { + var options = new DashboardOptions + { + Frontend = { AuthMode = authMode, BrowserToken = token } + }; + + return new TestOptionsMonitor(options); + } + + private sealed class TestNavigationManager : NavigationManager + { + public TestNavigationManager() + { + Initialize("http://localhost/", "http://localhost/"); + } + } + + private sealed class TestOptionsMonitor(DashboardOptions options) : IOptionsMonitor + { + public DashboardOptions CurrentValue { get; } = options; + + public DashboardOptions Get(string? name) => CurrentValue; + + public IDisposable? OnChange(Action listener) => null; + } + + /// + /// Stands in for the browser, capturing the URL the launcher asked app-terminalwindow.js to open. + /// + private sealed class TestJSRuntime : IJSRuntime, IJSObjectReference + { + public string OpenResult { get; init; } = "opened"; + + public string? LastWindowUrl { get; private set; } + + public ValueTask InvokeAsync(string identifier, object?[]? args) + { + // The launcher first imports the module, then calls into it. Both land here because this type doubles as + // the module reference it hands back. + if (identifier is "import") + { + return ValueTask.FromResult((TValue)(object)this); + } + + if (identifier is "openTerminalWindow") + { + Assert.NotNull(args); + LastWindowUrl = (string?)args[1]; + return ValueTask.FromResult((TValue)(object)OpenResult); + } + + return ValueTask.FromResult(default(TValue)!); + } + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + => InvokeAsync(identifier, args); + + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TestJSRuntime))] + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} From feb18b06e3c53c25203119e14a34294f4c5415e3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 17:38:34 +1000 Subject: [PATCH 023/106] Revert "Authenticate detached terminal windows via the login endpoint" This reverts commit 6821678e00. Detached terminal windows again open the terminal URL directly, so a window that does not inherit the opener's cookies still lands on the login page. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor.cs | 11 +- .../Components/Pages/ConsoleLogs.razor.cs | 11 +- .../Model/TerminalWindowLauncher.cs | 60 +------- .../Terminal/TerminalWindowLauncherTests.cs | 144 ------------------ 4 files changed, 11 insertions(+), 215 deletions(-) delete mode 100644 tests/Aspire.Dashboard.Tests/Terminal/TerminalWindowLauncherTests.cs diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index e89e47b8c3e..edfa3fe86a3 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -1,12 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Options; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Layout; @@ -80,9 +78,6 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener [Inject] public required NavigationManager NavigationManager { get; init; } - [Inject] - public required IOptionsMonitor DashboardOptions { get; init; } - public IReadOnlySet SubscribedShortcuts { get; } = new HashSet { AspireKeyboardShortcut.ToggleTerminalDock @@ -179,7 +174,7 @@ private void Activate(string terminalId) private bool IsPaneActive(string terminalId) => !IsPanelVisible && terminalId == _activeTerminalId; private TerminalWindowLauncher WindowLauncher - => _windowLauncher ??= new TerminalWindowLauncher(JS, NavigationManager, DashboardOptions, OnDetachedWindowClosedAsync); + => _windowLauncher ??= new TerminalWindowLauncher(JS, OnDetachedWindowClosedAsync); /// /// Pops the active terminal out into its own window. @@ -195,8 +190,8 @@ private async Task DetachActiveAsync() try { - var path = $"/terminal-window/apphost/{Uri.EscapeDataString(terminalId)}"; - var result = await WindowLauncher.OpenAsync(terminalId, path).ConfigureAwait(true); + var url = NavigationManager.ToAbsoluteUri($"/terminal-window/apphost/{Uri.EscapeDataString(terminalId)}").ToString(); + var result = await WindowLauncher.OpenAsync(terminalId, url).ConfigureAwait(true); if (result is TerminalWindowOpenResult.Blocked) { diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index e824f0ad196..4400d9f69a8 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -73,13 +73,6 @@ public void Cancel() [Inject] public required IOptions Options { get; init; } - /// - /// Used to authenticate detached terminal windows. Monitored rather than snapshotted so the token handed to a - /// new window is always the one the login middleware will validate it against. - /// - [Inject] - public required IOptionsMonitor OptionsMonitor { get; init; } - [Inject] public required IDashboardClient DashboardClient { get; init; } @@ -1380,7 +1373,7 @@ private Task HandleViewChangedAsync(string? newView) // Resource terminals never reattach, so the close callback has nothing to do: the inline view was live the // whole time the window was open. private TerminalWindowLauncher TerminalWindowLauncher - => _terminalWindowLauncher ??= new TerminalWindowLauncher(JS, NavigationManager, OptionsMonitor, _ => Task.CompletedTask); + => _terminalWindowLauncher ??= new TerminalWindowLauncher(JS, _ => Task.CompletedTask); /// /// Opens the selected resource's terminal in its own resizable window. @@ -1402,7 +1395,7 @@ private async Task OpenTerminalWindowAsync() var path = $"/terminal-window/resource/{Uri.EscapeDataString(resourceName)}/{_terminalReplicaIndex}"; var result = await TerminalWindowLauncher.OpenAsync( key: $"resource:{resourceName}:{_terminalReplicaIndex}", - path: path).ConfigureAwait(true); + url: NavigationManager.ToAbsoluteUri(path).ToString()).ConfigureAwait(true); if (result is TerminalWindowOpenResult.Blocked) { diff --git a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs index 73df0e7b052..6f600bda773 100644 --- a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs +++ b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Dashboard.Configuration; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Options; using Microsoft.JSInterop; namespace Aspire.Dashboard.Model; @@ -51,8 +48,6 @@ public sealed class TerminalWindowLauncher : IAsyncDisposable private const int DefaultWindowHeightPx = 600; private readonly IJSRuntime _js; - private readonly NavigationManager _navigationManager; - private readonly IOptionsMonitor _dashboardOptions; private readonly Func _onWindowClosed; private readonly HashSet _tracked = []; @@ -63,49 +58,39 @@ public sealed class TerminalWindowLauncher : IAsyncDisposable /// Initializes a new instance of the class. /// /// The JS runtime for the owning component's circuit. - /// Used to resolve the terminal path against the dashboard's base URI. - /// Used to authenticate the new window when the frontend requires a browser token. /// /// Invoked with the terminal key when the user closes a detached window. Not raised for windows closed through /// , because the caller already knows about those. /// - public TerminalWindowLauncher( - IJSRuntime js, - NavigationManager navigationManager, - IOptionsMonitor dashboardOptions, - Func onWindowClosed) + public TerminalWindowLauncher(IJSRuntime js, Func onWindowClosed) { ArgumentNullException.ThrowIfNull(js); - ArgumentNullException.ThrowIfNull(navigationManager); - ArgumentNullException.ThrowIfNull(dashboardOptions); ArgumentNullException.ThrowIfNull(onWindowClosed); _js = js; - _navigationManager = navigationManager; - _dashboardOptions = dashboardOptions; _onWindowClosed = onWindowClosed; } /// - /// Opens the terminal at in a window dedicated to the terminal identified by - /// , or focuses the existing window if one is already open for it. + /// Opens in a window dedicated to the terminal identified by , or + /// focuses the existing window if one is already open for it. /// /// /// An opaque, page-stable identifier for the terminal — a dock terminal id, or a resource name and replica index. /// - /// The root-relative dashboard path that renders the detached terminal. + /// The dashboard URL that renders the detached terminal. /// Requested window width, in pixels. /// Requested window height, in pixels. public async Task OpenAsync( string key, - string path, + string url, int widthPx = DefaultWindowWidthPx, int heightPx = DefaultWindowHeightPx) { var module = await GetModuleAsync().ConfigureAwait(false); var result = await module.InvokeAsync( - "openTerminalWindow", key, BuildWindowUrl(path), widthPx, heightPx, _selfRef).ConfigureAwait(false); + "openTerminalWindow", key, url, widthPx, heightPx, _selfRef).ConfigureAwait(false); if (result is not "blocked") { @@ -120,39 +105,6 @@ public async Task OpenAsync( }; } - /// - /// Resolves the absolute URL to open, routing it through the login endpoint when the frontend is token-protected. - /// - /// - /// A detached terminal is opened with window.open, and the window does not always inherit the opener's - /// cookies: embedded browsers — the webviews inside editors and desktop apps — commonly give each view its own - /// cookie jar, and a window handed off to the OS browser starts from that browser's jar instead. The popup would - /// then land on the login page asking for a token the user has no way to see, in a window deliberately opened - /// without address bar or menus. - /// - /// Routing through /login makes the window authenticate itself into whichever jar it ended up in, so no - /// detection of the hosting environment is needed and both cases are fixed the same way. The middleware redirects - /// to returnUrl on success, so the address bar settles on the terminal URL rather than the token. This is - /// the same handoff the AppHost already uses for the dashboard's own resource URL, so the token is not exposed - /// anywhere it is not exposed today. - /// - /// - private string BuildWindowUrl(string path) - { - var frontend = _dashboardOptions.CurrentValue.Frontend; - - // Only BrowserToken can be handed over in a URL. An OIDC popup re-runs the authorization code flow against - // the identity provider on its own, and an unsecured frontend has nothing to hand over. - if (frontend.AuthMode is FrontendAuthMode.BrowserToken && frontend.BrowserToken is { Length: > 0 } token) - { - path = $"/login?t={Uri.EscapeDataString(token)}&returnUrl={Uri.EscapeDataString(path)}"; - } - - // AbsoluteUri, not ToString(): Uri.ToString() returns the *unescaped* form, which would undo the escaping - // above and let a '&' or '#' in the token or path split the query apart. - return _navigationManager.ToAbsoluteUri(path).AbsoluteUri; - } - /// /// Brings the window for to the front. Returns if no window is open /// for it, which the caller can treat as a cue to reattach. diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWindowLauncherTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWindowLauncherTests.cs deleted file mode 100644 index 2c61c84c34b..00000000000 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWindowLauncherTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.CodeAnalysis; -using Aspire.Dashboard.Configuration; -using Aspire.Dashboard.Model; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.InternalTesting; -using Microsoft.Extensions.Options; -using Microsoft.JSInterop; -using Xunit; - -namespace Aspire.Dashboard.Tests.Terminal; - -public class TerminalWindowLauncherTests -{ - private const string TerminalPath = "/terminal-window/apphost/abc123"; - - [Fact] - public async Task OpenAsync_BrowserToken_RoutesThroughLoginSoTheWindowAuthenticatesItself() - { - var js = new TestJSRuntime(); - var launcher = CreateLauncher(js, CreateOptions(FrontendAuthMode.BrowserToken, "s3cret token")); - - await launcher.OpenAsync(key: "terminal:abc123", path: TerminalPath).DefaultTimeout(); - - // The token and the return path are both escaped, so a token containing URL-significant characters cannot - // truncate the query or smuggle in extra parameters. - Assert.Equal( - "http://localhost/login?t=s3cret%20token&returnUrl=%2Fterminal-window%2Fapphost%2Fabc123", - js.LastWindowUrl); - } - - [Fact] - public async Task OpenAsync_TokenAndPathContainQueryDelimiters_EscapingSurvivesUriResolution() - { - var js = new TestJSRuntime(); - var launcher = CreateLauncher(js, CreateOptions(FrontendAuthMode.BrowserToken, "a&b=c#d")); - - await launcher.OpenAsync(key: "terminal:abc123", path: "/terminal-window/resource/my&app/0").DefaultTimeout(); - - // Resolving against the base URI must not decode the escapes, or the '&' and '#' would split the query and - // the login middleware would see a truncated token and a truncated returnUrl. - Assert.Equal( - "http://localhost/login?t=a%26b%3Dc%23d&returnUrl=%2Fterminal-window%2Fresource%2Fmy%26app%2F0", - js.LastWindowUrl); - } - - [Theory] - [InlineData(FrontendAuthMode.Unsecured, null)] - [InlineData(FrontendAuthMode.OpenIdConnect, null)] - // OIDC re-runs the authorization code flow in the new window, so a token would be meaningless even if configured. - [InlineData(FrontendAuthMode.OpenIdConnect, "ignored-token")] - // A BrowserToken frontend without a token cannot hand anything over; the empty token must not reach the URL. - [InlineData(FrontendAuthMode.BrowserToken, "")] - public async Task OpenAsync_NoTokenToHandOver_OpensTheTerminalDirectly(FrontendAuthMode authMode, string? token) - { - var js = new TestJSRuntime(); - var launcher = CreateLauncher(js, CreateOptions(authMode, token)); - - await launcher.OpenAsync(key: "terminal:abc123", path: TerminalPath).DefaultTimeout(); - - Assert.Equal("http://localhost/terminal-window/apphost/abc123", js.LastWindowUrl); - } - - [Theory] - [InlineData("opened", TerminalWindowOpenResult.Opened)] - [InlineData("focused", TerminalWindowOpenResult.Focused)] - [InlineData("blocked", TerminalWindowOpenResult.Blocked)] - public async Task OpenAsync_MapsJavaScriptOutcome(string jsResult, TerminalWindowOpenResult expected) - { - var js = new TestJSRuntime { OpenResult = jsResult }; - var launcher = CreateLauncher(js, CreateOptions(FrontendAuthMode.Unsecured, token: null)); - - var result = await launcher.OpenAsync(key: "terminal:abc123", path: TerminalPath).DefaultTimeout(); - - Assert.Equal(expected, result); - } - - private static TerminalWindowLauncher CreateLauncher(IJSRuntime js, IOptionsMonitor options) - => new(js, new TestNavigationManager(), options, _ => Task.CompletedTask); - - private static IOptionsMonitor CreateOptions(FrontendAuthMode authMode, string? token) - { - var options = new DashboardOptions - { - Frontend = { AuthMode = authMode, BrowserToken = token } - }; - - return new TestOptionsMonitor(options); - } - - private sealed class TestNavigationManager : NavigationManager - { - public TestNavigationManager() - { - Initialize("http://localhost/", "http://localhost/"); - } - } - - private sealed class TestOptionsMonitor(DashboardOptions options) : IOptionsMonitor - { - public DashboardOptions CurrentValue { get; } = options; - - public DashboardOptions Get(string? name) => CurrentValue; - - public IDisposable? OnChange(Action listener) => null; - } - - /// - /// Stands in for the browser, capturing the URL the launcher asked app-terminalwindow.js to open. - /// - private sealed class TestJSRuntime : IJSRuntime, IJSObjectReference - { - public string OpenResult { get; init; } = "opened"; - - public string? LastWindowUrl { get; private set; } - - public ValueTask InvokeAsync(string identifier, object?[]? args) - { - // The launcher first imports the module, then calls into it. Both land here because this type doubles as - // the module reference it hands back. - if (identifier is "import") - { - return ValueTask.FromResult((TValue)(object)this); - } - - if (identifier is "openTerminalWindow") - { - Assert.NotNull(args); - LastWindowUrl = (string?)args[1]; - return ValueTask.FromResult((TValue)(object)OpenResult); - } - - return ValueTask.FromResult(default(TValue)!); - } - - public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) - => InvokeAsync(identifier, args); - - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TestJSRuntime))] - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} From 363c8cfc4213d7137ebd18f5a8a01cbf60381f98 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 17:58:04 +1000 Subject: [PATCH 024/106] Move interaction terminal lifetime to the caller An InteractionInput of type Terminal now takes an IAspireTerminal that the caller has already created, instead of a TerminalCommand the interaction would launch on its behalf. Whoever creates the terminal disposes it; the dialog is only a view onto it. This drops both teardown paths from InteractionService, and with them its dependency on TerminalService, which is no longer used at all. The dialog no longer stops a workload the caller may still be driving through the automation API, and the same terminal can be shown more than once. The Surface must still be TerminalSurface.Interaction. The original reason was that the dialog owned teardown and would rip a tab out of the dock; what remains is presentational, since a dock terminal is already rendered as a tab. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../TerminalInteractionCommands.cs | 40 ++++-- src/Aspire.Hosting/IInteractionService.cs | 59 +++------ src/Aspire.Hosting/InteractionService.cs | 61 ++------- .../Terminals/IAspireTerminal.cs | 5 +- .../DashboardServiceDataTerminalTests.cs | 3 +- .../Dashboard/DashboardServiceTests.cs | 27 ++-- .../InteractionServiceTests.cs | 12 +- .../ApplicationOrchestratorTests.cs | 3 +- .../Orchestrator/ParameterProcessorTests.cs | 3 +- .../PipelineActivityReporterTests.cs | 2 +- .../InteractionServiceTerminalTests.cs | 123 +++++++++--------- 11 files changed, 142 insertions(+), 196 deletions(-) diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 718b13d898f..7c9544e3fe6 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -51,13 +51,21 @@ public static IResourceBuilder WithAppHostShellCommand(this IResourceBuild executeCommand: async commandContext => { var interactionService = commandContext.Services.GetRequiredService(); + var terminalService = commandContext.Services.GetRequiredService(); - // The input describes the workload only. Aspire owns the terminal: it attaches the HMP1 server - // transport that carries the session over gRPC, then runs and tears down the process. - var terminal = OperatingSystem.IsWindows() + var command = OperatingSystem.IsWindows() ? new TerminalCommand("cmd.exe") : new TerminalCommand("/bin/bash") { Arguments = ["-i", "-l"] }; + // The caller owns the terminal, so it is disposed here rather than by the dialog. The workload does + // not start until the dialog is opened, so dismissing it without looking never spawns a shell. + await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + { + Title = "Shell", + Command = command, + Surface = TerminalSurface.Interaction + }); + var result = await interactionService.PromptInputsAsync( "AppHost shell", "This shell is a child process of the AppHost. Closing the dialog terminates it.", @@ -132,11 +140,18 @@ private static async Task ExecIntoContainerAsync( string message) { var interactionService = commandContext.Services.GetRequiredService(); + var terminalService = commandContext.Services.GetRequiredService(); - var terminal = new TerminalCommand("docker") + // The caller owns the terminal, so it is disposed here rather than by the dialog. + await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { - Arguments = ["exec", "-it", containerName, .. command] - }; + Title = title, + Command = new TerminalCommand("docker") + { + Arguments = ["exec", "-it", containerName, .. command] + }, + Surface = TerminalSurface.Interaction + }); var result = await interactionService.PromptInputsAsync( title, @@ -263,9 +278,9 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde } limit = Math.Clamp(limit, 2, 1_000_000); - // Created here rather than by the interaction service, because this command needs the handle in order - // to drive the game. The interaction still owns teardown once the dialog is raised. - var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + // The command owns the terminal for its whole life: it drives the game through the handle, and + // disposes it once the answer has been shown. + await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = "Number guess", Command = BuildNumberGuessCommand(limit), @@ -285,7 +300,7 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde Name = "game", Label = "Number guess", InputType = InputType.Terminal, - TerminalSession = terminal + Terminal = terminal } ], cancellationToken: gameCts.Token); @@ -325,8 +340,9 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde // Leave the winning line on screen long enough to read before the dialog disappears. await Task.Delay(TimeSpan.FromSeconds(2), commandContext.CancellationToken); - // Cancelling the token the prompt was started with is how code dismisses its own dialog. That also - // disposes the terminal, so the result replaces the terminal rather than stacking on top of it. + // Cancelling the token the prompt was started with is how code dismisses its own dialog, so the + // result replaces the terminal rather than stacking on top of it. The terminal itself is disposed by + // the `await using` above, once the answer has been shown. await gameCts.CancelAsync(); await dialogTask; diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index a36729fc5d5..ba8a396bd34 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -466,43 +466,23 @@ public long? MaxFileSize public InteractionFileCollection GetFiles() => _files; /// - /// Gets the terminal session to run for an input. Ignored by every other input - /// type. + /// Gets the terminal to display for an input. Ignored by every other input type. /// /// /// - /// Describes the process the terminal runs — for example - /// new TerminalCommand("docker") { Arguments = ["exec", "-it", id, "/bin/sh"] }. The AppHost owns the terminal: it - /// attaches the transport, runs the workload, and tears it down. + /// The terminal is created and owned by the caller, not by the interaction. Create it with + /// TerminalService.CreateTerminal passing , hand it to the input, + /// and dispose it when the caller is finished with it. The dialog is a view onto the terminal; closing the dialog + /// stops showing it but does not stop the workload. /// /// - /// The session starts lazily when a client first attaches, so a dialog that is dismissed without opening the - /// terminal never starts the underlying process. The session is torn down when the interaction completes. - /// - /// - /// Exactly one of this property and must be set on a terminal input. Set this one - /// when the dialog simply needs to show a process; set when the AppHost also needs - /// to drive that process. - /// - /// - [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] - [AspireExportIgnore(Reason = "A terminal is a live local process attached to the AppHost; it cannot be serialized to polyglot app hosts.")] - public TerminalCommand? Terminal { get; init; } - - /// - /// Gets an already-created terminal to display for an input. Ignored by every - /// other input type. - /// - /// - /// - /// Use this instead of when the AppHost needs a handle on the terminal — typically to - /// script it through 's automation members while the dialog is open. Create the - /// terminal with TerminalService.CreateTerminal, passing - /// , then hand it to the input: + /// Owning the terminal outside the interaction is what lets the AppHost script it through + /// 's automation members — before the dialog is raised, while it is open, and after + /// it closes — and lets the same terminal be shown by more than one dialog over its life. /// /// /// - /// var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + /// await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions /// { /// Title = "Setup", /// Command = new TerminalCommand("./setup.sh"), @@ -512,26 +492,29 @@ public long? MaxFileSize /// var dialog = interactionService.PromptInputsAsync( /// "Setup", /// "Running setup.", - /// [new InteractionInput { Name = "setup", InputType = InputType.Terminal, TerminalSession = terminal }], + /// [new InteractionInput { Name = "setup", InputType = InputType.Terminal, Terminal = terminal }], /// cancellationToken: cts.Token); /// /// await terminal.WaitForTextAsync("Continue? "); /// await terminal.SendTextAsync("y\r"); + /// + /// // Dismiss the dialog from code once the automation is done. + /// await cts.CancelAsync(); /// /// /// - /// The terminal's must be ; a dock - /// terminal would also appear as a tab, and the dialog would tear it out from under the dock when it closes. + /// The terminal's must be . A dock + /// terminal is presented as a dock tab that outlives the code which created it, so showing one in a dialog would + /// render the same terminal through two competing presentations. /// /// - /// The interaction still owns teardown: the terminal is disposed when the dialog completes or is cancelled, so - /// the caller does not dispose it. Cancelling the token passed to the prompt is therefore how automation code - /// closes the dialog and ends the session once it is done. + /// The workload starts lazily on the first attach or the first automation call, so a terminal created for a dialog + /// that is dismissed without ever being opened never spawns a process. /// /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] [AspireExportIgnore(Reason = "A terminal is a live local process attached to the AppHost; it cannot be serialized to polyglot app hosts.")] - public IAspireTerminal? TerminalSession { get; init; } + public IAspireTerminal? Terminal { get; init; } /// /// Identifies the AppHost-owned terminal created for this input. Stamped by the interaction service when the @@ -886,8 +869,8 @@ public enum InputType /// An interactive terminal. Renders a terminal that is attached to a session owned by the AppHost. /// /// - /// This input type is experimental. The terminal session is configured through - /// . + /// This input type is experimental. The terminal is created and owned by the caller and supplied through + /// ; the dialog is only a view onto it. /// Terminal } diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 3e31fe759ee..3456dc3283a 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -31,16 +31,14 @@ internal class InteractionService : IInteractionService private readonly IServiceProvider _serviceProvider; private readonly IConfiguration _configuration; private readonly IInteractionFileUploadStore _fileUploadStore; - private readonly TerminalService _terminalService; - public InteractionService(ILogger logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider, IConfiguration configuration, IInteractionFileUploadStore fileUploadStore, TerminalService terminalService) + public InteractionService(ILogger logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider, IConfiguration configuration, IInteractionFileUploadStore fileUploadStore) { _logger = logger; _distributedApplicationOptions = distributedApplicationOptions; _serviceProvider = serviceProvider; _configuration = configuration; _fileUploadStore = fileUploadStore; - _terminalService = terminalService; } public bool IsAvailable @@ -173,16 +171,16 @@ public async Task> PromptInputsAsy var input = inputs[i]; if (input.InputType == InputType.Terminal) { - if (input.Terminal is null == input.TerminalSession is null) + if (input.Terminal is null) { - throw new InvalidOperationException($"The input '{input.Name}' is a {nameof(InputType.Terminal)} input, so exactly one of {nameof(InteractionInput.Terminal)} and {nameof(InteractionInput.TerminalSession)} must be set."); + throw new InvalidOperationException($"The input '{input.Name}' is a {nameof(InputType.Terminal)} input, so {nameof(InteractionInput.Terminal)} must be set to a terminal created by the caller."); } - // A dock terminal is listed as a tab and is expected to outlive whatever created it, but the dialog - // disposes its terminal on close. Showing one here would rip it out from under the dock. - if (input.TerminalSession is { } session && session.Surface != TerminalSurface.Interaction) + // A dock terminal is presented as a dock tab that outlives the code which created it. Showing one in a + // dialog as well would render the same terminal through two competing presentations. + if (input.Terminal.Surface != TerminalSurface.Interaction) { - throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.TerminalSession)} to a terminal whose {nameof(IAspireTerminal.Surface)} is {session.Surface}. Terminals shown by an interaction must be created with {nameof(TerminalSurface)}.{nameof(TerminalSurface.Interaction)}."); + throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.Terminal)} to a terminal whose {nameof(IAspireTerminal.Surface)} is {input.Terminal.Surface}. Terminals shown by an interaction must be created with {nameof(TerminalSurface)}.{nameof(TerminalSurface.Interaction)}."); } } @@ -224,21 +222,13 @@ public async Task> PromptInputsAsy } if (hasTerminalInputs) { - // Terminals are created eagerly so the dialog carries a terminal id, but the underlying workload - // does not start until a client actually attaches. A dialog dismissed without opening the terminal - // therefore never spawns a process. + // The dashboard addresses a terminal by id, so carry the caller's terminal id on the input. The + // terminal is neither created nor disposed here: the caller owns it. foreach (var input in inputs) { if (input.InputType == InputType.Terminal) { - // A caller-supplied session is already created — the caller needed the handle so it could - // drive the terminal. Either way the interaction owns teardown from here on. - input.TerminalId = input.TerminalSession?.Id ?? _terminalService.CreateTerminal(new TerminalLaunchOptions - { - Title = string.IsNullOrEmpty(input.Label) ? input.Name : input.Label, - Command = input.Terminal!, - Surface = TerminalSurface.Interaction - }).Id; + input.TerminalId = input.Terminal!.Id; } } } @@ -297,22 +287,6 @@ public async Task> PromptInputsAsy } finally { - // Terminals are created before the interaction is tracked, so any escape between creation and - // CompleteInteractionCore — a throw from AddInteractionUpdate, or dynamic input loading — would - // otherwise leave them registered with TerminalService for the lifetime of the AppHost. The normal - // path has already nulled TerminalId, which makes this a no-op rather than a double teardown. - if (hasTerminalInputs) - { - foreach (var input in inputs) - { - if (input.InputType == InputType.Terminal && input.TerminalId is { } orphanedTerminalId) - { - _terminalService.RemoveAndDisposeInBackground(orphanedTerminalId); - input.TerminalId = null; - } - } - } - interactionCts.Cancel(); } } @@ -584,21 +558,6 @@ private void CompleteInteractionCore(Interaction interactionState, InteractionCo } } - // Terminal sessions are torn down on both paths — unlike uploaded files, nothing survives the interaction - // for the caller to consume, so a completed dialog must still stop the workload. - if (interactionState.InteractionInfo is Interaction.InputsInteractionInfo terminalInputsInfo && - terminalInputsInfo.Inputs.Any(input => input.InputType == InputType.Terminal)) - { - foreach (var input in terminalInputsInfo.Inputs) - { - if (input.InputType == InputType.Terminal && input.TerminalId is { } terminalId) - { - _terminalService.RemoveAndDisposeInBackground(terminalId); - input.TerminalId = null; - } - } - } - interactionState.State = Interaction.InteractionState.Complete; interactionState.CompletionTcs.TrySetResult(completion); _interactionCollection.Remove(interactionState.InteractionId); diff --git a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs index 6e21198025e..a1eb46e8c2b 100644 --- a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs @@ -21,8 +21,9 @@ namespace Aspire.Hosting.Terminals; /// surface can grow later if real usage demands it. /// /// -/// Disposing the terminal cancels its workload and removes it from the dashboard. Terminals attached to -/// an interaction are disposed automatically when the interaction completes or is cancelled. +/// Disposing the terminal cancels its workload and removes it from the dashboard. Whoever creates a +/// terminal owns it and must dispose it; showing one in an interaction does not transfer that ownership, +/// so a terminal survives the dialog it was displayed in. /// /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs index 9dbb3dc7467..25ad7af2d54 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs @@ -198,8 +198,7 @@ private static (DashboardServiceData Data, ResourceNotificationService Notificat new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); var data = new DashboardServiceData( notifications, loggerService, diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 198d14a8817..f496aa6c9f1 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -474,8 +474,7 @@ public async Task WatchInteractions_PromptMessageBoxAsync_CompleteOnResponse(boo new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -546,8 +545,7 @@ public async Task WatchInteractions_NoExplicitLabel_LabelIsName() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -595,8 +593,7 @@ public async Task WatchInteractions_PromptInputAsync_CompleteOnCancelResponse() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -656,8 +653,7 @@ public async Task WatchInteractions_ReaderError_CompleteWithError() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -695,8 +691,7 @@ public async Task WatchInteractions_WriterError_CompleteWithError() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger()); @@ -1081,8 +1076,7 @@ public async Task SendInteractionRequestAsync_ClientFileTypeForTextInput_DoesNot new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - fileUploadStore, - TestTerminalService.Create()); + fileUploadStore); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var fileInput = new InteractionInput { Name = "File", InputType = InputType.File }; var textInput = new InteractionInput { Name = "Text", InputType = InputType.Text }; @@ -1121,8 +1115,7 @@ public async Task SendInteractionRequestAsync_UsesAuthoritativeFilesAndDisposeDe new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - fileUploadStore, - TestTerminalService.Create()); + fileUploadStore); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var input = new InteractionInput { Name = "File", InputType = InputType.File, Required = true, AllowMultipleFiles = true }; var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input); @@ -1186,8 +1179,7 @@ public async Task SendInteractionRequestAsync_MismatchedFiles_Throws() new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - fileUploadStore, - TestTerminalService.Create()); + fileUploadStore); using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore); var input = new InteractionInput { Name = "File", InputType = InputType.File }; var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input); @@ -1307,8 +1299,7 @@ private static DashboardServiceData CreateDashboardServiceData( new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - fileUploadStore, - TestTerminalService.Create()); + fileUploadStore); return new DashboardServiceData( resourceNotificationService, diff --git a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs index 5395f36c0aa..9a121c41687 100644 --- a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs @@ -220,8 +220,7 @@ public void IsAvailable_InteractivityEnabledConfigured_ReturnsExpectedValue(stri new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), configuration, - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); // Assert Assert.Equal(expected, interactionService.IsAvailable); @@ -249,8 +248,7 @@ public void IsAvailable_InteractivityEnabledInvalidValue_ReturnsTrue(string conf new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), configuration, - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); // Assert - Invalid values should be ignored, defaulting to true (since dashboard is enabled) Assert.True(interactionService.IsAvailable); @@ -273,8 +271,7 @@ public void IsAvailable_InteractivityDisabledAndDashboardDisabled_ReturnsFalse() new DistributedApplicationOptions { DisableDashboard = true }, new ServiceCollection().BuildServiceProvider(), configuration, - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); // Assert - Both conditions should result in false Assert.False(interactionService.IsAvailable); @@ -1343,8 +1340,7 @@ private static InteractionService CreateInteractionService(DistributedApplicatio options ?? new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), configuration, - fileUploadStore ?? new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + fileUploadStore ?? new TestInteractionFileUploadStore()); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index db2f371996b..620b91afe45 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -1158,8 +1158,7 @@ private static InteractionService CreateInteractionService(DistributedApplicatio options ?? new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); } private sealed class MockDeploymentStateManager : IDeploymentStateManager diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs index cf773c8b297..efc9397998a 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs @@ -1294,8 +1294,7 @@ private static InteractionService CreateInteractionService(bool disableDashboard new DistributedApplicationOptions { DisableDashboard = disableDashboard }, new ServiceCollection().BuildServiceProvider(), new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - TestTerminalService.Create()); + new TestInteractionFileUploadStore()); } private sealed class MockDeploymentStateManager : IDeploymentStateManager diff --git a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs index 09dda8ebd15..827babf1cb6 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs @@ -1400,6 +1400,6 @@ internal static InteractionService CreateInteractionService() var provider = services.BuildServiceProvider(); var logger = provider.GetRequiredService>(); var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(); - return new InteractionService(logger, new DistributedApplicationOptions(), provider, configuration, new TestInteractionFileUploadStore(), TestTerminalService.Create()); + return new InteractionService(logger, new DistributedApplicationOptions(), provider, configuration, new TestInteractionFileUploadStore()); } } diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index 0b558c9875a..f997941ef4d 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -14,15 +14,15 @@ namespace Aspire.Hosting.Tests.Terminals; /// -/// Guards how validates and owns terminal-typed inputs. The interaction owns -/// teardown for every terminal it shows, so the tests here are as much about the terminal not outliving the -/// dialog as they are about the validation messages. +/// Guards how validates terminal-typed inputs and, above all, that it keeps its +/// hands off the terminal's lifetime. The caller creates the terminal and the caller disposes it, so the dialog is +/// only ever a view onto a terminal that already exists. /// [Trait("Partition", "2")] public class InteractionServiceTerminalTests { [Fact] - public async Task PromptInputsAsync_TerminalInputWithNeitherCommandNorSession_Throws() + public async Task PromptInputsAsync_TerminalInputWithoutATerminal_Throws() { var (interactionService, _) = CreateInteractionService(); @@ -31,42 +31,22 @@ public async Task PromptInputsAsync_TerminalInputWithNeitherCommandNorSession_Th var ex = await Assert.ThrowsAsync( () => interactionService.PromptInputsAsync("Title", "Message", [input])).DefaultTimeout(); - Assert.Contains("exactly one of", ex.Message, StringComparison.Ordinal); + Assert.Contains(nameof(InteractionInput.Terminal), ex.Message, StringComparison.Ordinal); } [Fact] - public async Task PromptInputsAsync_TerminalInputWithBothCommandAndSession_Throws() + public async Task PromptInputsAsync_TerminalOnTheDockSurface_Throws() { var (interactionService, terminalService) = CreateInteractionService(); - var session = CreateTerminal(terminalService, TerminalSurface.Interaction); + // A dock terminal is already presented as a dock tab, so showing it in a dialog as well would render one + // terminal through two competing presentations. + await using var dockTerminal = CreateTerminal(terminalService, TerminalSurface.Dock); var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, - Terminal = new TerminalCommand("bash"), - TerminalSession = session - }; - - var ex = await Assert.ThrowsAsync( - () => interactionService.PromptInputsAsync("Title", "Message", [input])).DefaultTimeout(); - - Assert.Contains("exactly one of", ex.Message, StringComparison.Ordinal); - } - - [Fact] - public async Task PromptInputsAsync_TerminalSessionOnTheDockSurface_Throws() - { - var (interactionService, terminalService) = CreateInteractionService(); - - // A dock terminal is listed as a tab and outlives whatever created it. The dialog disposes the terminal it - // shows, so accepting one here would rip a tab out from under the dock when the dialog closed. - var dockTerminal = CreateTerminal(terminalService, TerminalSurface.Dock); - var input = new InteractionInput - { - Name = "shell", - InputType = InputType.Terminal, - TerminalSession = dockTerminal + Terminal = dockTerminal }; var ex = await Assert.ThrowsAsync( @@ -74,30 +54,29 @@ public async Task PromptInputsAsync_TerminalSessionOnTheDockSurface_Throws() Assert.Contains(nameof(TerminalSurface.Dock), ex.Message, StringComparison.Ordinal); - // The dock tab must survive the rejected prompt. + // The rejected prompt must not take the caller's dock tab with it. Assert.True(terminalService.TryGetTerminal(dockTerminal.Id, out _)); } [Fact] - public async Task PromptInputsAsync_TerminalInputWithCommand_CreatesTerminalBeforeTheDialogIsShown() + public async Task PromptInputsAsync_TerminalInput_CarriesTheCallersTerminalIdIntoTheDialog() { var (interactionService, terminalService) = CreateInteractionService(); + await using var terminal = CreateTerminal(terminalService, TerminalSurface.Interaction); var input = new InteractionInput { Name = "shell", Label = "Shell", InputType = InputType.Terminal, - Terminal = new TerminalCommand("bash") + Terminal = terminal }; var resultTask = interactionService.PromptInputsAsync("Title", "Message", [input]); - // The dialog carries a terminal id, so the terminal has to exist by the time the interaction is published. - Assert.NotNull(input.TerminalId); - Assert.True(terminalService.TryGetTerminal(input.TerminalId, out var terminal)); - Assert.Equal("Shell", terminal.Title); - Assert.Equal(TerminalSurface.Interaction, terminal.Surface); + // The dashboard addresses terminals by id, so the id of the caller's terminal is what the dialog has to + // carry -- the interaction does not stand up a terminal of its own. + Assert.Equal(terminal.Id, input.TerminalId); var interaction = Assert.Single(interactionService.GetCurrentInteractions()); await CancelInteractionAsync(interactionService, interaction.InteractionId); @@ -106,62 +85,88 @@ public async Task PromptInputsAsync_TerminalInputWithCommand_CreatesTerminalBefo } [Fact] - public async Task PromptInputsAsync_Cancelled_DisposesTheTerminalItCreated() + public async Task PromptInputsAsync_Cancelled_LeavesTheCallersTerminalAlone() { var (interactionService, terminalService) = CreateInteractionService(); + await using var terminal = CreateTerminal(terminalService, TerminalSurface.Interaction); var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, - Terminal = new TerminalCommand("bash") + Terminal = terminal }; var resultTask = interactionService.PromptInputsAsync("Title", "Message", [input]); - var terminalId = input.TerminalId; - Assert.NotNull(terminalId); var interaction = Assert.Single(interactionService.GetCurrentInteractions()); await CancelInteractionAsync(interactionService, interaction.InteractionId); var result = await resultTask.DefaultTimeout(); - // Unlike an uploaded file, nothing about a terminal survives the dialog for the caller to consume, so a - // dismissed dialog must still stop the workload rather than leaving it registered for the AppHost's life. + // The terminal outlives the dialog. A caller may show the same terminal in a second prompt, or keep + // driving it through the automation API after the user dismisses this one, so a dismissed dialog must not + // stop the workload. Assert.True(result.Canceled); - Assert.False(terminalService.TryGetTerminal(terminalId, out _)); - Assert.Null(input.TerminalId); + Assert.True(terminalService.TryGetTerminal(terminal.Id, out _)); } [Fact] - public async Task PromptInputsAsync_CallerTokenCancelled_DisposesTheTerminalItCreated() + public async Task PromptInputsAsync_CallerTokenCancelled_LeavesTheCallersTerminalAlone() { var (interactionService, terminalService) = CreateInteractionService(); + await using var terminal = CreateTerminal(terminalService, TerminalSurface.Interaction); var terminalInput = new InteractionInput { Name = "shell", InputType = InputType.Terminal, - Terminal = new TerminalCommand("bash") + Terminal = terminal }; using var cts = new CancellationTokenSource(); var resultTask = interactionService.PromptInputsAsync("Title", "Message", [terminalInput], cancellationToken: cts.Token); - var terminalId = terminalInput.TerminalId; - Assert.NotNull(terminalId); - // Cancelling the caller's token unwinds the prompt through OnInteractionCancellation rather than through a - // dashboard-driven completion. Both routes end in CompleteInteractionCore, and the finally in - // PromptInputsAsync then runs over inputs whose TerminalId has already been cleared -- so this also covers - // the backstop being idempotent rather than tearing a terminal down twice. + // dashboard-driven completion. Both routes end in CompleteInteractionCore, so this covers the second of the + // two paths that used to tear the terminal down. cts.Cancel(); var result = await resultTask.DefaultTimeout(); Assert.True(result.Canceled); - Assert.False(terminalService.TryGetTerminal(terminalId, out _)); - Assert.Null(terminalInput.TerminalId); + Assert.True(terminalService.TryGetTerminal(terminal.Id, out _)); + } + + [Fact] + public async Task PromptInputsAsync_TerminalShownTwice_Succeeds() + { + var (interactionService, terminalService) = CreateInteractionService(); + + // Caller-owned lifetime is what makes this legal: the terminal survives the first dialog, so the same + // session can be surfaced again rather than the caller having to start a second workload. + await using var terminal = CreateTerminal(terminalService, TerminalSurface.Interaction); + + for (var i = 0; i < 2; i++) + { + var input = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + Terminal = terminal + }; + + var resultTask = interactionService.PromptInputsAsync("Title", "Message", [input]); + + Assert.Equal(terminal.Id, input.TerminalId); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + await CancelInteractionAsync(interactionService, interaction.InteractionId); + + await resultTask.DefaultTimeout(); + } + + Assert.True(terminalService.TryGetTerminal(terminal.Id, out _)); } /// @@ -176,8 +181,7 @@ public async Task PromptInputsAsync_CallerTokenCancelled_DisposesTheTerminalItCr /// /// The callback returns the state directly instead of routing through /// DashboardServiceData.ProcessInputs. These tests are about the terminal's lifetime rather than - /// input marshalling, and the terminal teardown they assert on happens in CompleteInteractionCore - /// regardless of how the input values were produced. + /// input marshalling. /// /// private static Task CancelInteractionAsync(InteractionService interactionService, int interactionId) @@ -202,8 +206,7 @@ private static (InteractionService InteractionService, TerminalService TerminalS new DistributedApplicationOptions(), new ServiceCollection().BuildServiceProvider(), new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore(), - terminalService); + new TestInteractionFileUploadStore()); return (interactionService, terminalService); } From 219dba2b5684fee8d23e7540130299455a6f41f0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 18:05:53 +1000 Subject: [PATCH 025/106] Let the caller start an AppHost terminal Starting the workload was previously a side effect of the first viewer attaching or the first automation call, which put the decision with the dashboard rather than with the code that created the terminal. IAspireTerminal.Start() makes it explicit and idempotent. The lazy path stays as a backstop on attach and automation so a forgotten start is a late start rather than a hard failure. The number guess command shows why this matters: starting before the dialog is raised means the script is already compiling while the dialog opens, instead of the compile beginning only once a browser attaches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../TerminalInteractionCommands.cs | 22 +++++++++++++------ .../Terminals/Hex1bAspireTerminal.cs | 7 ++++-- .../Terminals/IAspireTerminal.cs | 18 +++++++++++++++ .../Terminals/TerminalServiceTests.cs | 16 ++++++++++++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 7c9544e3fe6..5c4382879f2 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -57,8 +57,7 @@ public static IResourceBuilder WithAppHostShellCommand(this IResourceBuild ? new TerminalCommand("cmd.exe") : new TerminalCommand("/bin/bash") { Arguments = ["-i", "-l"] }; - // The caller owns the terminal, so it is disposed here rather than by the dialog. The workload does - // not start until the dialog is opened, so dismissing it without looking never spawns a shell. + // The caller owns the terminal: it starts it, and disposes it here rather than the dialog doing so. await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = "Shell", @@ -66,6 +65,8 @@ public static IResourceBuilder WithAppHostShellCommand(this IResourceBuild Surface = TerminalSurface.Interaction }); + terminal.Start(); + var result = await interactionService.PromptInputsAsync( "AppHost shell", "This shell is a child process of the AppHost. Closing the dialog terminates it.", @@ -142,7 +143,7 @@ private static async Task ExecIntoContainerAsync( var interactionService = commandContext.Services.GetRequiredService(); var terminalService = commandContext.Services.GetRequiredService(); - // The caller owns the terminal, so it is disposed here rather than by the dialog. + // The caller owns the terminal: it starts it, and disposes it here rather than the dialog doing so. await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = title, @@ -153,6 +154,8 @@ private static async Task ExecIntoContainerAsync( Surface = TerminalSurface.Interaction }); + terminal.Start(); + var result = await interactionService.PromptInputsAsync( title, message, @@ -204,6 +207,7 @@ public static IResourceBuilder WithDockShellCommand(this IRes }); // Reveals the dock in every connected browser and switches it to this tab. + terminal.Start(); terminal.Show(); try @@ -278,8 +282,8 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde } limit = Math.Clamp(limit, 2, 1_000_000); - // The command owns the terminal for its whole life: it drives the game through the handle, and - // disposes it once the answer has been shown. + // The command owns the terminal for its whole life: it starts it, drives the game through the + // handle, and disposes it once the answer has been shown. await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = "Number guess", @@ -287,10 +291,14 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde Surface = TerminalSurface.Interaction }); + // Start before the dialog rather than letting the first attach do it, so `dotnet run --file` is + // already compiling the script while the dialog is being raised. + terminal.Start(); + using var gameCts = CancellationTokenSource.CreateLinkedTokenSource(commandContext.CancellationToken); - // Start the dialog before playing so a browser can attach while `dotnet run --file` is still - // compiling the script — otherwise the human misses the opening moves. + // Raise the dialog before playing so a browser can attach while the opening moves are still being + // made — otherwise the human joins after the game is already won. var dialogTask = interactionService.PromptInputsAsync( "Number guess", $"Guessing a number between 1 and {limit}. Every keystroke below is being typed by the AppHost.", diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index a772f6e00d3..3ba29136ad1 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -64,6 +64,8 @@ public Hex1bAspireTerminal(TerminalService owner, string id, string title, Termi public TerminalDescriptor Descriptor => new(Id, Title); + public void Start() => EnsureStarted(); + public void Show() { if (Surface != TerminalSurface.Dock) @@ -120,8 +122,9 @@ private async Task WaitForSessionEndAsync(CancellationToken cancellationToken) /// Starts the workload if it is not already running. /// /// - /// Startup is lazy so that an interaction dialog dismissed without ever opening its terminal never - /// spawns a process. The first attach *or* the first automation call is what starts it. + /// Callers start a terminal explicitly through . This remains the backstop for the two + /// paths that cannot function without a running workload — a viewer attaching and an automation call — so + /// that forgetting to start is a late start rather than a hard failure. /// private Hex1bTerminal EnsureStarted() { diff --git a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs index a1eb46e8c2b..3325699339d 100644 --- a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs @@ -44,6 +44,24 @@ public interface IAspireTerminal : IAsyncDisposable /// TerminalSurface Surface { get; } + /// + /// Starts the terminal's workload if it is not already running. + /// + /// + /// + /// Starting is the caller's decision, not the dashboard's and not the interaction service's. Call this to + /// have the workload running before anyone is looking at it — a terminal that is already running when a + /// dialog opens shows its scrollback immediately, and automation can drive a terminal that is never + /// displayed at all. + /// + /// + /// This is idempotent and does not block: it schedules the workload rather than waiting for it to produce + /// output. Use to wait for the workload to reach a known state. + /// + /// + /// The terminal has already stopped. + void Start(); + /// /// Reveals the terminal dock in every connected dashboard and switches to this terminal's tab. /// diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 15e9ea0dfd5..8bf3165a811 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -78,6 +78,22 @@ public void TryGetTerminal_UnknownId_ReturnsFalse() Assert.Null(terminal); } + [Fact] + public async Task Start_IsIdempotentAndThrowsOnceStopped() + { + var service = TestTerminalService.Create(); + var terminal = CreateInteractionTerminal(service, "Shell"); + + // Callers decide when the workload spawns, so starting has to tolerate being called more than once -- + // a caller that starts explicitly and then attaches a viewer goes through this twice. + terminal.Start(); + terminal.Start(); + + await terminal.DisposeAsync().DefaultTimeout(); + + Assert.Throws(terminal.Start); + } + [Fact] public async Task DisposeAsync_RemovesTerminalFromRegistry() { From 7b6fdb84f9d84022576762e7a40d6fe66a24a0aa Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 18:29:14 +1000 Subject: [PATCH 026/106] Separate terminal ownership from terminal placement A terminal's owner and the place it is displayed are independent: an AppHost terminal can be a dock tab or live in a dialog, and a resource terminal is displayed on its resource's terminal view. Folding both into a single enum forced every display check to also be an ownership claim, and left an automation-only terminal with no honest value to report. TerminalOwner is fixed at creation and says whose process runs the workload, which is what determines the meaning of disposal. TerminalPlacement says where the terminal is displayed, and adds None for terminals that are driven purely through the automation API and never shown to anyone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../TerminalInteractionCommands.cs | 6 ++-- src/Aspire.Hosting/IInteractionService.cs | 6 ++-- src/Aspire.Hosting/InteractionService.cs | 4 +-- .../Terminals/Hex1bAspireTerminal.cs | 13 +++++--- .../Terminals/IAspireTerminal.cs | 27 ++++++++++----- .../Terminals/TerminalLaunchOptions.cs | 4 +-- src/Aspire.Hosting/Terminals/TerminalOwner.cs | 33 +++++++++++++++++++ ...erminalSurface.cs => TerminalPlacement.cs} | 28 +++++++++------- .../Terminals/TerminalService.cs | 16 ++++----- .../InteractionServiceTerminalTests.cs | 16 ++++----- .../Terminals/TerminalServiceTests.cs | 6 ++-- 11 files changed, 105 insertions(+), 54 deletions(-) create mode 100644 src/Aspire.Hosting/Terminals/TerminalOwner.cs rename src/Aspire.Hosting/Terminals/{TerminalSurface.cs => TerminalPlacement.cs} (53%) diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 5c4382879f2..717e9e1712e 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -62,7 +62,7 @@ public static IResourceBuilder WithAppHostShellCommand(this IResourceBuild { Title = "Shell", Command = command, - Surface = TerminalSurface.Interaction + Placement = TerminalPlacement.Dialog }); terminal.Start(); @@ -151,7 +151,7 @@ private static async Task ExecIntoContainerAsync( { Arguments = ["exec", "-it", containerName, .. command] }, - Surface = TerminalSurface.Interaction + Placement = TerminalPlacement.Dialog }); terminal.Start(); @@ -288,7 +288,7 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde { Title = "Number guess", Command = BuildNumberGuessCommand(limit), - Surface = TerminalSurface.Interaction + Placement = TerminalPlacement.Dialog }); // Start before the dialog rather than letting the first attach do it, so `dotnet run --file` is diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index ba8a396bd34..21d91ff8362 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -471,7 +471,7 @@ public long? MaxFileSize /// /// /// The terminal is created and owned by the caller, not by the interaction. Create it with - /// TerminalService.CreateTerminal passing , hand it to the input, + /// TerminalService.CreateTerminal passing , hand it to the input, /// and dispose it when the caller is finished with it. The dialog is a view onto the terminal; closing the dialog /// stops showing it but does not stop the workload. /// @@ -486,7 +486,7 @@ public long? MaxFileSize /// { /// Title = "Setup", /// Command = new TerminalCommand("./setup.sh"), - /// Surface = TerminalSurface.Interaction + /// Placement = TerminalPlacement.Dialog /// }); /// /// var dialog = interactionService.PromptInputsAsync( @@ -503,7 +503,7 @@ public long? MaxFileSize /// /// /// - /// The terminal's must be . A dock + /// The terminal's must be . A dock /// terminal is presented as a dock tab that outlives the code which created it, so showing one in a dialog would /// render the same terminal through two competing presentations. /// diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 3456dc3283a..09ce9640ba6 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -178,9 +178,9 @@ public async Task> PromptInputsAsy // A dock terminal is presented as a dock tab that outlives the code which created it. Showing one in a // dialog as well would render the same terminal through two competing presentations. - if (input.Terminal.Surface != TerminalSurface.Interaction) + if (input.Terminal.Placement != TerminalPlacement.Dialog) { - throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.Terminal)} to a terminal whose {nameof(IAspireTerminal.Surface)} is {input.Terminal.Surface}. Terminals shown by an interaction must be created with {nameof(TerminalSurface)}.{nameof(TerminalSurface.Interaction)}."); + throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.Terminal)} to a terminal whose {nameof(IAspireTerminal.Placement)} is {input.Terminal.Placement}. Terminals shown by an interaction must be created with {nameof(TerminalPlacement)}.{nameof(TerminalPlacement.Dialog)}."); } } diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index 3ba29136ad1..770d8a1a843 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -46,21 +46,23 @@ internal sealed class Hex1bAspireTerminal : IAspireTerminal private Task? _runTask; private bool _stopped; - public Hex1bAspireTerminal(TerminalService owner, string id, string title, TerminalSurface surface, Hex1bTerminalBuilder builder, ILogger logger) + public Hex1bAspireTerminal(TerminalService owner, string id, string title, TerminalPlacement placement, Hex1bTerminalBuilder builder, ILogger logger) { _owner = owner; _builder = builder; _logger = logger; Id = id; Title = title; - Surface = surface; + Placement = placement; } public string Id { get; } public string Title { get; private set; } - public TerminalSurface Surface { get; } + public TerminalOwner Owner => TerminalOwner.AppHost; + + public TerminalPlacement Placement { get; } public TerminalDescriptor Descriptor => new(Id, Title); @@ -68,9 +70,10 @@ public Hex1bAspireTerminal(TerminalService owner, string id, string title, Termi public void Show() { - if (Surface != TerminalSurface.Dock) + if (Placement != TerminalPlacement.Dock) { - // Interaction terminals are revealed by their own dialog, so there is no dock tab to switch to. + // A terminal in a dialog is revealed by that dialog, and one with no placement is not displayed + // at all, so in neither case is there a dock tab to switch to. return; } diff --git a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs index 3325699339d..41934d8d603 100644 --- a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs @@ -6,7 +6,7 @@ namespace Aspire.Hosting.Terminals; /// -/// A terminal owned by the AppHost process, surfaced in the dashboard and driveable from AppHost code. +/// A terminal surfaced in the dashboard and driveable from AppHost code. /// /// /// @@ -21,9 +21,12 @@ namespace Aspire.Hosting.Terminals; /// surface can grow later if real usage demands it. /// /// -/// Disposing the terminal cancels its workload and removes it from the dashboard. Whoever creates a -/// terminal owns it and must dispose it; showing one in an interaction does not transfer that ownership, -/// so a terminal survives the dialog it was displayed in. +/// What disposal means depends on . For the workload +/// runs in the AppHost, so disposing cancels it and removes the terminal from the dashboard; whoever creates +/// such a terminal owns it and must dispose it, and showing one in an interaction does not transfer that +/// ownership, so the terminal survives the dialog it was displayed in. For +/// the workload belongs to the resource, so disposing only releases +/// Aspire's handle and leaves the workload running. /// /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] @@ -40,9 +43,14 @@ public interface IAspireTerminal : IAsyncDisposable string Title { get; } /// - /// Gets the surface this terminal is displayed on. + /// Gets the process that owns this terminal's workload. /// - TerminalSurface Surface { get; } + TerminalOwner Owner { get; } + + /// + /// Gets where this terminal is displayed in the dashboard. + /// + TerminalPlacement Placement { get; } /// /// Starts the terminal's workload if it is not already running. @@ -56,7 +64,8 @@ public interface IAspireTerminal : IAsyncDisposable /// /// /// This is idempotent and does not block: it schedules the workload rather than waiting for it to produce - /// output. Use to wait for the workload to reach a known state. + /// output. Use to wait for the workload to reach a known state. It is also + /// a no-op for terminals, whose workload is started by the resource. /// /// /// The terminal has already stopped. @@ -66,8 +75,8 @@ public interface IAspireTerminal : IAsyncDisposable /// Reveals the terminal dock in every connected dashboard and switches to this terminal's tab. /// /// - /// Only meaningful for terminals. Interaction terminals are - /// revealed by their dialog, so this is a no-op for them. + /// Only meaningful for terminals. Terminals in a dialog are revealed + /// by that dialog, so this is a no-op for them. /// void Show(); diff --git a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs index 294135d5165..78ea20fbe42 100644 --- a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs +++ b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs @@ -23,7 +23,7 @@ public sealed class TerminalLaunchOptions public required TerminalCommand Command { get; set; } /// - /// Gets or sets the surface the terminal is displayed on. Defaults to . + /// Gets or sets where the terminal is displayed. Defaults to . /// - public TerminalSurface Surface { get; set; } = TerminalSurface.Dock; + public TerminalPlacement Placement { get; set; } = TerminalPlacement.Dock; } diff --git a/src/Aspire.Hosting/Terminals/TerminalOwner.cs b/src/Aspire.Hosting/Terminals/TerminalOwner.cs new file mode 100644 index 00000000000..2c37380abf6 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalOwner.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Terminals; + +/// +/// Identifies which process owns a terminal's workload, and therefore controls its lifetime. +/// +/// +/// This is fixed when the terminal is created and never changes. It is distinct from +/// , which describes where the terminal is currently displayed and can change +/// over the terminal's life. +/// +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public enum TerminalOwner +{ + /// + /// The workload runs in the AppHost process itself, and its lifetime is controlled by whoever created it. + /// + AppHost, + + /// + /// The workload belongs to a resource in the application model, and its lifetime follows that resource. + /// + /// + /// These terminals run out-of-process in a per-replica terminal host rather than in the AppHost, so + /// disposing the releases Aspire's handle on the terminal without stopping + /// the underlying workload. + /// + Resource +} diff --git a/src/Aspire.Hosting/Terminals/TerminalSurface.cs b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs similarity index 53% rename from src/Aspire.Hosting/Terminals/TerminalSurface.cs rename to src/Aspire.Hosting/Terminals/TerminalPlacement.cs index d3cc6019f35..e20316a42ab 100644 --- a/src/Aspire.Hosting/Terminals/TerminalSurface.cs +++ b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs @@ -8,8 +8,13 @@ namespace Aspire.Hosting.Terminals; /// /// Identifies where a terminal is displayed in the dashboard. /// +/// +/// Placement is a property of the view, not of the workload: terminals with different +/// values can share a placement, and a terminal can in principle move between +/// placements without its workload being affected. +/// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] -public enum TerminalSurface +public enum TerminalPlacement { /// /// The terminal is a tab in the dashboard's terminal dock, and is listed by the terminal watch stream. @@ -21,19 +26,20 @@ public enum TerminalSurface /// that interaction's dialog. These are addressed directly by the dialog and are deliberately excluded /// from the dock's tab list. /// - Interaction, + Dialog, /// - /// The terminal is attached to a resource in the application model and is displayed on that resource's - /// own terminal view rather than in the dock. + /// The terminal is displayed on the terminal view of the resource it belongs to. + /// + ResourceView, + + /// + /// The terminal is not displayed anywhere. /// /// - /// Nothing produces this value yet. It exists so that resource terminals — which today are owned by the - /// DCP terminal host rather than by — can be adopted into the same registry - /// and exposed through for automation. Every surface check in - /// is written as an explicit test for , so a resource - /// terminal already behaves correctly by default: it stays out of the dock's tab list and - /// is a no-op for it. + /// Terminals driven purely through the automation members of never need a + /// viewer. Giving that case its own value keeps it out of the dock's tab list without having to pretend it + /// belongs to a dialog or a resource. /// - Resource + None } diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 5b05a122be8..e9c0fb06313 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -20,7 +20,7 @@ namespace Aspire.Hosting.Terminals; /// /// Two experiences share this service: terminals belonging to an interaction /// input, and terminals shown as tabs in the dashboard's terminal dock. They differ only in -/// ; the lifetime, transport, and automation machinery is identical. +/// ; the lifetime, transport, and automation machinery is identical. /// /// /// This is distinct from the terminal host, which exists solely to surface terminals for DCP-owned processes. @@ -62,7 +62,7 @@ public IAspireTerminal CreateTerminal(TerminalLaunchOptions options) ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options.Command); - return CreateTerminal(options.Title, options.Surface, CreateBuilder(options.Command)); + return CreateTerminal(options.Title, options.Placement, CreateBuilder(options.Command)); } /// @@ -101,7 +101,7 @@ private static Hex1bTerminalBuilder CreateBuilder(TerminalCommand command) /// cannot describe — notably the dock's built-in terminal, which runs an /// in-process Hex1b app rather than a child process. /// - internal IAspireTerminal CreateTerminal(string title, TerminalSurface surface, Hex1bTerminalBuilder builder) + internal IAspireTerminal CreateTerminal(string title, TerminalPlacement placement, Hex1bTerminalBuilder builder) { ArgumentNullException.ThrowIfNull(title); ArgumentNullException.ThrowIfNull(builder); @@ -110,12 +110,12 @@ internal IAspireTerminal CreateTerminal(string title, TerminalSurface surface, H // Terminal ids are opaque to the dashboard and appear in websocket query strings, so use a // non-guessable value rather than a sequence number. var id = Guid.NewGuid().ToString("n"); - var terminal = new Hex1bAspireTerminal(this, id, title, surface, builder, _logger); + var terminal = new Hex1bAspireTerminal(this, id, title, placement, builder, _logger); _terminals[id] = terminal; - _logger.LogDebug("Created {Surface} terminal {TerminalId} ({Title}).", surface, id, title); + _logger.LogDebug("Created {Placement} terminal {TerminalId} ({Title}).", placement, id, title); - if (terminal.Surface == TerminalSurface.Dock) + if (terminal.Placement == TerminalPlacement.Dock) { Publish(new TerminalChange(TerminalChangeType.Added, terminal.Descriptor)); } @@ -175,7 +175,7 @@ internal TerminalSubscription SubscribeDockTerminals() ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Add(c), channel); var initial = _terminals.Values - .Where(t => t.Surface == TerminalSurface.Dock) + .Where(t => t.Placement == TerminalPlacement.Dock) .Select(t => t.Descriptor) .ToImmutableArray(); @@ -252,7 +252,7 @@ internal void Remove(Hex1bAspireTerminal terminal) _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title); - if (terminal.Surface == TerminalSurface.Dock) + if (terminal.Placement == TerminalPlacement.Dock) { Publish(new TerminalChange(TerminalChangeType.Removed, terminal.Descriptor)); } diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index f997941ef4d..c05fdf6a793 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -41,7 +41,7 @@ public async Task PromptInputsAsync_TerminalOnTheDockSurface_Throws() // A dock terminal is already presented as a dock tab, so showing it in a dialog as well would render one // terminal through two competing presentations. - await using var dockTerminal = CreateTerminal(terminalService, TerminalSurface.Dock); + await using var dockTerminal = CreateTerminal(terminalService, TerminalPlacement.Dock); var input = new InteractionInput { Name = "shell", @@ -52,7 +52,7 @@ public async Task PromptInputsAsync_TerminalOnTheDockSurface_Throws() var ex = await Assert.ThrowsAsync( () => interactionService.PromptInputsAsync("Title", "Message", [input])).DefaultTimeout(); - Assert.Contains(nameof(TerminalSurface.Dock), ex.Message, StringComparison.Ordinal); + Assert.Contains(nameof(TerminalPlacement.Dock), ex.Message, StringComparison.Ordinal); // The rejected prompt must not take the caller's dock tab with it. Assert.True(terminalService.TryGetTerminal(dockTerminal.Id, out _)); @@ -63,7 +63,7 @@ public async Task PromptInputsAsync_TerminalInput_CarriesTheCallersTerminalIdInt { var (interactionService, terminalService) = CreateInteractionService(); - await using var terminal = CreateTerminal(terminalService, TerminalSurface.Interaction); + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); var input = new InteractionInput { Name = "shell", @@ -89,7 +89,7 @@ public async Task PromptInputsAsync_Cancelled_LeavesTheCallersTerminalAlone() { var (interactionService, terminalService) = CreateInteractionService(); - await using var terminal = CreateTerminal(terminalService, TerminalSurface.Interaction); + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); var input = new InteractionInput { Name = "shell", @@ -116,7 +116,7 @@ public async Task PromptInputsAsync_CallerTokenCancelled_LeavesTheCallersTermina { var (interactionService, terminalService) = CreateInteractionService(); - await using var terminal = CreateTerminal(terminalService, TerminalSurface.Interaction); + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); var terminalInput = new InteractionInput { Name = "shell", @@ -145,7 +145,7 @@ public async Task PromptInputsAsync_TerminalShownTwice_Succeeds() // Caller-owned lifetime is what makes this legal: the terminal survives the first dialog, so the same // session can be surfaced again rather than the caller having to start a second workload. - await using var terminal = CreateTerminal(terminalService, TerminalSurface.Interaction); + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); for (var i = 0; i < 2; i++) { @@ -190,12 +190,12 @@ private static Task CancelInteractionAsync(InteractionService interactionService (_, _, _) => new InteractionCompletionState { Complete = true }, CancellationToken.None); - private static IAspireTerminal CreateTerminal(TerminalService service, TerminalSurface surface) + private static IAspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement) => service.CreateTerminal(new TerminalLaunchOptions { Title = "Terminal", Command = new TerminalCommand("bash"), - Surface = surface + Placement = placement }); private static (InteractionService InteractionService, TerminalService TerminalService) CreateInteractionService() diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 8bf3165a811..00645decbce 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -48,7 +48,7 @@ public void CreateTerminal_RegistersTerminalUnderANonGuessableId() var terminal = CreateInteractionTerminal(service, "Shell"); Assert.Equal("Shell", terminal.Title); - Assert.Equal(TerminalSurface.Interaction, terminal.Surface); + Assert.Equal(TerminalPlacement.Dialog, terminal.Placement); // Ids appear in websocket query strings, so they must not be a sequence number a caller could walk. Assert.Equal(32, terminal.Id.Length); @@ -235,7 +235,7 @@ private static IAspireTerminal CreateInteractionTerminal(TerminalService service { Title = title, Command = new TerminalCommand("bash"), - Surface = TerminalSurface.Interaction + Placement = TerminalPlacement.Dialog }); private static IAspireTerminal CreateDockTerminal(TerminalService service, string title) @@ -243,7 +243,7 @@ private static IAspireTerminal CreateDockTerminal(TerminalService service, strin { Title = title, Command = new TerminalCommand("bash"), - Surface = TerminalSurface.Dock + Placement = TerminalPlacement.Dock }); /// From 59f71830f199e92657677e7f39ad394b163490b0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 18:44:13 +1000 Subject: [PATCH 027/106] Report every terminal in one listing Terminals belonging to resources and terminals owned by the AppHost were tracked separately, so `aspire terminal ps` could only see the former. A terminal opened by a command -- in a dock tab, in an interaction dialog, or driven purely by automation -- was invisible. TerminalService becomes the single registry. ResourceTerminalCatalog projects the terminal-enabled replicas in the application model into the same shape, and ResourceAspireTerminal gives them the same IAspireTerminal automation surface by joining the replica's consumer socket as an HMP1 secondary, which passes input through without taking over the grid size a human viewer is watching. Handles connect lazily so that listing terminals does not add a peer to every replica. The automation members the two implementations share now live in TerminalAutomation rather than being duplicated. The backchannel carries AppHost terminals in a new array rather than widening the resource-shaped summary: replicas, terminal-host reachability, and restart counts have no meaning for a terminal running in the AppHost process. `terminal ps` renders them as a second table for the same reason. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../BackchannelJsonSerializerContext.cs | 2 + src/Aspire.Cli/Commands/TerminalPsCommand.cs | 142 +++++++-- .../AuxiliaryBackchannelRpcTarget.cs | 26 +- .../Backchannel/BackchannelDataTypes.cs | 44 ++- .../DistributedApplicationBuilder.cs | 13 +- .../Terminals/Hex1bAspireTerminal.cs | 57 +--- .../Terminals/ResourceAspireTerminal.cs | 288 ++++++++++++++++++ .../Terminals/ResourceTerminalCatalog.cs | 173 +++++++++++ .../Terminals/TerminalAutomation.cs | 95 ++++++ .../Terminals/TerminalService.cs | 87 +++++- .../Commands/TerminalCommandTests.cs | 167 ++++++++++ .../Terminals/ResourceTerminalCatalogTests.cs | 211 +++++++++++++ .../Terminals/TerminalServiceTests.cs | 59 ++++ 13 files changed, 1284 insertions(+), 80 deletions(-) create mode 100644 src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs create mode 100644 src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs create mode 100644 src/Aspire.Hosting/Terminals/TerminalAutomation.cs create mode 100644 tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index b20da405fe4..71bb37c7f98 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -110,6 +110,8 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(ListTerminalsResponse))] [JsonSerializable(typeof(TerminalSummary))] [JsonSerializable(typeof(TerminalSummary[]))] +[JsonSerializable(typeof(AppHostTerminalSummary))] +[JsonSerializable(typeof(AppHostTerminalSummary[]))] internal partial class BackchannelJsonSerializerContext : JsonSerializerContext { [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")] diff --git a/src/Aspire.Cli/Commands/TerminalPsCommand.cs b/src/Aspire.Cli/Commands/TerminalPsCommand.cs index 58d5e5dd55c..cc8792747cf 100644 --- a/src/Aspire.Cli/Commands/TerminalPsCommand.cs +++ b/src/Aspire.Cli/Commands/TerminalPsCommand.cs @@ -15,8 +15,9 @@ namespace Aspire.Cli.Commands; /// -/// Lists every WithTerminal-enabled resource in the connected AppHost, with current grid -/// size, attached-peer count, and per-replica health. Backs aspire terminal ps. +/// Lists every terminal in the connected AppHost — those belonging to WithTerminal-enabled +/// resources, and those the AppHost itself owns (dock tabs, interaction dialogs, automation-only +/// sessions). Backs aspire terminal ps. /// /// /// The command: @@ -25,10 +26,10 @@ namespace Aspire.Cli.Commands; /// Verifies the AppHost advertises the terminals.v1 capability and falls back to a /// "not supported" error message when it does not (older AppHosts pre-13.4 lack the entire /// WithTerminal/terminal attach+terminal ps surface). -/// Calls to enumerate every -/// terminal-enabled resource. Resources whose host process isn't reachable are still listed -/// with a status indicating they are unavailable rather than silently dropped. -/// Renders the result as a Spectre.Console table or — when --format json is supplied +/// Calls to enumerate them. +/// Resources whose host process isn't reachable are still listed with a status indicating they +/// are unavailable rather than silently dropped. +/// Renders the result as Spectre.Console tables or — when --format json is supplied /// — a flat JSON document for scripting. /// /// @@ -124,7 +125,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul "Listing terminal sessions...", async () => await connection.ListTerminalsAsync(cancellationToken).ConfigureAwait(false)); - if (response.Terminals.Length == 0) + if (response.Terminals.Length == 0 && (response.AppHostTerminals is null || response.AppHostTerminals.Length == 0)) { if (format == OutputFormat.Json) { @@ -133,15 +134,16 @@ protected override async Task ExecuteAsync(ParseResult parseResul else { _interactionService.DisplayMessage(KnownEmojis.Information, - "No resources in the connected AppHost are configured for interactive terminals (`.WithTerminal()`)."); + "No terminals are running in the connected AppHost. Resources opt in with `.WithTerminal()`; AppHost-owned terminals appear here while they are open."); } return CommandResult.Success(); } _logger.LogDebug( - "ListTerminalsAsync returned {Count} terminal(s); reachable={Reachable}", + "ListTerminalsAsync returned {ResourceCount} resource terminal(s) (reachable={Reachable}) and {AppHostCount} AppHost terminal(s)", response.Terminals.Length, - response.Terminals.Count(t => t.IsHostReachable)); + response.Terminals.Count(t => t.IsHostReachable), + response.AppHostTerminals?.Length ?? 0); if (format == OutputFormat.Json) { @@ -188,6 +190,7 @@ private void EmitJson(Aspire.Cli.Backchannel.ListTerminalsResponse response, boo dtos.Add(new TerminalPsJsonEntry { + Owner = "resource", ResourceName = terminal.ResourceName, DisplayName = terminal.DisplayName, ConfiguredColumns = terminal.ConfiguredColumns, @@ -197,13 +200,89 @@ private void EmitJson(Aspire.Cli.Backchannel.ListTerminalsResponse response, boo }); } + foreach (var terminal in response.AppHostTerminals ?? []) + { + dtos.Add(new TerminalPsJsonEntry + { + Owner = "apphost", + TerminalId = terminal.TerminalId, + DisplayName = terminal.Title, + Placement = terminal.Placement, + }); + } + var json = JsonSerializer.Serialize(dtos, TerminalPsJsonContext.Default.ListTerminalPsJsonEntry); _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } private void DisplayTable(Aspire.Cli.Backchannel.ListTerminalsResponse response, bool verbose) { - var table = new Table(); + if (response.Terminals.Length > 0) + { + DisplayResourceTerminals(response); + } + + if (response.AppHostTerminals is { Length: > 0 } appHostTerminals) + { + DisplayAppHostTerminals(appHostTerminals); + } + + if (verbose) + { + DisplayPeerDetails(response); + } + } + + /// + /// Renders terminals whose workload runs in the AppHost process. + /// + /// + /// Kept in its own table rather than merged with the resource table because none of the resource + /// columns — replica, liveness, size, peers, restarts — mean anything for these. Merging would produce + /// a row of placeholders that implies the data is missing rather than inapplicable. + /// + private void DisplayAppHostTerminals(Aspire.Cli.Backchannel.AppHostTerminalSummary[] terminals) + { + var table = new Table + { + Title = new TableTitle("AppHost terminals"), + }; + + table.AddBoldColumn("Terminal"); + table.AddBoldColumn("Id"); + table.AddBoldColumn("Shown"); + + foreach (var terminal in terminals) + { + table.AddRow( + Markup.Escape(terminal.Title), + Markup.Escape(terminal.TerminalId), + Markup.Escape(DescribePlacement(terminal.Placement))); + } + + _interactionService.DisplayRenderable(table); + } + + /// + /// Turns a TerminalPlacement value into something readable, passing through anything the AppHost + /// added after this CLI was built rather than hiding it behind "unknown". + /// + private static string DescribePlacement(string placement) => placement switch + { + "Dock" => "dock", + "Dialog" => "dialog", + "ResourceView" => "resource view", + "None" => "not shown", + _ => placement, + }; + + private void DisplayResourceTerminals(Aspire.Cli.Backchannel.ListTerminalsResponse response) + { + var table = new Table + { + Title = new TableTitle("Resource terminals"), + }; + table.AddBoldColumn("Resource"); table.AddBoldColumn("Replica"); table.AddBoldColumn("Status"); @@ -252,11 +331,6 @@ private void DisplayTable(Aspire.Cli.Backchannel.ListTerminalsResponse response, } _interactionService.DisplayRenderable(table); - - if (verbose) - { - DisplayPeerDetails(response); - } } private void DisplayPeerDetails(Aspire.Cli.Backchannel.ListTerminalsResponse response) @@ -303,12 +377,38 @@ private void DisplayPeerDetails(Aspire.Cli.Backchannel.ListTerminalsResponse res internal sealed class TerminalPsJsonEntry { - public required string ResourceName { get; init; } + /// + /// Gets what owns the terminal's workload: resource or apphost. Discriminates which of the + /// remaining properties are populated. + /// + public required string Owner { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TerminalId { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResourceName { get; init; } + public required string DisplayName { get; init; } - public required int ConfiguredColumns { get; init; } - public required int ConfiguredRows { get; init; } - public required bool IsHostReachable { get; init; } - public required TerminalPsJsonReplica[] Replicas { get; init; } + + /// + /// Gets where an AppHost terminal is displayed. Absent for resource terminals, which are always shown + /// on their resource. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Placement { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ConfiguredColumns { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ConfiguredRows { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IsHostReachable { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TerminalPsJsonReplica[]? Replicas { get; init; } } internal sealed class TerminalPsJsonReplica diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 300133d1034..5cfa447b676 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -20,6 +20,8 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + namespace Aspire.Hosting.Backchannel; /// @@ -578,8 +580,9 @@ public async Task GetTerminalInfoAsync(GetTerminalInfoR } /// - /// Lists every WithTerminal-enabled resource in the AppHost, with current grid size and - /// attached-peer details. Used by aspire terminal ps. Each per-resource snapshot is + /// Lists every terminal in the AppHost. Resource terminals are reported with current grid size and + /// attached-peer details; AppHost-owned terminals are reported separately because they have no + /// replicas or terminal host. Used by aspire terminal ps. Each per-resource snapshot is /// independent: a resource whose terminal host hasn't started yet (or whose control RPC times /// out) is reported with = false rather than /// failing the whole listing. @@ -625,9 +628,28 @@ public async Task ListTerminalsAsync(ListTerminalsRequest return new ListTerminalsResponse { Terminals = [.. terminals], + AppHostTerminals = CollectAppHostTerminals(), }; } + /// + /// Projects the terminals the AppHost itself owns — dock tabs, terminals shown in an interaction dialog, + /// and terminals driven only through automation — into the listing. + /// + private AppHostTerminalSummary[] CollectAppHostTerminals() + { + var terminalService = serviceProvider.GetRequiredService(); + + return [.. terminalService.ListAll() + .Where(t => t.Owner == Aspire.Hosting.Terminals.TerminalOwner.AppHost) + .Select(t => new AppHostTerminalSummary + { + TerminalId = t.Id, + Title = t.Title, + Placement = t.Placement.ToString(), + })]; + } + /// /// Waits for a resource to reach a target status. /// diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 4dda3439584..e357ae604d6 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -1795,8 +1795,8 @@ internal sealed class TerminalSummary } /// -/// Response from ListTerminalsAsync. Lists every WithTerminal-enabled resource in the -/// AppHost. Empty array when no resource is configured for terminals. +/// Response from ListTerminalsAsync. Lists every terminal in the AppHost, whether it belongs to a +/// resource or to the AppHost itself. /// internal sealed class ListTerminalsResponse { @@ -1804,6 +1804,46 @@ internal sealed class ListTerminalsResponse /// Gets the per-resource summaries. Empty (not null) when there are no terminal-enabled resources. /// public required TerminalSummary[] Terminals { get; init; } + + /// + /// Gets the terminals owned by the AppHost process rather than by a resource. + /// + /// + /// Carried separately from rather than folded into it because the resource + /// summaries are shaped around replicas and terminal hosts, neither of which an AppHost terminal has. + /// Null when the AppHost predates AppHost-owned terminals, which is distinct from an AppHost that has + /// none right now. + /// + public AppHostTerminalSummary[]? AppHostTerminals { get; init; } +} + +/// +/// One terminal whose workload runs in the AppHost process. +/// +/// +/// These have no replicas and no terminal host: the workload runs in-process and reaches the dashboard over +/// the gRPC tunnel, so there is no liveness to report beyond the terminal's presence in this list. +/// +internal sealed class AppHostTerminalSummary +{ + /// + /// Gets the identifier used to address this terminal. + /// + public required string TerminalId { get; init; } + + /// + /// Gets the terminal's title, as shown on its dock tab or in its dialog. + /// + public required string Title { get; init; } + + /// + /// Gets where the terminal is displayed: Dock, Dialog, ResourceView, or None. + /// + /// + /// Sent as a string rather than an enum so that a value added later deserializes on an older CLI instead + /// of failing the whole listing. + /// + public required string Placement { get; init; } } #endregion diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 05822e2b495..aba2d597d30 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -471,8 +471,17 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); // Constructed explicitly rather than by DI activation: TerminalService is public (so AppHost code can // resolve it) but its constructor is internal, and the DI container only activates public constructors. - _innerBuilder.Services.AddSingleton(sp => new Terminals.TerminalService( - sp.GetRequiredService>())); + _innerBuilder.Services.AddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + + return new Terminals.TerminalService(logger) + { + // Terminals belonging to resources are discovered from the model rather than registered, so the + // service is given a catalog to consult instead of owning their lifetime. + ResourceTerminals = new Terminals.ResourceTerminalCatalog(sp.GetRequiredService(), logger) + }; + }); ConfigureHealthChecks(); diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index 770d8a1a843..26b2150630b 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -1,7 +1,6 @@ // 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 Hex1b; using Hex1b.Automation; @@ -22,8 +21,6 @@ namespace Aspire.Hosting.Terminals; /// internal sealed class Hex1bAspireTerminal : IAspireTerminal { - private static readonly TimeSpan s_defaultAutomationTimeout = TimeSpan.FromSeconds(30); - // Unbounded because the producer is a viewer attaching; the queue depth is realistically 0 or 1 and // dropping or blocking an attach would strand the RPC that is waiting to be served. private readonly Channel _clients = Channel.CreateUnbounded(); @@ -150,7 +147,7 @@ private Hex1bTerminal EnsureStarted() .WithHmp1Server(_clients.Reader.ReadAllAsync) .Build(); - _automator = new Hex1bTerminalAutomator(_terminal, s_defaultAutomationTimeout); + _automator = new Hex1bTerminalAutomator(_terminal, TerminalAutomation.DefaultTimeout); _logger.LogDebug("Starting terminal {TerminalId} ({Title}).", Id, Title); @@ -202,14 +199,13 @@ public async Task SendTextAsync(string text, CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(text); EnsureStarted(); - await _automator!.TypeAsync(text, cancellationToken).ConfigureAwait(false); + await TerminalAutomation.SendTextAsync(_automator!, text, cancellationToken).ConfigureAwait(false); } public async Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) { var terminal = EnsureStarted(); - var sequence = AspireTerminalKeySequences.Get(key); - await terminal.SendInputAsync(Encoding.UTF8.GetBytes(sequence), cancellationToken).ConfigureAwait(false); + await TerminalAutomation.SendKeyAsync(terminal, key, cancellationToken).ConfigureAwait(false); } public async Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default) @@ -217,58 +213,15 @@ public async Task WaitForTextAsync(string text, TimeSpan? timeout = null, Cancel ArgumentNullException.ThrowIfNull(text); EnsureStarted(); - - // Hex1b's wait takes a timeout but no token, so the caller's cancellation is layered on here. The - // underlying wait keeps running until its timeout elapses; that is acceptable because it is a passive - // screen poll with no side effects. - var wait = _automator!.WaitUntilTextAsync(text, timeout ?? s_defaultAutomationTimeout); - var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled); - - var completed = await Task.WhenAny(wait, cancelled.Task).ConfigureAwait(false); - if (completed != wait) - { - // The wait is abandoned rather than awaited, so nothing would observe the WaitUntilTimeoutException it - // raises when its own timeout later elapses. An unobserved faulted task surfaces on - // TaskScheduler.UnobservedTaskException, which is a process-wide event an AppHost may treat as fatal. - _ = wait.ContinueWith( - static t => _ = t.Exception, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - - cancellationToken.ThrowIfCancellationRequested(); - } - - try - { - await wait.ConfigureAwait(false); - } - catch (WaitUntilTimeoutException ex) - { - // Translate so callers never have to reference Hex1b to handle a timeout. - throw new TimeoutException($"Terminal '{Id}' did not display the expected text within the timeout.", ex); - } + await TerminalAutomation.WaitForTextAsync(_automator!, Id, text, timeout, cancellationToken).ConfigureAwait(false); } public string GetScreenText() { - Hex1bTerminalAutomator? automator; lock (_gate) { - automator = _automator; + return TerminalAutomation.GetScreenText(_automator); } - - // A terminal that has never been attached to or driven has no screen yet. Reporting empty is - // friendlier than starting the workload as a side effect of a read. - if (automator is null) - { - return string.Empty; - } - - // The snapshot holds pooled buffers, so it must be released rather than left to finalization. - using var snapshot = automator.CreateSnapshot(); - return snapshot.GetScreenText(); } /// diff --git a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs new file mode 100644 index 00000000000..14c135c5af2 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs @@ -0,0 +1,288 @@ +// 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.Automation; +using Microsoft.Extensions.Logging; + +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Terminals; + +/// +/// An over a terminal that belongs to a resource replica. +/// +/// +/// +/// Unlike an AppHost terminal, the workload here runs in a per-replica terminal host process and the AppHost +/// is only ever a peer of it. Automation is served by joining that host's consumer socket as an ordinary HMP1 +/// client — the same socket the CLI's terminal attach and the dashboard's resource terminal view dial — +/// and running the standard automator against the resulting terminal. The screen is replicated to every peer, +/// so a client-side terminal is a faithful mirror of the producer's. +/// +/// +/// The connection is made on first use rather than at construction. Listing terminals must not cost a socket +/// connection per replica, and the connection shows up in the host's peer roster, so an idle handle that +/// nobody is automating should leave no trace. +/// +/// +/// The peer always joins as . Only the primary peer's dimensions drive the +/// producer's PTY, and a secondary is still fully interactive, so automation can read and type without +/// resizing the grid out from under a human who is watching the same terminal. +/// +/// +internal sealed class ResourceAspireTerminal : IAspireTerminal +{ + /// + /// How long to wait for the HMP1 handshake before treating the terminal host as unreachable. + /// + /// + /// The socket is local, so a healthy host completes the handshake in milliseconds. This bound exists for + /// the case where the host process is gone but its socket file has not been cleaned up, where a connect + /// would otherwise hang an automation call indefinitely. + /// + private static readonly TimeSpan s_connectTimeout = TimeSpan.FromSeconds(10); + + private readonly string _consumerUdsPath; + private readonly ILogger _logger; + private readonly CancellationTokenSource _clientCts = new(); + private readonly object _gate = new(); + + private Task? _connectTask; + private Task? _runTask; + private Hex1bTerminalAutomator? _automator; + private bool _disposed; + + public ResourceAspireTerminal(string id, string title, string consumerUdsPath, ILogger logger) + { + Id = id; + Title = title; + _consumerUdsPath = consumerUdsPath; + _logger = logger; + } + + public string Id { get; } + + public string Title { get; } + + public TerminalOwner Owner => TerminalOwner.Resource; + + public TerminalPlacement Placement => TerminalPlacement.ResourceView; + + /// + /// The workload is started by the resource it belongs to, so there is nothing for the AppHost to start. + /// + public void Start() + { + } + + /// + /// A resource terminal is displayed on its own resource's terminal view, which the dashboard navigates to + /// directly. There is no dock tab for this to activate. + /// + public void Show() + { + } + + public async Task SendTextAsync(string text, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(text); + + var connection = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); + await TerminalAutomation.SendTextAsync(connection.Automator, text, cancellationToken).ConfigureAwait(false); + } + + public async Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) + { + var connection = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); + await TerminalAutomation.SendKeyAsync(connection.Terminal, key, cancellationToken).ConfigureAwait(false); + } + + public async Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(text); + + var connection = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); + await TerminalAutomation.WaitForTextAsync(connection.Automator, Id, text, timeout, cancellationToken).ConfigureAwait(false); + } + + public string GetScreenText() + { + Hex1bTerminalAutomator? automator; + lock (_gate) + { + automator = _automator; + } + + return TerminalAutomation.GetScreenText(automator); + } + + /// + /// Connects to the replica's terminal host on first use, and returns the same connection thereafter. + /// + private Task EnsureConnectedAsync(CancellationToken cancellationToken) + { + Task connectTask; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Cache the task rather than the result so concurrent callers share one connection attempt, and a + // failed attempt is not retried behind the back of the caller that observed the failure. + connectTask = _connectTask ??= ConnectAsync(); + } + + return connectTask.WaitAsync(cancellationToken); + } + + private async Task ConnectAsync() + { + // Never run the connect inline under _gate. + await Task.Yield(); + + var connected = new TaskCompletionSource<(int Width, int Height)>(TaskCreationOptions.RunContinuationsAsynchronously); + Hex1bTerminal? terminal = null; + + terminal = Hex1bTerminal.CreateBuilder() + // An arbitrary opener. The handshake reports the producer's real grid and the terminal is resized + // to match before the automator is handed out, so nothing ever reads this size. + .WithDimensions(80, 24) + .WithHmp1UdsClient(_consumerUdsPath, options => + { + // Named so a human running `aspire terminal ps --verbose` can tell an automation peer apart + // from a dashboard tab or an attached CLI. + options.DisplayName = $"apphost-automation:{Id}"; + options.DefaultRole = Hmp1Role.Secondary; + + options.OnConnected = (e, _) => + { + Resize(terminal, e.Width, e.Height); + connected.TrySetResult((e.Width, e.Height)); + return Task.CompletedTask; + }; + + // Follow the producer's grid when another peer resizes it, so a screen read after a human + // resizes their dashboard tab is not silently clipped to the old dimensions. + options.OnRemoteResized = (e, _) => + { + Resize(terminal, e.Width, e.Height); + return Task.CompletedTask; + }; + + options.OnDisconnected = _ => + { + // The terminal host went away. Fail a handshake still in flight rather than letting it + // sit until the connect timeout. + connected.TrySetException(new InvalidOperationException( + $"The terminal host for terminal '{Id}' disconnected before the connection was established.")); + return Task.CompletedTask; + }; + }) + .Build(); + + _logger.LogDebug("Connecting AppHost automation to resource terminal {TerminalId} at '{ConsumerPath}'.", Id, _consumerUdsPath); + + _runTask = RunClientAsync(terminal); + + try + { + var (width, height) = await connected.Task.WaitAsync(s_connectTimeout).ConfigureAwait(false); + _logger.LogDebug("Connected to resource terminal {TerminalId} ({Width}x{Height}).", Id, width, height); + } + catch (Exception ex) + { + // The pump owns the terminal once RunClientAsync is running, so tear it down through the same + // path rather than disposing the terminal here and racing the pump. + _clientCts.Cancel(); + + if (ex is TimeoutException) + { + throw new InvalidOperationException( + $"Timed out connecting to the terminal host for terminal '{Id}' at '{_consumerUdsPath}'. The resource replica may not be running.", ex); + } + + throw; + } + + var automator = new Hex1bTerminalAutomator(terminal, TerminalAutomation.DefaultTimeout); + + lock (_gate) + { + _automator = automator; + } + + return new TerminalConnection(terminal, automator); + + static void Resize(Hex1bTerminal? target, int width, int height) + => target?.Resize(Math.Max(1, width), Math.Max(1, height)); + } + + private async Task RunClientAsync(Hex1bTerminal terminal) + { + try + { + await terminal.RunAsync(_clientCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: the handle was disposed, or the connect attempt was abandoned. + _logger.LogDebug("AppHost automation peer for resource terminal {TerminalId} was cancelled.", Id); + } + catch (Exception ex) + { + // Unexpected. The workload itself is unaffected — only this process's view of it is lost — so this + // is a warning rather than an error, but it does mean subsequent automation calls read a dead screen. + _logger.LogWarning(ex, "AppHost automation peer for resource terminal {TerminalId} ended unexpectedly.", Id); + } + finally + { + try + { + await terminal.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Disposing the automation peer for resource terminal {TerminalId} failed.", Id); + } + } + } + + /// + /// Disconnects the AppHost's automation peer. The resource's workload is unaffected. + /// + public async ValueTask DisposeAsync() + { + Task? runTask; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + runTask = _runTask; + _automator = null; + } + + await _clientCts.CancelAsync().ConfigureAwait(false); + + if (runTask is not null) + { + try + { + await runTask.ConfigureAwait(false); + } + catch (Exception ex) + { + // RunClientAsync already logs and swallows; this only guards against a fault escaping the pump + // itself, which must not turn disposal into a throwing operation. + _logger.LogDebug(ex, "The automation peer for resource terminal {TerminalId} faulted while disconnecting.", Id); + } + } + + _clientCts.Dispose(); + } + + private sealed record TerminalConnection(Hex1bTerminal Terminal, Hex1bTerminalAutomator Automator); +} diff --git a/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs b/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs new file mode 100644 index 00000000000..ed631ec045f --- /dev/null +++ b/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs @@ -0,0 +1,173 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Globalization; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; + +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Terminals; + +/// +/// Discovers the terminals that belong to resources in the application model, and hands out +/// handles for them. +/// +/// +/// +/// Resource terminals are not registered with the way AppHost terminals are. +/// They come and go with their replicas, and the AppHost is not their owner, so the application model plus the +/// per-replica terminal hosts remain the source of truth and this type projects that into the same shape as +/// the terminals the AppHost owns. +/// +/// +/// Handles are cached per replica so that repeated lookups share one automation connection rather than opening +/// a socket per call. They are created eagerly on lookup but connect lazily, so a handle that is listed and +/// never automated costs nothing. +/// +/// +internal sealed class ResourceTerminalCatalog : IAsyncDisposable +{ + /// + /// Prefix distinguishing a resource terminal id from the opaque identifier of an AppHost terminal. + /// + /// + /// AppHost terminal ids are random and must stay unguessable because they appear in websocket query + /// strings. A resource terminal is addressed by something the user already knows — the resource name and + /// replica index — which is what lets aspire terminal attach and automation refer to the same + /// terminal across a replica's terminal host being recycled. + /// + public const string IdPrefix = "resource:"; + + private readonly ConcurrentDictionary _handles = new(StringComparer.Ordinal); + private readonly DistributedApplicationModel _model; + private readonly ILogger _logger; + private int _disposed; + + public ResourceTerminalCatalog(DistributedApplicationModel model, ILogger logger) + { + _model = model; + _logger = logger; + } + + /// + /// Builds the stable identifier for a resource terminal. + /// + public static string BuildId(string resourceName, int replicaIndex) + => string.Create(CultureInfo.InvariantCulture, $"{IdPrefix}{resourceName}:{replicaIndex}"); + + /// + /// Determines whether an id addresses a resource terminal rather than an AppHost terminal. + /// + public static bool IsResourceTerminalId(string terminalId) + => terminalId.StartsWith(IdPrefix, StringComparison.Ordinal); + + /// + /// Enumerates every terminal-enabled resource replica in the application model. + /// + /// + /// This reads only the application model, so it neither connects to a terminal host nor reports liveness. + /// Callers that need per-replica health query the control socket separately; keeping the two apart is what + /// lets a listing be produced without touching a socket. + /// + public IReadOnlyList List() + { + var entries = new List(); + + foreach (var resource in _model.Resources) + { + var annotation = resource.Annotations.OfType().FirstOrDefault(); + if (annotation is null) + { + continue; + } + + foreach (var host in annotation.TerminalHosts) + { + entries.Add(new ResourceTerminalEntry( + Id: BuildId(resource.Name, host.ParentReplicaIndex), + ResourceName: resource.Name, + ReplicaIndex: host.ParentReplicaIndex, + ReplicaCount: annotation.TerminalHosts.Count, + ConsumerUdsPath: host.Layout.ConsumerUdsPath, + ControlUdsPath: host.Layout.ControlUdsPath, + ConfiguredColumns: annotation.Options.Columns, + ConfiguredRows: annotation.Options.Rows)); + } + } + + return entries; + } + + /// + /// Gets a handle for a resource terminal by its stable id. + /// + public bool TryGetTerminal(string terminalId, out IAspireTerminal? terminal) + { + terminal = null; + + if (_disposed != 0 || !IsResourceTerminalId(terminalId)) + { + return false; + } + + var entry = List().FirstOrDefault(e => string.Equals(e.Id, terminalId, StringComparison.Ordinal)); + if (entry is null) + { + return false; + } + + terminal = _handles.GetOrAdd( + entry.Id, + static (id, state) => new ResourceAspireTerminal(id, state.Entry.Title, state.Entry.ConsumerUdsPath, state.Logger), + (Entry: entry, Logger: _logger)); + + return true; + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + foreach (var handle in _handles.Values) + { + // Disposing a resource terminal handle disconnects the AppHost's automation peer; the resource's + // own workload is unaffected, so there is nothing here that should delay shutdown. + try + { + await handle.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Disconnecting the automation peer for resource terminal {TerminalId} failed.", handle.Id); + } + } + + _handles.Clear(); + } +} + +/// +/// One terminal-enabled resource replica, as described by the application model. +/// +internal sealed record ResourceTerminalEntry( + string Id, + string ResourceName, + int ReplicaIndex, + int ReplicaCount, + string ConsumerUdsPath, + string ControlUdsPath, + int ConfiguredColumns, + int ConfiguredRows) +{ + /// + /// Gets the title shown for this terminal, qualified by replica only when the resource has more than one. + /// + public string Title => ReplicaCount > 1 + ? string.Create(CultureInfo.InvariantCulture, $"{ResourceName} (replica {ReplicaIndex})") + : ResourceName; +} diff --git a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs new file mode 100644 index 00000000000..fccddcf1c39 --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs @@ -0,0 +1,95 @@ +// 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 Hex1b; +using Hex1b.Automation; + +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Terminals; + +/// +/// The shared implementation of 's automation members. +/// +/// +/// Every terminal Aspire exposes is ultimately a , whether its workload runs in the +/// AppHost or in a resource's terminal host that this process is merely connected to as a peer. Only the way +/// that terminal is obtained differs, so the automation semantics — cancellation layering, exception +/// translation, snapshot disposal — live here once rather than in each implementation. +/// +internal static class TerminalAutomation +{ + /// + /// How long the wait helpers poll for before giving up when the caller does not specify a timeout. + /// + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + public static Task SendTextAsync(Hex1bTerminalAutomator automator, string text, CancellationToken cancellationToken) + => automator.TypeAsync(text, cancellationToken); + + public static Task SendKeyAsync(Hex1bTerminal terminal, AspireTerminalKey key, CancellationToken cancellationToken) + { + var sequence = AspireTerminalKeySequences.Get(key); + return terminal.SendInputAsync(Encoding.UTF8.GetBytes(sequence), cancellationToken); + } + + public static async Task WaitForTextAsync( + Hex1bTerminalAutomator automator, + string terminalId, + string text, + TimeSpan? timeout, + CancellationToken cancellationToken) + { + // Hex1b's wait takes a timeout but no token, so the caller's cancellation is layered on here. The + // underlying wait keeps running until its timeout elapses; that is acceptable because it is a passive + // screen poll with no side effects. + var wait = automator.WaitUntilTextAsync(text, timeout ?? DefaultTimeout); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled); + + var completed = await Task.WhenAny(wait, cancelled.Task).ConfigureAwait(false); + if (completed != wait) + { + // The wait is abandoned rather than awaited, so nothing would observe the WaitUntilTimeoutException it + // raises when its own timeout later elapses. An unobserved faulted task surfaces on + // TaskScheduler.UnobservedTaskException, which is a process-wide event an AppHost may treat as fatal. + _ = wait.ContinueWith( + static t => _ = t.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + cancellationToken.ThrowIfCancellationRequested(); + } + + try + { + await wait.ConfigureAwait(false); + } + catch (WaitUntilTimeoutException ex) + { + // Translate so callers never have to reference Hex1b to handle a timeout. + throw new TimeoutException($"Terminal '{terminalId}' did not display the expected text within the timeout.", ex); + } + } + + /// + /// Reads the current screen, treating a terminal that has no automator yet as an empty screen. + /// + /// + /// A terminal that has never been attached to or driven has no screen yet. Reporting empty is friendlier + /// than starting the workload, or dialling a socket, as a side effect of a read. + /// + public static string GetScreenText(Hex1bTerminalAutomator? automator) + { + if (automator is null) + { + return string.Empty; + } + + // The snapshot holds pooled buffers, so it must be released rather than left to finalization. + using var snapshot = automator.CreateSnapshot(); + return snapshot.GetScreenText(); + } +} diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index e9c0fb06313..35ca941fa2e 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -47,6 +47,16 @@ internal TerminalService(ILogger logger) _logger = logger; } + /// + /// Gets or sets the catalog consulted for terminals that belong to resources rather than to the AppHost. + /// + /// + /// Assigned once the application model exists. It is settable rather than a constructor argument because + /// this service is created before the model is built, and it stays null in tests that exercise only + /// AppHost terminals. + /// + internal ResourceTerminalCatalog? ResourceTerminals { get; set; } + /// /// Creates a terminal. The workload does not start until something needs it: the first viewer attaching, /// or the first automation call. @@ -146,18 +156,73 @@ internal Task AttachAsync(string terminalId, Stream clientStream, CancellationTo /// The of the terminal to find. /// The terminal, if one with that id exists. /// if the terminal was found. + /// + /// Resolves terminals the AppHost owns as well as those belonging to resources, so automation code can + /// drive either kind through the same handle without knowing which it has. + /// public bool TryGetTerminal(string terminalId, [NotNullWhen(true)] out IAspireTerminal? terminal) { + ArgumentNullException.ThrowIfNull(terminalId); + if (_terminals.TryGetValue(terminalId, out var found)) { terminal = found; return true; } + if (ResourceTerminals?.TryGetTerminal(terminalId, out var resourceTerminal) == true) + { + terminal = resourceTerminal!; + return true; + } + terminal = null; return false; } + /// + /// Lists every terminal in the AppHost, whether the AppHost or a resource owns it. + /// + /// + /// This is what makes a single listing possible: dock tabs, terminals being shown in an interaction + /// dialog, terminals driven only by automation, and each terminal-enabled resource replica all appear + /// here. Resource entries are read from the application model and carry no liveness — obtaining that + /// requires a round trip to each replica's terminal host, which a listing should not force on callers + /// that only want to know what exists. + /// + internal IReadOnlyList ListAll() + { + var listings = new List(); + + foreach (var terminal in _terminals.Values) + { + listings.Add(new TerminalListing( + terminal.Id, + terminal.Title, + TerminalOwner.AppHost, + terminal.Placement, + ResourceName: null, + ReplicaIndex: null, + ConsumerUdsPath: null, + ControlUdsPath: null)); + } + + foreach (var entry in ResourceTerminals?.List() ?? []) + { + listings.Add(new TerminalListing( + entry.Id, + entry.Title, + TerminalOwner.Resource, + TerminalPlacement.ResourceView, + entry.ResourceName, + entry.ReplicaIndex, + entry.ConsumerUdsPath, + entry.ControlUdsPath)); + } + + return listings; + } + /// /// Subscribes to the dock's terminal list, returning the current set followed by a stream of changes. /// @@ -290,7 +355,10 @@ public async ValueTask DisposeAsync() channel.Writer.TryComplete(); } - await Task.CompletedTask.ConfigureAwait(false); + if (ResourceTerminals is { } resourceTerminals) + { + await resourceTerminals.DisposeAsync().ConfigureAwait(false); + } } } @@ -313,3 +381,20 @@ internal sealed record TerminalSubscription( public void Dispose() => Unsubscribe(); } + +/// +/// One terminal in the AppHost, as reported by . +/// +/// +/// The resource-specific members are populated only for terminals, whose +/// per-replica liveness is obtained by querying . +/// +internal sealed record TerminalListing( + string Id, + string Title, + TerminalOwner Owner, + TerminalPlacement Placement, + string? ResourceName, + int? ReplicaIndex, + string? ConsumerUdsPath, + string? ControlUdsPath); diff --git a/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs index cf7bf77046f..76f3adc1552 100644 --- a/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs @@ -563,6 +563,8 @@ public async Task TerminalPsCommand_JsonFormat_Verbose_WhenPopulated_RoundTripsC Assert.Equal(120, frontend.ConfiguredColumns); Assert.Equal(30, frontend.ConfiguredRows); Assert.True(frontend.IsHostReachable); + Assert.Equal("resource", frontend.Owner); + Assert.NotNull(frontend.Replicas); Assert.Equal(2, frontend.Replicas.Length); var replica0 = frontend.Replicas[0]; @@ -598,6 +600,7 @@ public async Task TerminalPsCommand_JsonFormat_Verbose_WhenPopulated_RoundTripsC Assert.False(backend.IsHostReachable); // Per #6 fix: degraded shape still surfaces Replicas so operators // can diagnose which replicas the AppHost expected. + Assert.NotNull(backend.Replicas); Assert.Single(backend.Replicas); Assert.False(backend.Replicas[0].IsAlive); Assert.Null(backend.Replicas[0].CurrentColumns); @@ -613,6 +616,170 @@ public async Task TerminalPsCommand_JsonFormat_Verbose_WhenPopulated_RoundTripsC } } + [Fact] + public async Task TerminalPsCommand_ListsAppHostTerminalsAlongsideResourceTerminals() + { + // `terminal ps` answers "what terminals exist", which includes the ones the AppHost owns: dock tabs, + // terminals being shown in an interaction dialog, and automation-only sessions. Listing only resource + // terminals would hide every terminal a command opened. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var capturedOutput = new TestOutputTextWriter(outputHelper); + + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ListTerminalsResponse = new ListTerminalsResponse + { + Terminals = [], + AppHostTerminals = + [ + new AppHostTerminalSummary + { + TerminalId = "abc123", + Title = "Azure login", + Placement = "Dialog", + }, + new AppHostTerminalSummary + { + TerminalId = "def456", + Title = "Build output", + Placement = "Dock", + } + ] + }; + }, + options => + { + options.OutputTextWriter = capturedOutput; + options.DisableAnsi = true; + }); + + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal ps --format json"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var stdout = string.Join("\n", capturedOutput.Logs).Trim(); + var entries = JsonSerializer.Deserialize(stdout, TerminalPsJsonContext.Default.ListTerminalPsJsonEntry); + + Assert.NotNull(entries); + Assert.Equal(2, entries.Count); + Assert.All(entries, e => Assert.Equal("apphost", e.Owner)); + + var dialog = entries[0]; + Assert.Equal("abc123", dialog.TerminalId); + Assert.Equal("Azure login", dialog.DisplayName); + Assert.Equal("Dialog", dialog.Placement); + + // The resource-shaped members describe replicas and terminal hosts, neither of which an AppHost + // terminal has. They must be omitted rather than emitted as nulls a script would have to filter. + Assert.Null(dialog.ResourceName); + Assert.Null(dialog.Replicas); + Assert.DoesNotContain("\"replicas\"", stdout); + } + } + + [Fact] + public async Task TerminalPsCommand_WhenAppHostPredatesAppHostTerminals_StillListsResourceTerminals() + { + // An AppHost built before AppHost-owned terminals sends no AppHostTerminals at all. That is distinct + // from having none, and neither case may fault the listing. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var capturedOutput = new TestOutputTextWriter(outputHelper); + + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ListTerminalsResponse = new ListTerminalsResponse + { + Terminals = + [ + new TerminalSummary + { + ResourceName = "frontend", + DisplayName = "Frontend", + ConfiguredColumns = 120, + ConfiguredRows = 30, + IsHostReachable = true, + Replicas = + [ + new TerminalReplicaInfo + { + ReplicaIndex = 0, + Label = "frontend-0", + ConsumerUdsPath = "/tmp/frontend-0.host.sock", + IsAlive = true, + ProducerConnected = true, + RestartCount = 0, + } + ] + } + ], + AppHostTerminals = null, + }; + }, + options => + { + options.OutputTextWriter = capturedOutput; + options.DisableAnsi = true; + }); + + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal ps --format json"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var stdout = string.Join("\n", capturedOutput.Logs).Trim(); + var entries = JsonSerializer.Deserialize(stdout, TerminalPsJsonContext.Default.ListTerminalPsJsonEntry); + + Assert.NotNull(entries); + var entry = Assert.Single(entries); + Assert.Equal("resource", entry.Owner); + Assert.Equal("frontend", entry.ResourceName); + } + } + + [Fact] + public async Task TerminalPsCommand_WhenNoTerminalsOfEitherKind_ReportsEmpty() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var capturedOutput = new TestOutputTextWriter(outputHelper); + + var (provider, _) = CreateProviderWithBackchannel( + workspace, + backchannel => + { + backchannel.ListTerminalsResponse = new ListTerminalsResponse + { + Terminals = [], + AppHostTerminals = [], + }; + }, + options => + { + options.OutputTextWriter = capturedOutput; + options.DisableAnsi = true; + }); + + using (provider) + { + var command = provider.GetRequiredService(); + var result = command.Parse("terminal ps --format json"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal("[]", string.Join("\n", capturedOutput.Logs).Trim()); + } + } + private (ServiceProvider Provider, TestAppHostAuxiliaryBackchannel Backchannel) CreateProviderWithBackchannel( TemporaryWorkspace workspace, Action configure, diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs new file mode 100644 index 00000000000..e9d1e80c79e --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs @@ -0,0 +1,211 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; +using Aspire.Hosting.Testing; +using Aspire.Hosting.Utils; +using Aspire.Shared.TerminalHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +/// +/// Guards the projection of terminal-enabled resources into the same shape as AppHost-owned terminals. +/// Nothing here connects to a terminal host: the catalog reads only the application model, which is what +/// lets a listing be produced without a running workload. +/// +[Trait("Partition", "2")] +public class ResourceTerminalCatalogTests : IAsyncLifetime +{ + private readonly string _terminalDirectory = Directory.CreateTempSubdirectory("aspire-terminal-catalog-tests-").FullName; + + [Fact] + public void BuildIdRoundTripsThroughIsResourceTerminalId() + { + var id = ResourceTerminalCatalog.BuildId("shellbox", 2); + + Assert.Equal("resource:shellbox:2", id); + Assert.True(ResourceTerminalCatalog.IsResourceTerminalId(id)); + } + + [Fact] + public void AppHostTerminalIdIsNotMistakenForAResourceTerminal() + { + // AppHost terminal ids are opaque, so the prefix is the only thing separating the two id spaces. + var service = TestTerminalService.Create(); + var terminal = service.CreateTerminal(new TerminalLaunchOptions + { + Title = "Shell", + Command = new TerminalCommand("bash"), + }); + + Assert.False(ResourceTerminalCatalog.IsResourceTerminalId(terminal.Id)); + } + + [Fact] + public async Task ListReturnsNothingBeforeTerminalHostsAreMaterialized() + { + // TerminalAnnotation.TerminalHosts stays empty until BeforeStartEvent, so a catalog built against a + // model that hasn't started yet must report an empty list rather than throwing or inventing entries. + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + + await using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + + await using var catalog = new ResourceTerminalCatalog(model, NullLogger.Instance); + + Assert.Empty(catalog.List()); + } + + [Fact] + public async Task ListProjectsOneEntryPerReplica() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithAnnotation(new ReplicaAnnotation(3)).WithTerminal(); + + await using var catalog = await CreateCatalogAsync(builder); + + var entries = catalog.List().OrderBy(e => e.ReplicaIndex).ToList(); + + Assert.Collection(entries, + e => Assert.Equal("resource:myapp:0", e.Id), + e => Assert.Equal("resource:myapp:1", e.Id), + e => Assert.Equal("resource:myapp:2", e.Id)); + } + + [Fact] + public async Task SingleReplicaTitleIsNotQualifiedByReplicaIndex() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + + await using var catalog = await CreateCatalogAsync(builder); + + var entry = Assert.Single(catalog.List()); + + Assert.Equal("myapp", entry.Title); + } + + [Fact] + public async Task EveryReplicaIsQualifiedWhenResourceHasMoreThanOne() + { + // Replica 0 has to be qualified too. Deciding on the index alone would leave the first replica of a + // scaled-out resource labelled as if it were the only one. + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithAnnotation(new ReplicaAnnotation(2)).WithTerminal(); + + await using var catalog = await CreateCatalogAsync(builder); + + var titles = catalog.List().OrderBy(e => e.ReplicaIndex).Select(e => e.Title).ToList(); + + Assert.Equal(["myapp (replica 0)", "myapp (replica 1)"], titles); + } + + [Fact] + public async Task TryGetTerminalRejectsAnAppHostTerminalId() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + + await using var catalog = await CreateCatalogAsync(builder); + + Assert.False(catalog.TryGetTerminal("not-a-resource-terminal", out var terminal)); + Assert.Null(terminal); + } + + [Fact] + public async Task TryGetTerminalRejectsAnUnknownReplica() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + + await using var catalog = await CreateCatalogAsync(builder); + + Assert.False(catalog.TryGetTerminal(ResourceTerminalCatalog.BuildId("myapp", 7), out var terminal)); + Assert.Null(terminal); + } + + [Fact] + public async Task TryGetTerminalReturnsTheSameHandleForRepeatedLookups() + { + // Handles are cached so that repeated automation calls share one connection to the replica rather + // than adding a peer to its terminal host per call. + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + + await using var catalog = await CreateCatalogAsync(builder); + var id = ResourceTerminalCatalog.BuildId("myapp", 0); + + Assert.True(catalog.TryGetTerminal(id, out var first)); + Assert.True(catalog.TryGetTerminal(id, out var second)); + + Assert.Same(first, second); + } + + [Fact] + public async Task ResourceTerminalReportsResourceOwnership() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + + await using var catalog = await CreateCatalogAsync(builder); + + Assert.True(catalog.TryGetTerminal(ResourceTerminalCatalog.BuildId("myapp", 0), out var terminal)); + + Assert.Equal(TerminalOwner.Resource, terminal!.Owner); + Assert.Equal(TerminalPlacement.ResourceView, terminal.Placement); + Assert.Equal("myapp", terminal.Title); + } + + [Fact] + public async Task TryGetTerminalReturnsFalseAfterDisposal() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + + var catalog = await CreateCatalogAsync(builder); + await catalog.DisposeAsync(); + + Assert.False(catalog.TryGetTerminal(ResourceTerminalCatalog.BuildId("myapp", 0), out var terminal)); + Assert.Null(terminal); + } + + /// + /// Builds the application and publishes , which is the seam where + /// WithTerminal() materializes the per-replica terminal hosts the catalog reads. + /// + private static async Task CreateCatalogAsync(IDistributedApplicationTestingBuilder builder) + { + await using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model)); + + return new ResourceTerminalCatalog(model, NullLogger.Instance); + } + + private IDistributedApplicationTestingBuilder CreateBuilder() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + builder.Configuration[TerminalHostPaths.DirectoryOverrideConfigName] = _terminalDirectory; + return builder; + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + try + { + Directory.Delete(_terminalDirectory, recursive: true); + } + catch (DirectoryNotFoundException) + { + } + + return ValueTask.CompletedTask; + } +} diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 00645decbce..941a470c8fb 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -230,6 +230,65 @@ public async Task CreateTerminal_AfterDispose_Throws() Assert.Throws(() => CreateInteractionTerminal(service, "Shell")); } + [Fact] + public void ListAll_IncludesTerminalsRegardlessOfPlacement() + { + // A terminal driven only through automation is never displayed, so a listing keyed off the dock would + // miss it entirely. `aspire terminal ps` is meant to answer "what exists", not "what is on screen". + var service = TestTerminalService.Create(); + var dock = CreateDockTerminal(service, "Dock"); + var dialog = CreateInteractionTerminal(service, "Dialog"); + var hidden = service.CreateTerminal(new TerminalLaunchOptions + { + Title = "Automation", + Command = new TerminalCommand("bash"), + Placement = TerminalPlacement.None + }); + + var listings = service.ListAll(); + + Assert.Equal( + new[] { dock.Id, dialog.Id, hidden.Id }.OrderBy(id => id, StringComparer.Ordinal), + listings.Select(l => l.Id).OrderBy(id => id, StringComparer.Ordinal)); + Assert.All(listings, l => Assert.Equal(TerminalOwner.AppHost, l.Owner)); + Assert.All(listings, l => Assert.Null(l.ResourceName)); + } + + [Fact] + public void ListAll_CarriesPlacementAndTitle() + { + var service = TestTerminalService.Create(); + CreateDockTerminal(service, "Build output"); + + var listing = Assert.Single(service.ListAll()); + + Assert.Equal("Build output", listing.Title); + Assert.Equal(TerminalPlacement.Dock, listing.Placement); + } + + [Fact] + public async Task ListAll_DropsRemovedTerminals() + { + var service = TestTerminalService.Create(); + var terminal = CreateDockTerminal(service, "Dock"); + + await terminal.DisposeAsync().DefaultTimeout(); + + Assert.Empty(service.ListAll()); + } + + [Fact] + public void ListAll_WithoutAResourceCatalogReturnsOnlyAppHostTerminals() + { + // ResourceTerminals is left null when the AppHost has no model yet, which must degrade to "no resource + // terminals" rather than faulting the listing. + var service = TestTerminalService.Create(); + CreateDockTerminal(service, "Dock"); + + Assert.Null(service.ResourceTerminals); + Assert.Single(service.ListAll()); + } + private static IAspireTerminal CreateInteractionTerminal(TerminalService service, string title) => service.CreateTerminal(new TerminalLaunchOptions { From 038e51a243e46ce5480a2aaa31ec5238d27ebdba Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 19:07:47 +1000 Subject: [PATCH 028/106] Make AppHost automation of resource terminals work Automating a resource terminal from the AppHost failed at the presentation adapter with a native tcgetattr error: the client attached a console adapter, but the AppHost has no controlling terminal. Run the automation peer headless, which keeps the replicated screen automation reads while driving no console. A wait that times out surfaced Hex1b.Automation.Hex1bAutomationException rather than TimeoutException, because the automator wraps a failed step's exception to carry its step history. Walk the chain so a timeout still translates, and leave any other automation failure with its original type. Dialling a replica that is not running left no socket to connect to, and the resulting failure was swallowed by the pump, so the call sat until the connect timeout instead of reporting the real error immediately. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Terminals/Terminals.AppHost/AppHost.cs | 5 +- .../TerminalInteractionCommands.cs | 59 +++++++ .../Terminals/ResourceAspireTerminal.cs | 33 +++- .../Terminals/TerminalAutomation.cs | 28 +++- .../Terminals/ResourceAspireTerminalTests.cs | 156 ++++++++++++++++++ 5 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index 76e24289872..a0519772147 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -27,7 +27,10 @@ .WithAppHostShellCommand() // Drives an interactive console program from AppHost code: the terminal is shown in a dialog, but the guesses // are typed by the AppHost, which reads each reply back off the screen and bisects until it wins. - .WithNumberGuessCommand(); + .WithNumberGuessCommand() + // Automates a terminal the AppHost does NOT own: it joins `shell`'s existing terminal as an extra viewer and + // types into it, which is what shelling into a resource from AppHost code looks like. + .WithAutomateResourceTerminalCommand("shell"); // Long-running container that the "Shell into container" interaction command execs into. Aspire is not orchestrating // the exec — the AppHost shells out to `docker exec` — so the container needs a stable, predictable name. diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 717e9e1712e..c0fa7e3efee 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -363,6 +363,65 @@ await interactionService.PromptMessageBoxAsync( }); } + /// + /// Adds a command that automates a resource terminal — one attached to a resource by + /// WithTerminal(), whose PTY lives in a separate Aspire.TerminalHost process. + /// + /// + /// + /// This is the counterpart to , which automates a terminal the AppHost owns. + /// Here the AppHost is not the owner: it joins the resource's existing terminal as an additional viewer, types + /// into it, and reads the result back off the shared screen. Whatever a human has open in the dashboard stays + /// open and sees the same output, because the AppHost joins as a secondary and so never resizes the grid out + /// from under them. + /// + /// + /// The terminal is addressed by the resource name and replica index rather than by an opaque id, which is what + /// makes it nameable across the resource's terminal host being recycled. + /// + /// + [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")] + public static IResourceBuilder WithAutomateResourceTerminalCommand(this IResourceBuilder resource, string targetResourceName) where T : IResource + { + return resource.WithCommand( + "terminal-automate-resource", + "Type into this resource's terminal", + executeCommand: async commandContext => + { + var interactionService = commandContext.Services.GetRequiredService(); + var terminalService = commandContext.Services.GetRequiredService(); + + var terminalId = $"resource:{targetResourceName}:0"; + + if (!terminalService.TryGetTerminal(terminalId, out var terminal)) + { + return CommandResults.Failure($"No terminal is registered for '{terminalId}'."); + } + + // A marker rather than a fixed string: the shell echoes the command line before it echoes the + // output, so a fixed string would match the echo of the command itself and the wait would succeed + // before anything had actually run. + var marker = Guid.NewGuid().ToString("N")[..8]; + + try + { + await terminal.SendTextAsync($"echo apphost-was-here-{marker}\n", commandContext.CancellationToken); + await terminal.WaitForTextAsync($"apphost-was-here-{marker}\r\n", TimeSpan.FromSeconds(15), commandContext.CancellationToken); + } + catch (Exception ex) when (ex is TimeoutException or InvalidOperationException) + { + return CommandResults.Failure(ex.Message); + } + + await interactionService.PromptMessageBoxAsync( + "Resource terminal automation", + $"Typed into `{terminalId}` from the AppHost and read the reply back off the screen. Open that resource's terminal to see the line.", + cancellationToken: commandContext.CancellationToken); + + return CommandResults.Success(); + }); + } + /// /// Plays numberguess.cs to completion by bisecting, and returns the number found and how many guesses it took. /// diff --git a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs index 14c135c5af2..4f5565b0f0f 100644 --- a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs @@ -141,9 +141,23 @@ private async Task ConnectAsync() await Task.Yield(); var connected = new TaskCompletionSource<(int Width, int Height)>(TaskCreationOptions.RunContinuationsAsynchronously); + + // The handshake can be failed after the connect timeout has already given up on it — by the pump + // ending, or by a disconnect callback. Nothing would await it by then, and an unobserved faulted task + // surfaces on TaskScheduler.UnobservedTaskException, which is a process-wide event an AppHost may + // treat as fatal. Observing it here is harmless: an awaiter still sees the exception. + _ = connected.Task.ContinueWith( + static t => _ = t.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); Hex1bTerminal? terminal = null; terminal = Hex1bTerminal.CreateBuilder() + // The AppHost has no controlling terminal, so the client must not try to drive one. Headless + // discards output at the adapter and supplies no input; the screen buffer automation reads is + // still maintained, because it is built from the remote's output before presentation. + .WithHeadless() // An arbitrary opener. The handshake reports the producer's real grid and the terminal is resized // to match before the automator is handed out, so nothing ever reads this size. .WithDimensions(80, 24) @@ -182,7 +196,7 @@ private async Task ConnectAsync() _logger.LogDebug("Connecting AppHost automation to resource terminal {TerminalId} at '{ConsumerPath}'.", Id, _consumerUdsPath); - _runTask = RunClientAsync(terminal); + _runTask = RunClientAsync(terminal, connected); try { @@ -217,11 +231,17 @@ static void Resize(Hex1bTerminal? target, int width, int height) => target?.Resize(Math.Max(1, width), Math.Max(1, height)); } - private async Task RunClientAsync(Hex1bTerminal terminal) + private async Task RunClientAsync(Hex1bTerminal terminal, TaskCompletionSource<(int Width, int Height)> connected) { try { await terminal.RunAsync(_clientCts.Token).ConfigureAwait(false); + + // The pump returning without the handshake having completed means the transport closed before the + // terminal was usable. Nothing else would fail the handshake in that case, so it would otherwise + // sit until the connect timeout. + connected.TrySetException(new InvalidOperationException( + $"The connection to the terminal host for terminal '{Id}' closed before the terminal was ready.")); } catch (OperationCanceledException) { @@ -230,6 +250,15 @@ private async Task RunClientAsync(Hex1bTerminal terminal) } catch (Exception ex) { + // Surface the real transport error to a handshake still in flight. A replica that is not running + // leaves no socket to dial, which fails here immediately, and reporting it now is both faster and + // more specific than letting the connect timeout elapse. + if (connected.TrySetException(ex)) + { + _logger.LogDebug(ex, "Connecting the AppHost automation peer to resource terminal {TerminalId} failed.", Id); + return; + } + // Unexpected. The workload itself is unaffected — only this process's view of it is lost — so this // is a warning rather than an error, but it does mean subsequent automation calls read a dead screen. _logger.LogWarning(ex, "AppHost automation peer for resource terminal {TerminalId} ended unexpectedly.", Id); diff --git a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs index fccddcf1c39..61c2f82ba93 100644 --- a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs +++ b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs @@ -67,13 +67,37 @@ public static async Task WaitForTextAsync( { await wait.ConfigureAwait(false); } - catch (WaitUntilTimeoutException ex) + catch (Exception ex) when (FindWaitTimeout(ex) is { } timedOut) { // Translate so callers never have to reference Hex1b to handle a timeout. - throw new TimeoutException($"Terminal '{terminalId}' did not display the expected text within the timeout.", ex); + throw new TimeoutException($"Terminal '{terminalId}' did not display the expected text within the timeout.", timedOut); } } + /// + /// Finds the wait timeout inside an automation failure, or when the failure was + /// caused by something else. + /// + /// + /// The automator reports a failed step by wrapping the step's own exception in a + /// carrying the step history, so a timeout does not arrive as a bare + /// . The chain is walked rather than unwrapped one level because the + /// nesting depth is an implementation detail of the automator. Only a timeout is translated: any other + /// automation failure is a real fault and keeps its original type. + /// + private static WaitUntilTimeoutException? FindWaitTimeout(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is WaitUntilTimeoutException timedOut) + { + return timedOut; + } + } + + return null; + } + /// /// Reads the current screen, treating a terminal that has no automator yet as an empty screen. /// diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs new file mode 100644 index 00000000000..63b0056198d --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; +using Hex1b; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +/// +/// Drives against a real HMP1 socket rather than a fake, because the +/// behaviour worth guarding here only exists once a client actually attaches: the AppHost has no controlling +/// terminal, so a client that tries to drive one fails at the presentation adapter with a native +/// tcgetattr error that no in-memory substitute reproduces. +/// +/// +/// The stand-in for a replica's terminal host is an ordinary Hex1b terminal serving its own Unix domain +/// socket, which is the same shape the real terminal host exposes as its consumer socket. +/// +[Trait("Partition", "2")] +public class ResourceAspireTerminalTests : IAsyncLifetime +{ + private readonly string _socketDirectory = Directory.CreateTempSubdirectory("aspire-resource-terminal-tests-").FullName; + + [Fact] + public async Task AutomationTypesIntoAndReadsBackFromATerminalHost() + { + // A shell is the workload because the round trip being proven is a human-shaped one: type a command, + // have the workload run it, read the result off the replicated screen. + await using var host = await StartTerminalHostAsync("bash"); + + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance); + + // The shell echoes the command line before its output, so a fixed marker would match the echo of the + // input rather than the result. Splitting the literal across a quote means the typed line and the + // output line differ, and only the output line contains the marker. + await terminal.SendTextAsync("echo apphost-was\"\"-here\r").DefaultTimeout(); + + await terminal.WaitForTextAsync("apphost-was-here", TimeSpan.FromSeconds(30)).DefaultTimeout(); + + Assert.Contains("apphost-was-here", terminal.GetScreenText()); + } + + [Fact] + public async Task WaitForTextThrowsTimeoutWhenTheTextNeverAppears() + { + await using var host = await StartTerminalHostAsync("bash"); + + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance); + + // Establish the connection first so the timeout under test is the wait, not the handshake. + await terminal.SendTextAsync("\r").DefaultTimeout(); + + await Assert.ThrowsAsync( + () => terminal.WaitForTextAsync("text-the-workload-never-writes", TimeSpan.FromSeconds(1))).DefaultTimeout(); + } + + [Fact] + public async Task AutomationFailsWhenNoTerminalHostIsListening() + { + var missingSocket = Path.Combine(_socketDirectory, "not-listening.sock"); + + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", missingSocket, NullLogger.Instance); + + // A replica whose terminal host is gone must surface as a failed automation call rather than hanging + // until the connect timeout expires on every subsequent call. + await Assert.ThrowsAnyAsync(() => terminal.SendTextAsync("hello")).DefaultTimeout(); + } + + [Fact] + public async Task DisposeIsSafeWhenNothingEverConnected() + { + var terminal = new ResourceAspireTerminal("resource:test:0", "test", Path.Combine(_socketDirectory, "unused.sock"), NullLogger.Instance); + + // Listing terminals hands out handles that are never automated, so disposing an unconnected handle is + // the common case rather than an edge case. + await terminal.DisposeAsync().AsTask().DefaultTimeout(); + } + + /// + /// Stands up a terminal serving an HMP1 Unix domain socket, standing in for a replica's terminal host. + /// + private async Task StartTerminalHostAsync(string shell) + { + // Socket paths have a low length limit (around 104 bytes on macOS), so keep the file name short. + var socketPath = Path.Combine(_socketDirectory, $"{Guid.NewGuid().ToString("N")[..8]}.sock"); + + var terminal = Hex1bTerminal.CreateBuilder() + // The test host has no controlling terminal either, so it is headless for the same reason the + // AppHost's client is. + .WithHeadless() + .WithDimensions(120, 40) + .WithPtyProcess(shell) + .WithHmp1UdsServer(socketPath) + .Build(); + + var cts = new CancellationTokenSource(); + var runTask = terminal.RunAsync(cts.Token); + + // The socket file appears when the listener binds, which is what a client can dial. + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!File.Exists(socketPath) && DateTime.UtcNow < deadline) + { + if (runTask.IsFaulted) + { + await runTask; + } + + await Task.Delay(25); + } + + Assert.True(File.Exists(socketPath), $"The terminal host did not begin listening on '{socketPath}'."); + + return new TerminalHostStub(socketPath, terminal, cts, runTask); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + try + { + Directory.Delete(_socketDirectory, recursive: true); + } + catch (IOException) + { + // A socket file that the runtime still holds open is not worth failing a test over. + } + + return ValueTask.CompletedTask; + } + + private sealed class TerminalHostStub(string socketPath, Hex1bTerminal terminal, CancellationTokenSource cts, Task runTask) : IAsyncDisposable + { + public string SocketPath { get; } = socketPath; + + public async ValueTask DisposeAsync() + { + await cts.CancelAsync(); + + try + { + await runTask; + } + catch (OperationCanceledException) + { + } + + await terminal.DisposeAsync(); + cts.Dispose(); + } + } +} From 7ec292962a3ef6845ef7931d97e72f760d9ef587 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 5 Sep 2026 19:39:52 +1000 Subject: [PATCH 029/106] Fix CI failures in terminal listing and dashboard tests Terminal listing resolved TerminalService with GetRequiredService, so an AppHost that never registers it faulted the whole listing instead of returning the resource-terminal half of the answer, which is still correct. The resource terminal automation tests drive a POSIX shell, so skip them where that shell does not exist. The console logs view picker gained an "open in window" action, so assert over the complete menu rather than the two view toggles. TerminalView is rendered directly there, so register the localization it injects. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../AuxiliaryBackchannelRpcTarget.cs | 10 +++++- .../Pages/ConsoleLogsTerminalTests.cs | 33 +++++++++++++------ .../Terminals/ResourceAspireTerminalTests.cs | 4 +++ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 5cfa447b676..559626d49b8 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -636,9 +636,17 @@ public async Task ListTerminalsAsync(ListTerminalsRequest /// Projects the terminals the AppHost itself owns — dock tabs, terminals shown in an interaction dialog, /// and terminals driven only through automation — into the listing. /// + /// + /// The terminal service is resolved optionally. A listing already reports degraded entries rather than + /// failing when a resource's terminal host is unreachable, so failing the whole call because this one + /// contributor is absent would throw away the resource half of an answer that is still correct. + /// private AppHostTerminalSummary[] CollectAppHostTerminals() { - var terminalService = serviceProvider.GetRequiredService(); + if (serviceProvider.GetService() is not { } terminalService) + { + return []; + } return [.. terminalService.ListAll() .Where(t => t.Owner == Aspire.Hosting.Terminals.TerminalOwner.AppHost) diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index 2e01e5afaa8..91b67b8e97c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -122,17 +122,26 @@ public async Task TerminalResource_ViewPicker_MarksActiveViewAsChecked() cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name); cut.WaitForState(() => cut.FindComponents().Count > 0); - // The view-toggle items are the first two entries in the menu, added in - // Console-then-Terminal order (see UpdateMenuButtons). Both are modeled as - // checkable menu items so assistive technology can announce the selection; - // the live resource defaults to Terminal, so only the Terminal item is - // checked. + // In the Terminal view the menu is the two view-toggle items followed by the + // window action; the Console view additionally separates them with a divider. + // Both toggles are modeled as checkable menu items so assistive technology can + // announce the selection, and the live resource defaults to Terminal, so only + // the Terminal item is checked. cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Terminal); - Assert.Equal(2, instance.LogsMenuItemsForTest.Count); - Assert.Equal(MenuItemRole.MenuItemCheckbox, instance.LogsMenuItemsForTest[0].Role); - Assert.Equal(MenuItemRole.MenuItemCheckbox, instance.LogsMenuItemsForTest[1].Role); - Assert.False(instance.LogsMenuItemsForTest[0].Checked); - Assert.True(instance.LogsMenuItemsForTest[1].Checked); + Assert.Collection( + instance.LogsMenuItemsForTest, + item => + { + Assert.Equal(MenuItemRole.MenuItemCheckbox, item.Role); + Assert.False(item.Checked); + }, + item => + { + Assert.Equal(MenuItemRole.MenuItemCheckbox, item.Role); + Assert.True(item.Checked); + }, + // The window action is not a view toggle, so it carries no checkable role. + item => Assert.Null(item.Role)); // Switching to Console moves the checked state to the Console item. await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Console))); @@ -585,6 +594,10 @@ public async Task TerminalResource_ViewToggle_RenderedDisplayStylesMatchActiveVi [Fact] public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitialization() { + // This test renders TerminalView on its own rather than through the page, so the localization the + // component injects has to be registered here; the page-level tests get it from FluentUI setup. + Services.AddLocalization(); + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); var initTerminal = module.Setup("initTerminal", _ => true); var reconnectTerminal = module.Setup("reconnectTerminal", _ => true); diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs index 63b0056198d..76b220b1c56 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs @@ -28,6 +28,8 @@ public class ResourceAspireTerminalTests : IAsyncLifetime [Fact] public async Task AutomationTypesIntoAndReadsBackFromATerminalHost() { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); + // A shell is the workload because the round trip being proven is a human-shaped one: type a command, // have the workload run it, read the result off the replicated screen. await using var host = await StartTerminalHostAsync("bash"); @@ -47,6 +49,8 @@ public async Task AutomationTypesIntoAndReadsBackFromATerminalHost() [Fact] public async Task WaitForTextThrowsTimeoutWhenTheTextNeverAppears() { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); + await using var host = await StartTerminalHostAsync("bash"); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance); From af05c2c5c06ebefeb178bc6e6961bd6b9ba868cd Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 6 Sep 2026 12:28:33 +1000 Subject: [PATCH 030/106] Fix terminal lifecycle races and dashboard recovery Synchronize terminal registry updates with snapshots and shutdown, drain individual viewer transports before release, and recover resource automation peers and disposed handles. Restore dashboard terminal watches, isolate historical runs, marshal UI updates, and reconcile selections during initialization retries. Add focused lifecycle, transport, recovery, and component regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.cs | 33 +- .../Components/Layout/MainLayout.razor | 26 +- .../Components/Layout/TerminalDock.razor | 16 +- .../Components/Layout/TerminalDock.razor.cs | 125 ++++--- .../Components/Pages/TerminalWindow.razor.cs | 37 +- .../ServiceClient/DashboardClient.cs | 66 +++- .../Terminals/Hex1bAspireTerminal.cs | 19 +- .../Terminals/ResourceAspireTerminal.cs | 348 +++++++++++------- .../Terminals/ResourceTerminalCatalog.cs | 73 +++- .../Terminals/TerminalClientStream.cs | 168 +++++++++ .../Terminals/TerminalService.cs | 100 +++-- .../Controls/TerminalViewTests.cs | 126 +++++++ .../Layout/MainLayoutTerminalTests.cs | 96 +++++ .../Layout/MainLayoutTests.cs | 5 +- .../Layout/TerminalDockTests.cs | 112 ++++++ .../Pages/ConsoleLogsTerminalTests.cs | 7 +- .../Pages/TerminalWindowTests.cs | 38 ++ .../Shared/TerminalSetupHelpers.cs | 60 +++ .../Model/DashboardClientTests.cs | 159 +++++++- .../Terminals/Hex1bAspireTerminalTests.cs | 115 ++++++ .../Terminals/ResourceAspireTerminalTests.cs | 130 ++++--- .../Terminals/ResourceTerminalCatalogTests.cs | 86 ++++- .../Terminals/TerminalClientStreamTests.cs | 53 +++ .../Terminals/TerminalServiceTests.cs | 122 +++++- .../Utils/GatedTerminalWriteStream.cs | 62 ++++ .../Utils/TestAppHostTerminalViewer.cs | 112 ++++++ .../Utils/TestDuplexStream.cs | 61 +++ .../Utils/TestResourceTerminalHost.cs | 74 ++++ tests/Shared/TestDashboardClient.cs | 33 +- 29 files changed, 2098 insertions(+), 364 deletions(-) create mode 100644 src/Aspire.Hosting/Terminals/TerminalClientStream.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs create mode 100644 tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Terminals/TerminalClientStreamTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Utils/GatedTerminalWriteStream.cs create mode 100644 tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs create mode 100644 tests/Aspire.Hosting.Tests/Utils/TestDuplexStream.cs create mode 100644 tests/Aspire.Hosting.Tests/Utils/TestResourceTerminalHost.cs diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 2e23930b8d1..5a377842d0d 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -176,7 +176,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } var currentEndpoint = ResolveEndpoint(); - if (!string.Equals(currentEndpoint, _connectedEndpoint, StringComparison.Ordinal)) + while (!string.Equals(currentEndpoint, _connectedEndpoint, StringComparison.Ordinal)) { try { @@ -191,17 +191,19 @@ protected override async Task OnAfterRenderAsync(bool firstRender) return; } - _connectedEndpoint = currentEndpoint; + _connectedEndpoint = _terminalId != 0 ? currentEndpoint : null; + if (_terminalId == 0) + { + break; + } + + currentEndpoint = ResolveEndpoint(); } return; } - // If a re-render fires while the very first initTerminal call is still - // in flight, do nothing here. Once that call completes the firstRender - // path will set _connectedEndpoint and any future rebind needed will be - // caught on the next render after that. Without this guard the rebind - // branch below would re-enter initialization and stack a second xterm - // onto the same container — see the comment on _initStarted. + // Initialization and its retries can yield while parameters change. Let the active operation reconcile + // the latest endpoint when it resumes rather than creating a second xterm in the same container. if (_initStarted && _terminalId == 0) { return; @@ -218,7 +220,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // the SignalR circuit and tear down the entire dashboard tab. Failing // to switch terminals is a localized, recoverable issue (the JS side // will keep retrying or the user can reload); a circuit failure is not. - if (!string.Equals(endpoint, _connectedEndpoint, StringComparison.Ordinal)) + while (!string.Equals(endpoint, _connectedEndpoint, StringComparison.Ordinal)) { try { @@ -236,7 +238,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // side keeps retrying so a transient hiccup heals itself. return; } - _connectedEndpoint = endpoint; + _connectedEndpoint = _terminalId != 0 ? endpoint : null; + if (_terminalId == 0) + { + break; + } + + // OnAfterRenderAsync completion does not cause a render. Apply selections that arrived while JS + // initialization was pending now, including removing the endpoint entirely. + endpoint = ResolveEndpoint(); } } @@ -261,6 +271,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) private async Task InitializeTerminalAsync(string endpoint) { + // Retries need the same reentrancy guard as the first render while JS creates the terminal. + _initStarted = true; try { _jsModule = await JS.InvokeAsync( @@ -328,6 +340,7 @@ public async Task ReconnectAsync(string? newEndpoint) { await _jsModule.InvokeVoidAsync("disposeTerminal", _terminalId); _terminalId = 0; + _initStarted = false; _connectedGeneration = -1; return; } diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index 659ebbb19d4..f7c0f402b32 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -52,14 +52,17 @@ } - - @* WindowConsole only ships at Size20 in this Fluent version, so it is scaled to 24px to line up with - the rest of the header cluster. The Size24 alternatives (WindowDevTools, Code) read as "developer - tools" rather than "terminal". *@ - - + @if (!_isSwitchingRuns && !DashboardClient.IsReadOnly) + { + + @* WindowConsole only ships at Size20 in this Fluent version, so it is scaled to 24px to line up with + the rest of the header cluster. The Size24 alternatives (WindowDevTools, Code) read as "developer + tools" rather than "terminal". *@ + + + } - + @if (!_isSwitchingRuns && !DashboardClient.IsReadOnly) + { + @* Removing the dock cancels its selected-run subscription and shortcut. Returning to the live run starts a + new subscription rather than reusing the stream chosen for a previous selection. *@ + + }
@Loc[nameof(Layout.MainLayoutUnhandledErrorMessage)] diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index bcc2a7bc254..798e37b4892 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -15,13 +15,15 @@ @key="terminal.TerminalId" @onclick="@(() => Activate(terminal.TerminalId))"> @terminal.Title - - - + + + + +
} @@ -104,8 +105,13 @@ public Task OnPageKeyDownAsync(AspireKeyboardShortcut shortcut) /// suppressed whenever focus is in a terminal or any other text input, because it types ~ there, so the /// dock needs an affordance that works regardless of where focus happens to be. /// - public Task ToggleAsync() + public Task ToggleAsync() => InvokeAsync(() => { + if (_disposed) + { + return; + } + if (_isVisible) { Hide(); @@ -115,8 +121,7 @@ public Task ToggleAsync() Show(); } - return Task.CompletedTask; - } + }); private void Show() { @@ -129,10 +134,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { // Wiring happens on the render that first materialises the dock element, which is not the component's first // render — the markup is suppressed until the dock has been opened at least once. - if (_hasBeenOpened && _jsModule is null) + if (!_disposed && _hasBeenOpened && _jsModule is null) { _selfRef = DotNetObjectReference.Create(this); _jsModule = await JS.InvokeAsync("import", "./Components/Layout/TerminalDock.razor.js").ConfigureAwait(true); + if (_disposed) + { + await _jsModule.DisposeAsync().ConfigureAwait(true); + return; + } await _jsModule.InvokeVoidAsync("registerResizeHandle", _dockElement, _selfRef).ConfigureAwait(true); } } @@ -141,12 +151,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) /// Called from JS while the user drags the dock's top edge. ///
[JSInvokable] - public Task SetHeightAsync(int heightPx) + public Task SetHeightAsync(int heightPx) => InvokeAsync(() => { + if (_disposed) + { + return; + } + _heightPx = Math.Clamp(heightPx, 120, 1200); StateHasChanged(); - return Task.CompletedTask; - } + }); private void Hide() { @@ -249,15 +263,13 @@ private async Task ReturnToDockAsync(string terminalId) /// Reattaches a terminal whose window the user closed. Remounting TerminalView opens a fresh socket and /// the HMP1 state sync replays the screen, so nothing is lost by having had no viewer in between. ///
- private Task OnDetachedWindowClosedAsync(string terminalId) + private Task OnDetachedWindowClosedAsync(string terminalId) => InvokeAsync(() => { - if (_detachedTerminalIds.Remove(terminalId)) + if (!_disposed && _detachedTerminalIds.Remove(terminalId)) { - return InvokeAsync(StateHasChanged); + StateHasChanged(); } - - return Task.CompletedTask; - } + }); /// /// Shows the panel that stands in for a terminal when there is nothing to show, or nothing selected. @@ -291,23 +303,45 @@ private async Task WatchTerminalsAsync(CancellationToken cancellationToken) { await foreach (var update in DashboardClient.SubscribeTerminalsAsync(cancellationToken).ConfigureAwait(false)) { - if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Snapshot) - { - _terminals.Clear(); - _terminals.AddRange(update.Snapshot.Terminals); - _activeTerminalId ??= _terminals.FirstOrDefault()?.TerminalId; - } - else if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Change) + // The stream runs on a worker. Dispatch the entire update, not just the render: Razor and click + // handlers enumerate these collections and must never race a snapshot or removal. + await InvokeAsync(async () => { - if (Apply(update.Change.ChangeType, update.Change.Terminal) is { } endedTerminalId) + if (_disposed) { - // The terminal is gone, so its window is showing a dead grid. Close it here rather than - // leaving the user to notice and dismiss it. - await InvokeAsync(() => CloseDetachedWindowAsync(endedTerminalId)).ConfigureAwait(false); + return; + } + + List endedTerminalIds = []; + if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Snapshot) + { + _terminals.Clear(); + _terminals.AddRange(update.Snapshot.Terminals); + if (!_terminals.Any(t => t.TerminalId == _activeTerminalId)) + { + _activeTerminalId = _terminals.FirstOrDefault()?.TerminalId; + } + + // Recovery snapshots replace all prior state, including terminals removed while offline. + endedTerminalIds.AddRange(_detachedTerminalIds.Where(id => !_terminals.Any(t => t.TerminalId == id))); + _detachedTerminalIds.ExceptWith(endedTerminalIds); + } + else if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Change && + Apply(update.Change.ChangeType, update.Change.Terminal) is { } endedTerminalId) + { + endedTerminalIds.Add(endedTerminalId); } - } - await InvokeAsync(StateHasChanged).ConfigureAwait(false); + StateHasChanged(); + foreach (var terminalId in endedTerminalIds) + { + if (_disposed) + { + return; + } + await CloseDetachedWindowAsync(terminalId).ConfigureAwait(true); + } + }).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -389,13 +423,34 @@ private static string BuildEndpoint(string terminalId) public async ValueTask DisposeAsync() { + if (_disposed) + { + return; + } + + _disposed = true; ShortcutManager.RemoveGlobalKeydownListener(this); + // Stop updates before releasing browser-side state. A queued dispatcher callback observes _disposed and + // does nothing, and cancellation interrupts either the active RPC or its recovery wait. + await _cts.CancelAsync().ConfigureAwait(true); + if (_watchTask is { } watchTask) + { + try + { + await watchTask.ConfigureAwait(true); + } + catch (OperationCanceledException) + { + // Expected when stopping the watch. + } + } + if (_jsModule is { } module) { try { - await module.DisposeAsync().ConfigureAwait(false); + await module.DisposeAsync().ConfigureAwait(true); } catch (JSDisconnectedException) { @@ -409,21 +464,7 @@ public async ValueTask DisposeAsync() { // Leaves any detached windows open: they are viewers of AppHost-owned terminals and have no reason to // die because this circuit went away. - await launcher.DisposeAsync().ConfigureAwait(false); - } - - await _cts.CancelAsync().ConfigureAwait(false); - - if (_watchTask is { } watchTask) - { - try - { - await watchTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected. We cancelled _cts immediately above, so the watch task ends by design. - } + await launcher.DisposeAsync().ConfigureAwait(true); } _cts.Dispose(); diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs index 3c007ae635e..b71b6bba6ff 100644 --- a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs @@ -28,6 +28,7 @@ public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable private string? _endpoint; private string _title = string.Empty; private bool _ended; + private bool _disposed; private Task? _watchTask; /// @@ -83,17 +84,25 @@ private async Task WatchTerminalsAsync(string terminalId, CancellationToken canc { await foreach (var update in DashboardClient.SubscribeTerminalsAsync(cancellationToken).ConfigureAwait(false)) { - var changed = update.KindCase switch + await InvokeAsync(() => { - WatchTerminalsUpdate.KindOneofCase.Snapshot => ApplySnapshot(terminalId, update.Snapshot), - WatchTerminalsUpdate.KindOneofCase.Change => ApplyChange(terminalId, update.Change), - _ => false - }; - - if (changed) - { - await InvokeAsync(StateHasChanged).ConfigureAwait(false); - } + if (_disposed) + { + return; + } + + var changed = update.KindCase switch + { + WatchTerminalsUpdate.KindOneofCase.Snapshot => ApplySnapshot(terminalId, update.Snapshot), + WatchTerminalsUpdate.KindOneofCase.Change => ApplyChange(terminalId, update.Change), + _ => false + }; + + if (changed) + { + StateHasChanged(); + } + }).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -102,7 +111,7 @@ private async Task WatchTerminalsAsync(string terminalId, CancellationToken canc } catch (Exception ex) { - // A broken stream only costs the window its title updates; the terminal itself is on a separate socket. + // Transport failures are retried by the client. Log unexpected failures without failing the circuit. Logger.LogWarning(ex, "Terminal window watch stream ended unexpectedly."); } } @@ -156,6 +165,12 @@ private bool MarkEnded() /// public async ValueTask DisposeAsync() { + if (_disposed) + { + return; + } + + _disposed = true; await _cts.CancelAsync().ConfigureAwait(false); if (_watchTask is { } watchTask) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index f5558824876..bc811178831 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -1154,13 +1154,69 @@ public async IAsyncEnumerable SubscribeTerminalsAsync([Enu { EnsureInitialized(); - // Unlike resources and interactions, this is not fanned out through a local channel. The dock is a single - // consumer per browser circuit and the update rate is tiny, so a direct server stream per subscriber is both - // simpler and avoids having to replay snapshot state for late subscribers. using var cts = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); - using var call = _client!.WatchTerminals(new WatchTerminalsRequest(), headers: _headers, cancellationToken: cts.Token); + var errorCount = 0; + + // Each subscriber owns its RPC, including recovery. Reopening it supplies a fresh snapshot, so neither the + // dock nor a detached window has to reconstruct changes missed during a disconnect. + while (true) + { + await WhenConnected.WaitAsync(cts.Token).ConfigureAwait(false); - await foreach (var update in call.ResponseStream.ReadAllAsync(cts.Token).ConfigureAwait(false)) + var unsupported = false; + var updates = WatchTerminalsCoreAsync(cts.Token).GetAsyncEnumerator(cts.Token); + await using (updates.ConfigureAwait(false)) + { + while (true) + { + bool hasNext; + try + { + hasNext = await updates.MoveNextAsync().ConfigureAwait(false); + } + catch (RpcException ex) + { + cts.Token.ThrowIfCancellationRequested(); + unsupported = ex.StatusCode == StatusCode.Unimplemented; + if (unsupported) + { + // Older AppHosts can serve the dashboard without implementing the terminal RPC. + _logger.LogWarning("Server does not support terminals."); + } + else + { + _logger.LogWarning(ex, "Terminal watch stream disconnected. Retrying."); + } + break; + } + + if (!hasNext) + { + break; + } + + errorCount = 0; + yield return updates.Current; + } + } + + if (unsupported) + { + yield break; + } + + // Normal stream completion also needs recovery. Dispose the old RPC before backing off, and cancel + // both connection waits and backoff when the subscriber goes away or the dashboard client is disposed. + var delay = TimeSpan.FromSeconds(Math.Min(Math.Pow(2, errorCount), 15)); + errorCount = Math.Min(errorCount + 1, 4); + await Task.Delay(delay, cts.Token).ConfigureAwait(false); + } + } + + private async IAsyncEnumerable WatchTerminalsCoreAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + using var call = _client!.WatchTerminals(new WatchTerminalsRequest(), headers: _headers, cancellationToken: cancellationToken); + await foreach (var update in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { yield return update; } diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index 26b2150630b..cd31f2df45d 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -96,26 +96,25 @@ public void Retitle(string title) /// Attaches a viewer, starting the workload if this is the first thing to need it. /// /// - /// A task that completes once the terminal has fully torn down, or once - /// is signalled. Callers keep their transport open until it completes. + /// A task that completes once this viewer disconnects or the terminal ends, and all operations on the + /// caller's transport have finished. Callers keep their transport open until it completes. /// - public Task AttachAsync(Stream clientStream, CancellationToken cancellationToken) + public async Task AttachAsync(Stream clientStream, CancellationToken cancellationToken) { EnsureStarted(); - if (!_clients.Writer.TryWrite(clientStream)) + // Hex1b owns and disposes the wrapper, never the gRPC stream. Closing it cancels only this viewer's + // I/O and waits for outstanding accesses, even when Hex1b's other pump is still winding down. + var attachment = new TerminalClientStream(clientStream); + await using var _ = attachment.ConfigureAwait(false); + if (!_clients.Writer.TryWrite(attachment)) { throw new InvalidOperationException($"Terminal '{Id}' is no longer accepting clients."); } - return WaitForSessionEndAsync(cancellationToken); - } - - private async Task WaitForSessionEndAsync(CancellationToken cancellationToken) - { var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled); - await Task.WhenAny(_sessionEnded.Task, cancelled.Task).ConfigureAwait(false); + await Task.WhenAny(_sessionEnded.Task, attachment.Released, cancelled.Task).ConfigureAwait(false); } /// diff --git a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs index 4f5565b0f0f..c2a20b746be 100644 --- a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs @@ -45,12 +45,11 @@ internal sealed class ResourceAspireTerminal : IAspireTerminal private readonly string _consumerUdsPath; private readonly ILogger _logger; - private readonly CancellationTokenSource _clientCts = new(); + private readonly CancellationTokenSource _disposalCts = new(); private readonly object _gate = new(); - private Task? _connectTask; - private Task? _runTask; - private Hex1bTerminalAutomator? _automator; + private ConnectionAttempt? _connection; + private Task? _disposeTask; private bool _disposed; public ResourceAspireTerminal(string id, string title, string consumerUdsPath, ILogger logger) @@ -69,6 +68,17 @@ public ResourceAspireTerminal(string id, string title, string consumerUdsPath, I public TerminalPlacement Placement => TerminalPlacement.ResourceView; + internal bool IsDisposed + { + get + { + lock (_gate) + { + return _disposed; + } + } + } + /// /// The workload is started by the resource it belongs to, so there is nothing for the AppHost to start. /// @@ -111,206 +121,260 @@ public string GetScreenText() Hex1bTerminalAutomator? automator; lock (_gate) { - automator = _automator; + automator = _connection?.Automator; } return TerminalAutomation.GetScreenText(automator); } /// - /// Connects to the replica's terminal host on first use, and returns the same connection thereafter. + /// Shares a live connection, replacing failed or disconnected peers on a later automation call. /// - private Task EnsureConnectedAsync(CancellationToken cancellationToken) + private async Task EnsureConnectedAsync(CancellationToken cancellationToken) { - Task connectTask; - lock (_gate) + while (true) { - ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); - // Cache the task rather than the result so concurrent callers share one connection attempt, and a - // failed attempt is not retried behind the back of the caller that observed the failure. - connectTask = _connectTask ??= ConnectAsync(); - } + ConnectionAttempt connection; + bool disconnected; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_connection is null) + { + _connection = new ConnectionAttempt(_disposalCts.Token); + _connection.Completion = RunConnectionAsync(_connection); + } - return connectTask.WaitAsync(cancellationToken); + connection = _connection; + disconnected = connection.Disconnected; + } + + if (!disconnected) + { + // A caller's cancellation only abandons its wait, not the connection shared by other callers. + // Never replay an automation command: a failed attempt is surfaced to its original caller. + return await connection.Ready.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + // Finish releasing the old peer before opening another. Keeping it registered until cleanup ends + // also lets DisposeAsync await every peer, including a connection that failed during startup. + await connection.Completion.WaitAsync(cancellationToken).ConfigureAwait(false); + lock (_gate) + { + if (ReferenceEquals(_connection, connection)) + { + _connection = null; + } + } + } } - private async Task ConnectAsync() + private async Task RunConnectionAsync(ConnectionAttempt connection) { - // Never run the connect inline under _gate. + // Building and running Hex1b must not happen inline under _gate. await Task.Yield(); + var cancellationToken = connection.Cancellation.Token; var connected = new TaskCompletionSource<(int Width, int Height)>(TaskCreationOptions.RunContinuationsAsynchronously); - - // The handshake can be failed after the connect timeout has already given up on it — by the pump - // ending, or by a disconnect callback. Nothing would await it by then, and an unobserved faulted task - // surfaces on TaskScheduler.UnobservedTaskException, which is a process-wide event an AppHost may - // treat as fatal. Observing it here is harmless: an awaiter still sees the exception. - _ = connected.Task.ContinueWith( - static t => _ = t.Exception, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); Hex1bTerminal? terminal = null; + Task? runTask = null; + Exception? failure = null; + var expectedCancellation = false; - terminal = Hex1bTerminal.CreateBuilder() - // The AppHost has no controlling terminal, so the client must not try to drive one. Headless - // discards output at the adapter and supplies no input; the screen buffer automation reads is - // still maintained, because it is built from the remote's output before presentation. - .WithHeadless() - // An arbitrary opener. The handshake reports the producer's real grid and the terminal is resized - // to match before the automator is handed out, so nothing ever reads this size. - .WithDimensions(80, 24) - .WithHmp1UdsClient(_consumerUdsPath, options => - { - // Named so a human running `aspire terminal ps --verbose` can tell an automation peer apart - // from a dashboard tab or an attached CLI. - options.DisplayName = $"apphost-automation:{Id}"; - options.DefaultRole = Hmp1Role.Secondary; - - options.OnConnected = (e, _) => - { - Resize(terminal, e.Width, e.Height); - connected.TrySetResult((e.Width, e.Height)); - return Task.CompletedTask; - }; - - // Follow the producer's grid when another peer resizes it, so a screen read after a human - // resizes their dashboard tab is not silently clipped to the old dimensions. - options.OnRemoteResized = (e, _) => + try + { + cancellationToken.ThrowIfCancellationRequested(); + terminal = Hex1bTerminal.CreateBuilder() + // The AppHost has no controlling terminal. Headless suppresses local console I/O but still + // maintains the replicated screen used by automation. + .WithHeadless() + .WithDimensions(80, 24) + .WithHmp1UdsClient(_consumerUdsPath, options => { - Resize(terminal, e.Width, e.Height); - return Task.CompletedTask; - }; + options.DisplayName = $"apphost-automation:{Id}"; + options.DefaultRole = Hmp1Role.Secondary; + + options.OnConnected = (e, _) => + { + Resize(terminal, e.Width, e.Height); + connected.TrySetResult((e.Width, e.Height)); + return Task.CompletedTask; + }; + + options.OnRemoteResized = (e, _) => + { + Resize(terminal, e.Width, e.Height); + return Task.CompletedTask; + }; + + options.OnDisconnected = _ => + { + MarkDisconnected(connection); + connection.Cancellation.Cancel(); + return Task.CompletedTask; + }; + }) + .Build(); + + _logger.LogDebug("Connecting AppHost automation to resource terminal {TerminalId} at '{ConsumerPath}'.", Id, _consumerUdsPath); + cancellationToken.ThrowIfCancellationRequested(); + runTask = terminal.RunAsync(cancellationToken); + + // A missing socket faults the pump before the handshake. Observe either result so an immediate + // transport failure is not reported as a ten-second handshake timeout. + var completed = await Task.WhenAny(connected.Task, runTask).WaitAsync(s_connectTimeout, cancellationToken).ConfigureAwait(false); + if (completed == runTask) + { + await runTask.ConfigureAwait(false); + throw new InvalidOperationException( + $"The connection to the terminal host for terminal '{Id}' closed before the terminal was ready."); + } - options.OnDisconnected = _ => + var (width, height) = await connected.Task.ConfigureAwait(false); + var automator = new Hex1bTerminalAutomator(terminal, TerminalAutomation.DefaultTimeout); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + if (connection.Disconnected) { - // The terminal host went away. Fail a handshake still in flight rather than letting it - // sit until the connect timeout. - connected.TrySetException(new InvalidOperationException( - $"The terminal host for terminal '{Id}' disconnected before the connection was established.")); - return Task.CompletedTask; - }; - }) - .Build(); + throw new InvalidOperationException($"The terminal host for terminal '{Id}' disconnected during initialization."); + } - _logger.LogDebug("Connecting AppHost automation to resource terminal {TerminalId} at '{ConsumerPath}'.", Id, _consumerUdsPath); - - _runTask = RunClientAsync(terminal, connected); + connection.Automator = automator; + connection.Ready.TrySetResult(new TerminalConnection(terminal, automator)); + } - try - { - var (width, height) = await connected.Task.WaitAsync(s_connectTimeout).ConfigureAwait(false); _logger.LogDebug("Connected to resource terminal {TerminalId} ({Width}x{Height}).", Id, width, height); + await runTask.ConfigureAwait(false); } catch (Exception ex) { - // The pump owns the terminal once RunClientAsync is running, so tear it down through the same - // path rather than disposing the terminal here and racing the pump. - _clientCts.Cancel(); - - if (ex is TimeoutException) + failure = ex; + expectedCancellation = ex is OperationCanceledException && cancellationToken.IsCancellationRequested; + } + finally + { + MarkDisconnected(connection); + await connection.Cancellation.CancelAsync().ConfigureAwait(false); + if (runTask is not null) { - throw new InvalidOperationException( - $"Timed out connecting to the terminal host for terminal '{Id}' at '{_consumerUdsPath}'. The resource replica may not be running.", ex); + try + { + await runTask.ConfigureAwait(false); + } + catch (Exception ex) + { + // Preserve the handshake failure if one already occurred; otherwise report the pump's + // failure below. In both cases the task is observed before disposing its terminal. + failure ??= ex; + } } - throw; - } - - var automator = new Hex1bTerminalAutomator(terminal, TerminalAutomation.DefaultTimeout); + if (terminal is not null) + { + try + { + await terminal.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Disposing the automation peer for resource terminal {TerminalId} failed unexpectedly.", Id); + failure ??= ex; + } + } - lock (_gate) - { - _automator = automator; + connection.Cancellation.Dispose(); } - return new TerminalConnection(terminal, automator); - - static void Resize(Hex1bTerminal? target, int width, int height) - => target?.Resize(Math.Max(1, width), Math.Max(1, height)); - } - - private async Task RunClientAsync(Hex1bTerminal terminal, TaskCompletionSource<(int Width, int Height)> connected) - { - try - { - await terminal.RunAsync(_clientCts.Token).ConfigureAwait(false); - - // The pump returning without the handshake having completed means the transport closed before the - // terminal was usable. Nothing else would fail the handshake in that case, so it would otherwise - // sit until the connect timeout. - connected.TrySetException(new InvalidOperationException( - $"The connection to the terminal host for terminal '{Id}' closed before the terminal was ready.")); - } - catch (OperationCanceledException) + if (expectedCancellation) { - // Expected: the handle was disposed, or the connect attempt was abandoned. - _logger.LogDebug("AppHost automation peer for resource terminal {TerminalId} was cancelled.", Id); + _logger.LogDebug("AppHost automation peer for resource terminal {TerminalId} stopped after disposal or a terminal host disconnect.", Id); + connection.Ready.TrySetCanceled(cancellationToken); } - catch (Exception ex) + else if (failure is not null) { - // Surface the real transport error to a handshake still in flight. A replica that is not running - // leaves no socket to dial, which fails here immediately, and reporting it now is both faster and - // more specific than letting the connect timeout elapse. - if (connected.TrySetException(ex)) + if (failure is TimeoutException) { - _logger.LogDebug(ex, "Connecting the AppHost automation peer to resource terminal {TerminalId} failed.", Id); - return; + failure = new InvalidOperationException( + $"Timed out connecting to the terminal host for terminal '{Id}' at '{_consumerUdsPath}'. The resource replica may not be running.", failure); } - // Unexpected. The workload itself is unaffected — only this process's view of it is lost — so this - // is a warning rather than an error, but it does mean subsequent automation calls read a dead screen. - _logger.LogWarning(ex, "AppHost automation peer for resource terminal {TerminalId} ended unexpectedly.", Id); - } - finally - { - try + if (connection.Ready.TrySetException(failure)) { - await terminal.DisposeAsync().ConfigureAwait(false); + _logger.LogDebug(failure, "Connecting the AppHost automation peer to resource terminal {TerminalId} failed; a later call can retry.", Id); } - catch (Exception ex) + else { - _logger.LogDebug(ex, "Disposing the automation peer for resource terminal {TerminalId} failed.", Id); + _logger.LogWarning(failure, "AppHost automation peer for resource terminal {TerminalId} ended unexpectedly; a later call can reconnect.", Id); } } + + static void Resize(Hex1bTerminal? target, int width, int height) + => target?.Resize(Math.Max(1, width), Math.Max(1, height)); + } + + private void MarkDisconnected(ConnectionAttempt connection) + { + lock (_gate) + { + connection.Disconnected = true; + connection.Automator = null; + } } /// /// Disconnects the AppHost's automation peer. The resource's workload is unaffected. /// - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - Task? runTask; lock (_gate) { - if (_disposed) + _disposed = true; + if (_connection is { } connection) { - return; + connection.Automator = null; } - _disposed = true; - runTask = _runTask; - _automator = null; + return new ValueTask(_disposeTask ??= DisposeCoreAsync(_connection)); + } + } + + private async Task DisposeCoreAsync(ConnectionAttempt? connection) + { + await Task.Yield(); + await _disposalCts.CancelAsync().ConfigureAwait(false); + + if (connection is not null) + { + await connection.Completion.ConfigureAwait(false); } - await _clientCts.CancelAsync().ConfigureAwait(false); + _disposalCts.Dispose(); + } - if (runTask is not null) + private sealed class ConnectionAttempt + { + public ConnectionAttempt(CancellationToken disposalToken) { - try - { - await runTask.ConfigureAwait(false); - } - catch (Exception ex) - { - // RunClientAsync already logs and swallows; this only guards against a fault escaping the pump - // itself, which must not turn disposal into a throwing operation. - _logger.LogDebug(ex, "The automation peer for resource terminal {TerminalId} faulted while disconnecting.", Id); - } + Cancellation = CancellationTokenSource.CreateLinkedTokenSource(disposalToken); + // Every caller may abandon its wait before connection fails. Observe that failure even when + // nobody remains to await Ready; future calls still replace the failed attempt. + _ = Ready.Task.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); } - _clientCts.Dispose(); + public CancellationTokenSource Cancellation { get; } + public TaskCompletionSource Ready { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task Completion { get; set; } = Task.CompletedTask; + public Hex1bTerminalAutomator? Automator { get; set; } + public bool Disconnected { get; set; } } private sealed record TerminalConnection(Hex1bTerminal Terminal, Hex1bTerminalAutomator Automator); diff --git a/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs b/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs index ed631ec045f..01bb1ba1960 100644 --- a/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs +++ b/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; using System.Globalization; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; @@ -40,10 +39,12 @@ internal sealed class ResourceTerminalCatalog : IAsyncDisposable /// public const string IdPrefix = "resource:"; - private readonly ConcurrentDictionary _handles = new(StringComparer.Ordinal); + private readonly Dictionary _handles = new(StringComparer.Ordinal); + private readonly HashSet _retiringHandles = []; + private readonly object _gate = new(); private readonly DistributedApplicationModel _model; private readonly ILogger _logger; - private int _disposed; + private bool _disposed; public ResourceTerminalCatalog(DistributedApplicationModel model, ILogger logger) { @@ -107,7 +108,7 @@ public bool TryGetTerminal(string terminalId, out IAspireTerminal? terminal) { terminal = null; - if (_disposed != 0 || !IsResourceTerminalId(terminalId)) + if (!IsResourceTerminalId(terminalId)) { return false; } @@ -118,22 +119,52 @@ public bool TryGetTerminal(string terminalId, out IAspireTerminal? terminal) return false; } - terminal = _handles.GetOrAdd( - entry.Id, - static (id, state) => new ResourceAspireTerminal(id, state.Entry.Title, state.Entry.ConsumerUdsPath, state.Logger), - (Entry: entry, Logger: _logger)); + lock (_gate) + { + if (_disposed) + { + return false; + } + + // Disposing a handle releases only an automation peer, not the resource terminal. A later lookup + // must therefore be able to acquire a fresh handle for the same still-running replica. + if (!_handles.TryGetValue(entry.Id, out var handle) || handle.IsDisposed) + { + if (handle is not null) + { + // IsDisposed is set before asynchronous teardown finishes. Keep replaced peers reachable + // until that teardown completes so catalog shutdown also waits for them. + _retiringHandles.Add(handle); + _ = RetireHandleAsync(handle); + } + + handle = new ResourceAspireTerminal(entry.Id, entry.Title, entry.ConsumerUdsPath, _logger); + _handles[entry.Id] = handle; + } + + terminal = handle; + } return true; } public async ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + ResourceAspireTerminal[] handles; + lock (_gate) { - return; + if (_disposed) + { + return; + } + + _disposed = true; + handles = [.. _handles.Values, .. _retiringHandles]; + _handles.Clear(); + _retiringHandles.Clear(); } - foreach (var handle in _handles.Values) + foreach (var handle in handles) { // Disposing a resource terminal handle disconnects the AppHost's automation peer; the resource's // own workload is unaffected, so there is nothing here that should delay shutdown. @@ -147,7 +178,25 @@ public async ValueTask DisposeAsync() } } - _handles.Clear(); + } + + private async Task RetireHandleAsync(ResourceAspireTerminal handle) + { + try + { + await handle.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Disconnecting a replaced automation peer for resource terminal {TerminalId} failed.", handle.Id); + } + finally + { + lock (_gate) + { + _retiringHandles.Remove(handle); + } + } } } diff --git a/src/Aspire.Hosting/Terminals/TerminalClientStream.cs b/src/Aspire.Hosting/Terminals/TerminalClientStream.cs new file mode 100644 index 00000000000..f3aa92c315d --- /dev/null +++ b/src/Aspire.Hosting/Terminals/TerminalClientStream.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Terminals; + +/// +/// Limits Hex1b's access to a caller-owned transport to the lifetime of one viewer attachment. +/// +internal sealed class TerminalClientStream(Stream transport) : Stream +{ + private readonly object _gate = new(); + private readonly CancellationTokenSource _disconnectCts = new(); + private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _operations; + private bool _closing; + + public Task Released => _released.Task; + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + EnterOperation(); + try + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disconnectCts.Token); + return await transport.ReadAsync(buffer, linked.Token).ConfigureAwait(false); + } + finally + { + ExitOperation(); + } + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + EnterOperation(); + try + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disconnectCts.Token); + await transport.WriteAsync(buffer, linked.Token).ConfigureAwait(false); + } + finally + { + ExitOperation(); + } + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + EnterOperation(); + try + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disconnectCts.Token); + await transport.FlushAsync(linked.Token).ConfigureAwait(false); + } + finally + { + ExitOperation(); + } + } + + public override int Read(byte[] buffer, int offset, int count) + => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override void Flush() => FlushAsync(CancellationToken.None).GetAwaiter().GetResult(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + private void EnterOperation() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closing, this); + _operations++; + } + } + + private void ExitOperation() + { + lock (_gate) + { + if (--_operations == 0 && _closing) + { + _drained.TrySetResult(); + } + } + } + + public override ValueTask DisposeAsync() + { + lock (_gate) + { + if (_closing) + { + return new ValueTask(Released); + } + + _closing = true; + if (_operations == 0) + { + _drained.TrySetResult(); + } + } + + _ = ReleaseAsync(); + return new ValueTask(Released); + } + + private async Task ReleaseAsync() + { + try + { + try + { + await _disconnectCts.CancelAsync().ConfigureAwait(false); + } + finally + { + // Hex1b 0.165.0 disposes each session's stream without joining its read/write pumps. + // OnClientDisconnected also runs in parallel with that disposal, so neither the callback + // nor the Dispose call alone is a transport-release signal. Reject new operations and + // drain existing ones before allowing the gRPC handler to dispose its actual transport. + // https://github.com/mitchdenny/hex1b/blob/39947cb9455dd39b8de6326c643baaf4e4324962/src/Hex1b/Hmp1/Hmp1PresentationAdapter.cs + await _drained.Task.ConfigureAwait(false); + _disconnectCts.Dispose(); + } + + _released.TrySetResult(); + } + catch (Exception ex) + { + // Disposal can also be initiated by Hex1b, which suppresses transport cleanup errors. + // Preserve those errors for the attachment owner instead. + _released.TrySetException(ex); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + base.Dispose(disposing); + } +} diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 35ca941fa2e..6c1c9273351 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -115,21 +115,28 @@ internal IAspireTerminal CreateTerminal(string title, TerminalPlacement placemen { ArgumentNullException.ThrowIfNull(title); ArgumentNullException.ThrowIfNull(builder); - ObjectDisposedException.ThrowIf(_disposed != 0, this); // Terminal ids are opaque to the dashboard and appear in websocket query strings, so use a // non-guessable value rather than a sequence number. var id = Guid.NewGuid().ToString("n"); - var terminal = new Hex1bAspireTerminal(this, id, title, placement, builder, _logger); + Hex1bAspireTerminal terminal; - _terminals[id] = terminal; - _logger.LogDebug("Created {Placement} terminal {TerminalId} ({Title}).", placement, id, title); - - if (terminal.Placement == TerminalPlacement.Dock) + // Registration, publication, snapshots, and shutdown share one boundary. Otherwise a subscriber + // can see both a snapshot entry and its Added event, or shutdown can miss a new registration. + lock (_syncLock) { - Publish(new TerminalChange(TerminalChangeType.Added, terminal.Descriptor)); + ObjectDisposedException.ThrowIf(_disposed != 0, this); + terminal = new Hex1bAspireTerminal(this, id, title, placement, builder, _logger); + _terminals[id] = terminal; + + if (terminal.Placement == TerminalPlacement.Dock) + { + Publish(new TerminalChange(TerminalChangeType.Added, terminal.Descriptor)); + } } + _logger.LogDebug("Created {Placement} terminal {TerminalId} ({Title}).", placement, id, title); + return terminal; } @@ -137,8 +144,8 @@ internal IAspireTerminal CreateTerminal(string title, TerminalPlacement placemen /// Attaches a viewer transport to a terminal. /// /// - /// A task that completes when the terminal ends or is signalled. - /// Callers keep their transport open until it completes. + /// A task that completes after this viewer disconnects or the terminal ends, once all operations on the + /// caller's transport have finished. Cancellation disconnects only this viewer. /// internal Task AttachAsync(string terminalId, Stream clientStream, CancellationToken cancellationToken) { @@ -237,7 +244,14 @@ internal TerminalSubscription SubscribeDockTerminals() var channel = Channel.CreateUnbounded( new UnboundedChannelOptions { AllowSynchronousContinuations = false, SingleReader = true, SingleWriter = false }); - ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Add(c), channel); + if (_disposed != 0) + { + channel.Writer.TryComplete(); + } + else + { + ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Add(c), channel); + } var initial = _terminals.Values .Where(t => t.Placement == TerminalPlacement.Dock) @@ -272,7 +286,7 @@ async IAsyncEnumerable StreamChanges([EnumeratorCancellation] Ca } internal void NotifyActivated(Hex1bAspireTerminal terminal) - => Publish(new TerminalChange(TerminalChangeType.Activated, terminal.Descriptor)); + => Notify(terminal, TerminalChangeType.Activated); /// /// Removes a terminal from the registry and tears its workload down without waiting for it. @@ -306,21 +320,35 @@ async Task DisposeQuietlyAsync(Hex1bAspireTerminal target) } internal void NotifyRetitled(Hex1bAspireTerminal terminal) - => Publish(new TerminalChange(TerminalChangeType.Retitled, terminal.Descriptor)); + => Notify(terminal, TerminalChangeType.Retitled); - internal void Remove(Hex1bAspireTerminal terminal) + private void Notify(Hex1bAspireTerminal terminal, TerminalChangeType changeType) { - if (!_terminals.TryRemove(terminal.Id, out _)) + lock (_syncLock) { - return; + if (_terminals.ContainsKey(terminal.Id)) + { + Publish(new TerminalChange(changeType, terminal.Descriptor)); + } } + } - _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title); - - if (terminal.Placement == TerminalPlacement.Dock) + internal void Remove(Hex1bAspireTerminal terminal) + { + lock (_syncLock) { - Publish(new TerminalChange(TerminalChangeType.Removed, terminal.Descriptor)); + if (!_terminals.TryRemove(terminal.Id, out _)) + { + return; + } + + if (terminal.Placement == TerminalPlacement.Dock) + { + Publish(new TerminalChange(TerminalChangeType.Removed, terminal.Descriptor)); + } } + + _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title); } private void Publish(TerminalChange change) @@ -336,25 +364,41 @@ private void Publish(TerminalChange change) /// public async ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + Hex1bAspireTerminal[] terminals; + lock (_syncLock) { - return; + if (_disposed != 0) + { + return; + } + + _disposed = 1; + terminals = [.. _terminals.Values]; + _terminals.Clear(); + + foreach (var terminal in terminals) + { + if (terminal.Placement == TerminalPlacement.Dock) + { + Publish(new TerminalChange(TerminalChangeType.Removed, terminal.Descriptor)); + } + } + + foreach (var channel in _outgoingChannels) + { + channel.Writer.TryComplete(); + } } - foreach (var terminal in _terminals.Values) + foreach (var terminal in terminals) { - Remove(terminal); + _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title); // Don't await the workload winding down. AppHost shutdown should not be held up by a terminal // whose process ignores cancellation; the process is torn down with the AppHost regardless. _ = terminal.StopAsync(); } - foreach (var channel in _outgoingChannels) - { - channel.Writer.TryComplete(); - } - if (ResourceTerminals is { } resourceTerminals) { await resourceTerminals.DisposeAsync().ConfigureAwait(false); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs new file mode 100644 index 00000000000..880a7753fe2 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Tests.Shared; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +public class TerminalViewTests : DashboardTestContext +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void FailedInitialization_SameEndpointRenderRetries(bool endpointOnFirstRender) + { + Services.AddLocalization(); + var fail = true; + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var failedInit = module.Setup("initTerminal", _ => fail); + failedInit.SetException(new JSException("Initialization failed")); + var successfulInit = module.Setup("initTerminal", _ => !fail); + successfulInit.SetResult(1); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + + const string Endpoint = "/api/apphost-terminal?terminalId=terminal"; + var cut = RenderComponent(builder => + builder.Add(p => p.EndpointPathAndQuery, endpointOnFirstRender ? Endpoint : null)); + if (!endpointOnFirstRender) + { + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + } + + cut.WaitForAssertion(() => Assert.Equal(endpointOnFirstRender ? 2 : 1, failedInit.Invocations.Count)); + fail = false; + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + cut.WaitForAssertion(() => Assert.Single(successfulInit.Invocations)); + + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + Assert.Single(successfulInit.Invocations); + } + + [Fact] + public void FailedInitialization_RetryDoesNotReenterWhilePending() + { + Services.AddLocalization(); + var fail = true; + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var failedInit = module.Setup("initTerminal", _ => fail); + failedInit.SetException(new JSException("Initialization failed")); + var retry = module.Setup("initTerminal", _ => !fail); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + + const string Endpoint = "/api/apphost-terminal?terminalId=terminal"; + var cut = RenderComponent(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + cut.WaitForAssertion(() => Assert.Equal(2, failedInit.Invocations.Count)); + fail = false; + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + cut.WaitForAssertion(() => Assert.Single(retry.Invocations)); + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + Assert.Single(retry.Invocations); + retry.SetResult(1); + } + + [Theory] + [InlineData("/api/apphost-terminal?terminalId=second")] + [InlineData(null)] + public void PendingInitializationRetry_ReconcilesChangedEndpoint(string? updatedEndpoint) + { + Services.AddLocalization(); + var fail = true; + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var failedInit = module.Setup("initTerminal", _ => fail); + failedInit.SetException(new JSException("Initialization failed")); + var retry = module.Setup("initTerminal", _ => !fail); + var reconnect = module.Setup("reconnectTerminal", _ => true); + reconnect.SetResult(1); + var dispose = module.SetupVoid("disposeTerminal", _ => true); + dispose.SetVoidResult(); + + const string Endpoint = "/api/apphost-terminal?terminalId=first"; + var cut = RenderComponent(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + cut.WaitForAssertion(() => Assert.Equal(2, failedInit.Invocations.Count)); + fail = false; + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + cut.WaitForAssertion(() => Assert.Single(retry.Invocations)); + + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, updatedEndpoint)); + retry.SetResult(1); + cut.WaitForAssertion(() => + { + Assert.Single(retry.Invocations); + if (updatedEndpoint is null) + { + Assert.Single(dispose.Invocations); + } + else + { + var invocation = Assert.Single(reconnect.Invocations); + Assert.Equal($"ws://localhost{updatedEndpoint}", invocation.Arguments[1]); + } + }); + } + + [Fact] + public void EndpointRemovedDuringInitialization_CanAttachAgain() + { + Services.AddLocalization(); + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var init = module.Setup("initTerminal", _ => true); + var dispose = module.SetupVoid("disposeTerminal", _ => true); + dispose.SetVoidResult(); + + const string Endpoint = "/api/apphost-terminal?terminalId=terminal"; + var cut = RenderComponent(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, null)); + init.SetResult(1); + cut.WaitForAssertion(() => Assert.Single(dispose.Invocations)); + + cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, Endpoint)); + cut.WaitForAssertion(() => Assert.Equal(2, init.Invocations.Count)); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs new file mode 100644 index 00000000000..7dfd21bb0a2 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Layout; +using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Tests.Shared; +using Aspire.DashboardService.Proto.V1; +using Bunit; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Layout; + +public partial class MainLayoutTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TerminalDock_RunSelection_OnlySubscribesWhileLive(bool startHistorical) + { + var updates = Channel.CreateUnbounded(); + var subscriptionDisposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var client = new TestDashboardClient(terminalChannelProvider: () => updates) + { + OnTerminalSubscriptionDisposed = () => subscriptionDisposed.TrySetResult() + }; + var runStore = new FluentUISetupHelpers.TestDashboardRunStore( + [ + new("current", DashboardRunStore.SchemaVersion, DateTimeOffset.UnixEpoch, null, false, "TestApp", string.Empty, true), + new("historical", DashboardRunStore.SchemaVersion, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, true, "TestApp", string.Empty, false) + ]); + SetupMainLayoutServices(dashboardRunStore: runStore, dashboardClient: client); + TerminalSetupHelpers.SetupTerminalView(this); + TerminalSetupHelpers.SetupTerminalDock(this); + var selection = Assert.IsType(Services.GetRequiredService()); + selection.OnSelectRun = runId => client.IsReadOnly = runId is not null; + if (startHistorical) + { + selection.SelectRun("historical"); + } + + var cut = RenderComponent(builder => builder.Add(p => p.ViewportInformation, + new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false))); + var shortcuts = Services.GetRequiredService(); + var label = Services.GetRequiredService>()[nameof(Resources.Layout.MainLayoutToggleTerminalDock)].Value; + + if (startHistorical) + { + Assert.Empty(cut.FindComponents()); + Assert.Empty(cut.FindAll($"fluent-button[aria-label='{label}']")); + Assert.Equal(0, client.TerminalSubscriptionCount); + await cut.InvokeAsync(() => shortcuts.OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock)); + await cut.InvokeAsync(() => cut.FindComponent().Instance.SelectedRunIdChanged.InvokeAsync(null)); + } + + var originalDock = cut.FindComponent().Instance; + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "old")); + cut.WaitForAssertion(() => + { + Assert.Equal("old", cut.Find(".terminal-dock-tab.active").TextContent.Trim()); + Assert.Equal(1, client.ActiveTerminalSubscriptionCount); + }); + + await cut.InvokeAsync(() => cut.FindComponent().Instance.SelectedRunIdChanged.InvokeAsync("historical")); + await subscriptionDisposed.Task.DefaultTimeout(); + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindComponents()); + Assert.Empty(cut.FindAll($"fluent-button[aria-label='{label}']")); + Assert.Equal(0, client.ActiveTerminalSubscriptionCount); + }); + await cut.InvokeAsync(() => shortcuts.OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock)); + Assert.Empty(cut.FindComponents()); + + // The next subscription receives only the new live snapshot; no terminal from the previous dock survives. + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("new")); + await cut.InvokeAsync(() => cut.FindComponent().Instance.SelectedRunIdChanged.InvokeAsync(null)); + var newDock = cut.FindComponent().Instance; + Assert.NotSame(originalDock, newDock); + await cut.InvokeAsync(() => shortcuts.OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock)); + cut.WaitForAssertion(() => + { + Assert.Equal("new", cut.Find(".terminal-dock-tab.active").TextContent.Trim()); + Assert.Equal(2, client.TerminalSubscriptionCount); + Assert.Equal(1, client.ActiveTerminalSubscriptionCount); + }); + + await cut.InvokeAsync(() => newDock.DisposeAsync().AsTask()).DefaultTimeout(); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 82223b5f790..64fd5627ed5 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -1059,7 +1059,8 @@ private void SetupMainLayoutServices( IDialogService? dialogService = null, BrowserTimeProvider? browserTimeProvider = null, IDashboardRunStore? dashboardRunStore = null, - ISessionStorage? sessionStorage = null) + ISessionStorage? sessionStorage = null, + TestDashboardClient? dashboardClient = null) { FluentUISetupHelpers.AddCommonDashboardServices( this, @@ -1076,7 +1077,7 @@ private void SetupMainLayoutServices( Services.AddOptions(); Services.AddSingleton(); - var dashboardClient = new TestDashboardClient(); + dashboardClient ??= new TestDashboardClient(); Services.AddSingleton(dashboardClient); Services.AddKeyedSingleton(DashboardClient.LiveAppHostServiceKey, dashboardClient); Services.AddSingleton(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs new file mode 100644 index 00000000000..ec52699d260 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Layout; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Tests.Shared; +using Aspire.DashboardService.Proto.V1; +using Bunit; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Layout; + +public class TerminalDockTests : DashboardTestContext +{ + [Fact] + public async Task WatchUpdates_ReplaceSnapshotAndPreservePanelUntilActivated() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); + cut.WaitForAssertion(() => Assert.Equal("first", cut.Find(".terminal-dock-tab.active").TextContent.Trim())); + + await cut.Find(".terminal-dock-new").ClickAsync(new()); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock-panel"))); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Added, "third")); + cut.WaitForAssertion(() => + { + Assert.Equal(3, cut.FindAll(".terminal-dock-tab").Count); + Assert.Single(cut.FindAll(".terminal-dock-panel")); + Assert.Empty(cut.FindAll(".terminal-dock-tab.active")); + }); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "second")); + cut.WaitForAssertion(() => Assert.Equal("second", cut.Find(".terminal-dock-tab.active").TextContent.Trim())); + + // A reconnect snapshot can omit the selected terminal without sending its individual removal. + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("replacement")); + cut.WaitForAssertion(() => + { + Assert.Equal("replacement", cut.Find(".terminal-dock-tab.active").TextContent.Trim()); + Assert.Single(cut.FindComponents()); + }); + + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + Assert.Equal(0, client.ActiveTerminalSubscriptionCount); + await Services.GetRequiredService().OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock); + } + + [Fact] + public async Task CloseInactiveTab_DoesNotChangeSelection() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second", "third")); + cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll(".terminal-dock-tab").Count)); + + await cut.FindAll(".terminal-dock-tab-close")[1].ClickAsync(new()); + Assert.Equal(["second"], client.ClosedTerminals.ToArray()); + Assert.Equal("first", cut.Find(".terminal-dock-tab.active").TextContent.Trim()); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "second")); + cut.WaitForAssertion(() => + { + Assert.Equal(2, cut.FindAll(".terminal-dock-tab").Count); + Assert.Equal("first", cut.Find(".terminal-dock-tab.active").TextContent.Trim()); + }); + } + + [Fact] + public async Task RecoverySnapshot_ClosesWindowForMissingTerminal() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "detached")); + cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); + + await cut.Find(".terminal-dock-detach").ClickAsync(new()); + cut.WaitForAssertion(() => + { + Assert.Single(cut.FindAll(".terminal-dock-detached")); + Assert.Empty(cut.FindComponents()); + }); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("remaining")); + cut.WaitForAssertion(() => + { + Assert.Equal("remaining", cut.Find(".terminal-dock-tab.active").TextContent.Trim()); + }); + + // The snapshot renders before window cleanup, and the JS call does not itself cause another render. + // Wait for that side effect independently of bUnit's render-triggered assertions. + await AsyncTestHelpers.AssertIsTrueRetryAsync( + () => JSInterop.Invocations.Any(i => i.Identifier == "closeTerminalWindow"), + "The removed terminal's detached window was not closed."); + var close = Assert.Single(JSInterop.Invocations, i => i.Identifier == "closeTerminalWindow"); + Assert.Equal("detached", close.Arguments[0]); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index 91b67b8e97c..eb1a8a1d01d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -6,6 +6,7 @@ using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Model; using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Utils; @@ -694,11 +695,7 @@ private void SetupTerminalViewJsInterop() // reaching its assertions. The stubs return harmless defaults — the // assertions in these tests are about render-branch selection, not // about runtime terminal behaviour. - var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); - module.Setup("initTerminal", _ => true).SetResult(1); - module.Setup("reconnectTerminal", _ => true).SetResult(2); - module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); - module.SetupVoid("refreshLayout", _ => true).SetVoidResult(); + TerminalSetupHelpers.SetupTerminalView(this); } private static ResourceViewModel CreateTerminalResource(string resourceName, int replicaIndex, int replicaCount, KnownResourceState state = KnownResourceState.Running) diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs new file mode 100644 index 00000000000..4f0835bffd1 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Pages; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Tests.Shared; +using Aspire.DashboardService.Proto.V1; +using Bunit; +using Microsoft.AspNetCore.InternalTesting; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Pages; + +public class TerminalWindowTests : DashboardTestContext +{ + [Fact] + public async Task RecoverySnapshot_RemovesMissingTerminalAndDisposalCancelsWatch() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); + cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot()); + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindComponents()); + Assert.Single(cut.FindAll(".terminal-window-ended")); + }); + + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + Assert.Equal(0, client.ActiveTerminalSubscriptionCount); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs new file mode 100644 index 00000000000..06ddb0c3667 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Tests.Shared; +using Aspire.DashboardService.Proto.V1; +using Bunit; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +internal static class TerminalSetupHelpers +{ + public static void SetupTerminalComponents(TestContext context, TestDashboardClient client) + { + FluentUISetupHelpers.AddCommonDashboardServices(context); + FluentUISetupHelpers.SetupFluentUIComponents(context); + FluentUISetupHelpers.SetupFluentButton(context); + context.Services.AddSingleton(client); + SetupTerminalView(context); + SetupTerminalDock(context); + } + + public static void SetupTerminalView(TestContext context) + { + var module = context.JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + module.Setup("initTerminal", _ => true).SetResult(1); + module.Setup("reconnectTerminal", _ => true).SetResult(2); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + module.SetupVoid("refreshLayout", _ => true).SetVoidResult(); + } + + public static void SetupTerminalDock(TestContext context) + { + var dock = context.JSInterop.SetupModule("./Components/Layout/TerminalDock.razor.js"); + dock.SetupVoid("registerResizeHandle", _ => true).SetVoidResult(); + + var windows = context.JSInterop.SetupModule("/js/app-terminalwindow.js"); + windows.Setup("openTerminalWindow", _ => true).SetResult("opened"); + windows.Setup("focusTerminalWindow", _ => true).SetResult(true); + windows.SetupVoid("closeTerminalWindow", _ => true).SetVoidResult(); + windows.SetupVoid("untrackTerminalWindow", _ => true).SetVoidResult(); + } + + public static WatchTerminalsUpdate Snapshot(params string[] terminalIds) => new() + { + Snapshot = new TerminalDescriptorList + { + Terminals = { terminalIds.Select(id => new TerminalDescriptor { TerminalId = id, Title = id }) } + } + }; + + public static WatchTerminalsUpdate Change(TerminalChangeType changeType, string terminalId, string? title = null) => new() + { + Change = new TerminalChangeNotification + { + ChangeType = changeType, + Terminal = new TerminalDescriptor { TerminalId = terminalId, Title = title ?? terminalId } + } + }; +} diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index b5052853610..eae2d9c07ed 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Threading.Channels; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; @@ -592,6 +593,125 @@ public async Task ExecuteResourceCommandAsync_ClientCancellation_ReturnsAppHostD Assert.Equal(response.Message, response.ErrorMessage); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubscribeTerminals_StreamEnds_ResubscribesWithSnapshot(bool failStream) + { + var first = Channel.CreateUnbounded(); + var second = Channel.CreateUnbounded(); + var disposed = Channel.CreateUnbounded(); + var subscriptions = 0; + var service = new MockDashboardServiceClient + { + ResourceUpdatesChannel = Channel.CreateUnbounded().Reader, + TerminalUpdatesProvider = () => Interlocked.Increment(ref subscriptions) == 1 ? first.Reader : second.Reader, + OnTerminalWatchDisposed = () => disposed.Writer.TryWrite(true) + }; + await using var client = CreateResourceServiceClient(); + client.SetDashboardServiceClient(service); + await using var updates = client.SubscribeTerminalsAsync(CancellationToken.None).GetAsyncEnumerator(); + + var initial = new WatchTerminalsUpdate { Snapshot = new TerminalDescriptorList() }; + await first.Writer.WriteAsync(initial); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Same(initial, updates.Current); + + first.Writer.Complete(failStream ? new RpcException(new Status(StatusCode.Unavailable, "Disconnected")) : null); + var replacement = new WatchTerminalsUpdate + { + Snapshot = new TerminalDescriptorList + { + Terminals = { new TerminalDescriptor { TerminalId = "replacement", Title = "Replacement" } } + } + }; + await second.Writer.WriteAsync(replacement); + + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Same(replacement, updates.Current); + Assert.Equal(2, Volatile.Read(ref subscriptions)); + Assert.True(await disposed.Reader.ReadAsync().AsTask().DefaultTimeout()); + await updates.DisposeAsync().DefaultTimeout(); + Assert.True(await disposed.Reader.ReadAsync().AsTask().DefaultTimeout()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task SubscribeTerminals_Cancellation_StopsActiveStreamOrRecovery(bool disposeClient, bool duringRecovery) + { + var channel = Channel.CreateUnbounded(); + var streamDisposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var service = new MockDashboardServiceClient + { + ResourceUpdatesChannel = Channel.CreateUnbounded().Reader, + TerminalUpdatesProvider = () => channel.Reader, + OnTerminalWatchDisposed = () => streamDisposed.TrySetResult() + }; + await using var client = CreateResourceServiceClient(); + client.SetDashboardServiceClient(service); + using var cts = new CancellationTokenSource(); + await using var updates = client.SubscribeTerminalsAsync(cts.Token).GetAsyncEnumerator(); + await channel.Writer.WriteAsync(new WatchTerminalsUpdate { Snapshot = new TerminalDescriptorList() }); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + + var next = updates.MoveNextAsync().AsTask(); + if (duringRecovery) + { + channel.Writer.Complete(); + await streamDisposed.Task.DefaultTimeout(); + } + + if (disposeClient) + { + await client.DisposeAsync().DefaultTimeout(); + } + else + { + await cts.CancelAsync(); + } + + await Assert.ThrowsAnyAsync(() => next).DefaultTimeout(); + await streamDisposed.Task.DefaultTimeout(); + } + + [Fact] + public async Task SubscribeTerminals_Cancellation_StopsConnectionWait() + { + await using var client = CreateResourceServiceClient(); + client.SetDashboardServiceClient(new MockDashboardServiceClient { FailOnGetApplicationInformation = true }); + using var cts = new CancellationTokenSource(); + await using var updates = client.SubscribeTerminalsAsync(cts.Token).GetAsyncEnumerator(); + + var next = updates.MoveNextAsync().AsTask(); + await cts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => next).DefaultTimeout(); + } + + [Fact] + public async Task SubscribeTerminals_Unimplemented_CompletesWithoutRetry() + { + var channel = Channel.CreateUnbounded(); + channel.Writer.Complete(new RpcException(new Status(StatusCode.Unimplemented, "Older AppHost"))); + var subscriptions = 0; + await using var client = CreateResourceServiceClient(); + client.SetDashboardServiceClient(new MockDashboardServiceClient + { + ResourceUpdatesChannel = Channel.CreateUnbounded().Reader, + TerminalUpdatesProvider = () => + { + Interlocked.Increment(ref subscriptions); + return channel.Reader; + } + }); + await using var updates = client.SubscribeTerminalsAsync(CancellationToken.None).GetAsyncEnumerator(); + + Assert.False(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Equal(1, Volatile.Read(ref subscriptions)); + } + private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient { public bool FailOnWatchResources { get; init; } @@ -601,9 +721,22 @@ private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto. public string MinDashboardVersion { get; init; } = ""; public IReadOnlyList ConsoleLogUpdates { get; init; } = []; public IReadOnlyList ResourceUpdates { get; init; } = []; + public ChannelReader? ResourceUpdatesChannel { get; init; } + public Func>? TerminalUpdatesProvider { get; init; } + public Action? OnTerminalWatchDisposed { get; init; } public Activity? ActivityOnGetApplicationInformation { get; private set; } private int _resourceUpdatesReturned; + public override AsyncServerStreamingCall WatchTerminals(WatchTerminalsRequest request, CallOptions options) + { + return new AsyncServerStreamingCall( + new AsyncStreamReader(channel: TerminalUpdatesProvider?.Invoke()), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => OnTerminalWatchDisposed?.Invoke()); + } + public override AsyncServerStreamingCall WatchResourceConsoleLogs(WatchResourceConsoleLogsRequest request, CallOptions options) { return new AsyncServerStreamingCall( @@ -701,7 +834,9 @@ public override AsyncServerStreamingCall WatchResources(Wa { var reader = FailOnWatchResources ? (IAsyncStreamReader)new FailingAsyncStreamReader() - : new AsyncStreamReader(Interlocked.Exchange(ref _resourceUpdatesReturned, 1) == 0 ? ResourceUpdates : []); + : new AsyncStreamReader( + Interlocked.Exchange(ref _resourceUpdatesReturned, 1) == 0 ? ResourceUpdates : [], + ResourceUpdatesChannel); return new AsyncServerStreamingCall( reader, @@ -725,23 +860,37 @@ public Task MoveNext(CancellationToken cancellationToken) private sealed class AsyncStreamReader : IAsyncStreamReader { private readonly Queue _items; + private readonly ChannelReader? _channel; - public AsyncStreamReader(IEnumerable? items = null) + public AsyncStreamReader(IEnumerable? items = null, ChannelReader? channel = null) { _items = new Queue(items ?? []); + _channel = channel; } public T Current { get; private set; } = default!; - public Task MoveNext(CancellationToken cancellationToken) + public async Task MoveNext(CancellationToken cancellationToken) { if (_items.TryDequeue(out var item)) { Current = item; - return Task.FromResult(true); + return true; + } + + if (_channel is { } channel) + { + while (await channel.WaitToReadAsync(cancellationToken)) + { + if (channel.TryRead(out var update)) + { + Current = update; + return true; + } + } } - return Task.FromResult(false); + return false; } } diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs new file mode 100644 index 00000000000..dd5b5bba7bf --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipelines; +using Aspire.Hosting.Terminals; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Hex1b; +using Microsoft.AspNetCore.InternalTesting; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +[Trait("Partition", "2")] +public class Hex1bAspireTerminalTests +{ + [Fact] + public async Task AttachAsync_MultipleViewersCanDisconnectAndReconnectWithoutStoppingTheWorkload() + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + var input = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var inputReader = input.Reader.AsStream(); + await using var inputWriter = input.Writer.AsStream(); + var workload = new StreamWorkloadAdapter(outputReader, inputWriter); + await using var terminal = service.CreateTerminal("Shared", TerminalPlacement.Dialog, + Hex1bTerminal.CreateBuilder().WithDimensions(80, 24).WithWorkload(workload)); + + // Attach starts the previously idle workload. The other viewer must survive the first peer's EOF, + // and a later viewer must receive the same terminal's existing screen rather than a fresh process. + await using var first = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); + await using var second = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); + await outputWriter.WriteAsync("before-disconnect\r\n"u8.ToArray()); + await Task.WhenAll( + first.WaitForTextAsync("before-disconnect"), + second.WaitForTextAsync("before-disconnect")).DefaultTimeout(); + + await first.DisconnectPeerAsync().DefaultTimeout(); + Assert.True(service.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + + await second.SendTextAsync("viewer-input").DefaultTimeout(); + var bytes = new byte["viewer-input"u8.Length]; + await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout(); + Assert.Equal("viewer-input"u8.ToArray(), bytes); + + await terminal.SendTextAsync("automation-input").DefaultTimeout(); + bytes = new byte["automation-input"u8.Length]; + await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout(); + Assert.Equal("automation-input"u8.ToArray(), bytes); + + await outputWriter.WriteAsync("after-disconnect\r\n"u8.ToArray()); + await Task.WhenAll( + second.WaitForTextAsync("after-disconnect"), + terminal.WaitForTextAsync("after-disconnect")).DefaultTimeout(); + Assert.Contains("after-disconnect", terminal.GetScreenText()); + + await using var reconnected = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); + await reconnected.WaitForTextAsync("after-disconnect").DefaultTimeout(); + } + + [Fact] + public async Task AttachAsync_CancellationDuringHandshakeWaitsForTheOutstandingWrite() + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + var workload = new StreamWorkloadAdapter(outputReader, Stream.Null); + await using var terminal = service.CreateTerminal("Handshake", TerminalPlacement.Dialog, + Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + + var (serverStream, clientStream) = TestDuplexStream.CreatePair(); + using var serverOwner = serverStream; + using var clientOwner = clientStream; + using var gated = new GatedTerminalWriteStream(serverStream); + using var attachmentCts = new CancellationTokenSource(); + using var clientCts = new CancellationTokenSource(); + await using var client = Hex1bTerminal.CreateBuilder().WithHeadless().WithHmp1Stream(clientStream).Build(); + var attachment = service.AttachAsync(terminal.Id, gated, attachmentCts.Token); + var run = client.RunAsync(clientCts.Token); + + try + { + await gated.WriteStarted.DefaultTimeout(); + await attachmentCts.CancelAsync(); + await gated.WriteCancelled.DefaultTimeout(); + Assert.False(attachment.IsCompleted); + } + finally + { + gated.ReleaseWrite(); + await attachmentCts.CancelAsync(); + await attachment.DefaultTimeout(); + await clientCts.CancelAsync(); + try + { + await run.DefaultTimeout(); + } + catch (OperationCanceledException) when (clientCts.IsCancellationRequested) + { + } + + serverStream.Dispose(); + } + + // The cancelled viewer did not cancel the AppHost terminal or poison subsequent attachments. + await using var replacement = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); + await outputWriter.WriteAsync("replacement-ready\r\n"u8.ToArray()); + await replacement.WaitForTextAsync("replacement-ready").DefaultTimeout(); + } +} diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs index 76b220b1c56..e99052b36b3 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs @@ -2,9 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Terminals; -using Hex1b; +using Aspire.Hosting.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging.Abstractions; +using System.Net.Sockets; #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. @@ -32,7 +33,7 @@ public async Task AutomationTypesIntoAndReadsBackFromATerminalHost() // A shell is the workload because the round trip being proven is a human-shaped one: type a command, // have the workload run it, read the result off the replicated screen. - await using var host = await StartTerminalHostAsync("bash"); + await using var host = await TestResourceTerminalHost.StartAsync(CreateSocketPath()); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance); @@ -51,7 +52,7 @@ public async Task WaitForTextThrowsTimeoutWhenTheTextNeverAppears() { Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); - await using var host = await StartTerminalHostAsync("bash"); + await using var host = await TestResourceTerminalHost.StartAsync(CreateSocketPath()); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance); @@ -84,77 +85,74 @@ public async Task DisposeIsSafeWhenNothingEverConnected() await terminal.DisposeAsync().AsTask().DefaultTimeout(); } - /// - /// Stands up a terminal serving an HMP1 Unix domain socket, standing in for a replica's terminal host. - /// - private async Task StartTerminalHostAsync(string shell) + [Fact] + public async Task AutomationRetriesAfterTheTerminalHostStarts() { - // Socket paths have a low length limit (around 104 bytes on macOS), so keep the file name short. - var socketPath = Path.Combine(_socketDirectory, $"{Guid.NewGuid().ToString("N")[..8]}.sock"); - - var terminal = Hex1bTerminal.CreateBuilder() - // The test host has no controlling terminal either, so it is headless for the same reason the - // AppHost's client is. - .WithHeadless() - .WithDimensions(120, 40) - .WithPtyProcess(shell) - .WithHmp1UdsServer(socketPath) - .Build(); - - var cts = new CancellationTokenSource(); - var runTask = terminal.RunAsync(cts.Token); - - // The socket file appears when the listener binds, which is what a client can dial. - var deadline = DateTime.UtcNow.AddSeconds(30); - while (!File.Exists(socketPath) && DateTime.UtcNow < deadline) - { - if (runTask.IsFaulted) - { - await runTask; - } - - await Task.Delay(25); - } - - Assert.True(File.Exists(socketPath), $"The terminal host did not begin listening on '{socketPath}'."); - - return new TerminalHostStub(socketPath, terminal, cts, runTask); + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); + + var socketPath = CreateSocketPath(); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance); + + await Assert.ThrowsAnyAsync(() => terminal.SendTextAsync("not-delivered")).DefaultTimeout(); + + await using var host = await TestResourceTerminalHost.StartAsync(socketPath); + await terminal.SendTextAsync("echo recovered\"\"-connection\r").DefaultTimeout(); + await terminal.WaitForTextAsync("recovered-connection").DefaultTimeout(); + Assert.Contains("recovered-connection", terminal.GetScreenText()); } - public ValueTask InitializeAsync() => ValueTask.CompletedTask; + [Fact] + public async Task AutomationReconnectsAfterTheTerminalHostRestarts() + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); - public ValueTask DisposeAsync() + var socketPath = CreateSocketPath(); + await using var firstHost = await TestResourceTerminalHost.StartAsync(socketPath); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance); + + await terminal.SendTextAsync("echo first\"\"-host\r").DefaultTimeout(); + await terminal.WaitForTextAsync("first-host").DefaultTimeout(); + await firstHost.DisposeAsync().AsTask().DefaultTimeout(); + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => terminal.GetScreenText() == string.Empty, "The old automation screen was not invalidated."); + + await using var secondHost = await TestResourceTerminalHost.StartAsync(socketPath); + await terminal.SendTextAsync("echo replacement\"\"-host\r").DefaultTimeout(); + await terminal.WaitForTextAsync("replacement-host").DefaultTimeout(); + Assert.Contains("replacement-host", terminal.GetScreenText()); + } + + [Fact] + public async Task CancelingOneCallerDoesNotCancelTheSharedConnectionAttempt() { - try - { - Directory.Delete(_socketDirectory, recursive: true); - } - catch (IOException) - { - // A socket file that the runtime still holds open is not worth failing a test over. - } + var socketPath = CreateSocketPath(); + using var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + listener.Bind(new UnixDomainSocketEndPoint(socketPath)); + listener.Listen(); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance); + using var cts = new CancellationTokenSource(); + + // Accept the transport but withhold the HMP1 handshake so cancellation occurs during connection setup. + var canceledCall = terminal.SendTextAsync("first", cts.Token); + using var peer = await listener.AcceptAsync().DefaultTimeout(); + var otherCall = terminal.SendTextAsync("second"); + + await cts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => canceledCall).DefaultTimeout(); + Assert.False(otherCall.IsCompleted); - return ValueTask.CompletedTask; + await terminal.DisposeAsync().AsTask().DefaultTimeout(); + await Assert.ThrowsAnyAsync(() => otherCall).DefaultTimeout(); } - private sealed class TerminalHostStub(string socketPath, Hex1bTerminal terminal, CancellationTokenSource cts, Task runTask) : IAsyncDisposable + private string CreateSocketPath() + // Socket paths have a low length limit (around 104 bytes on macOS), so keep the file name short. + => Path.Combine(_socketDirectory, $"{Guid.NewGuid().ToString("N")[..8]}.sock"); + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() { - public string SocketPath { get; } = socketPath; - - public async ValueTask DisposeAsync() - { - await cts.CancelAsync(); - - try - { - await runTask; - } - catch (OperationCanceledException) - { - } - - await terminal.DisposeAsync(); - cts.Dispose(); - } + Directory.Delete(_socketDirectory, recursive: true); + return ValueTask.CompletedTask; } } diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs index e9d1e80c79e..c255e185344 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs @@ -3,9 +3,12 @@ using Aspire.Hosting.Terminals; using Aspire.Hosting.Testing; +using Aspire.Hosting.Tests.Dcp; using Aspire.Hosting.Utils; using Aspire.Shared.TerminalHost; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. @@ -20,7 +23,8 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class ResourceTerminalCatalogTests : IAsyncLifetime { - private readonly string _terminalDirectory = Directory.CreateTempSubdirectory("aspire-terminal-catalog-tests-").FullName; + // Leave room for the generated host socket name under macOS's 104-byte Unix socket path limit. + private readonly string _terminalDirectory = Directory.CreateTempSubdirectory("aspire-tc-").FullName; [Fact] public void BuildIdRoundTripsThroughIsResourceTerminalId() @@ -146,6 +150,79 @@ public async Task TryGetTerminalReturnsTheSameHandleForRepeatedLookups() Assert.Same(first, second); } + [Fact] + public async Task TryGetTerminalReplacesADisposedHandle() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + await using var catalog = await CreateCatalogAsync(builder); + var id = ResourceTerminalCatalog.BuildId("myapp", 0); + + Assert.True(catalog.TryGetTerminal(id, out var first)); + await first!.DisposeAsync(); + + Assert.True(catalog.TryGetTerminal(id, out var replacement)); + Assert.NotSame(first, replacement); + Assert.Equal(id, replacement!.Id); + Assert.False(Assert.IsType(replacement).IsDisposed); + Assert.True(catalog.TryGetTerminal(id, out var repeated)); + Assert.Same(replacement, repeated); + } + + [Fact] + public async Task ConcurrentLookupsShareTheReplacementHandle() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + await using var catalog = await CreateCatalogAsync(builder); + var id = ResourceTerminalCatalog.BuildId("myapp", 0); + Assert.True(catalog.TryGetTerminal(id, out var first)); + await first!.DisposeAsync(); + + var handles = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => + { + Assert.True(catalog.TryGetTerminal(id, out var handle)); + return handle; + }))); + + Assert.NotSame(first, handles[0]); + Assert.All(handles, handle => Assert.Same(handles[0], handle)); + } + + [Fact] + public async Task CatalogDisposalWaitsForAReplacedHandleToFinishDisconnecting() + { + using var builder = CreateBuilder(); + builder.AddExecutable("myapp", "myapp", ".").WithTerminal(); + var logger = new GatedLogger("Connecting AppHost automation"); + await using var catalog = await CreateCatalogAsync(builder, logger); + var id = ResourceTerminalCatalog.BuildId("myapp", 0); + Assert.True(catalog.TryGetTerminal(id, out var first)); + + var automation = first!.SendTextAsync("not-delivered"); + try + { + await logger.Blocked.DefaultTimeout(); + var firstDisposal = first.DisposeAsync().AsTask(); + Assert.True(catalog.TryGetTerminal(id, out var replacement)); + Assert.NotSame(first, replacement); + await replacement!.DisposeAsync(); + + // The current handle is already disposed. Only the replaced peer can keep shutdown pending. + var catalogDisposal = catalog.DisposeAsync().AsTask(); + Assert.False(catalogDisposal.IsCompleted); + logger.Release(); + await catalogDisposal.DefaultTimeout(); + Assert.True(firstDisposal.IsCompleted); + await Assert.ThrowsAnyAsync(() => automation).DefaultTimeout(); + } + finally + { + logger.Release(); + await first.DisposeAsync(); + } + } + [Fact] public async Task ResourceTerminalReportsResourceOwnership() { @@ -178,13 +255,16 @@ public async Task TryGetTerminalReturnsFalseAfterDisposal() /// Builds the application and publishes , which is the seam where /// WithTerminal() materializes the per-replica terminal hosts the catalog reads. /// - private static async Task CreateCatalogAsync(IDistributedApplicationTestingBuilder builder) + private static Task CreateCatalogAsync(IDistributedApplicationTestingBuilder builder) + => CreateCatalogAsync(builder, NullLogger.Instance); + + private static async Task CreateCatalogAsync(IDistributedApplicationTestingBuilder builder, ILogger logger) { await using var app = builder.Build(); var model = app.Services.GetRequiredService(); await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model)); - return new ResourceTerminalCatalog(model, NullLogger.Instance); + return new ResourceTerminalCatalog(model, logger); } private IDistributedApplicationTestingBuilder CreateBuilder() diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalClientStreamTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalClientStreamTests.cs new file mode 100644 index 00000000000..dffdec8bba7 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalClientStreamTests.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; +using Aspire.Hosting.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Hosting.Tests.Terminals; + +[Trait("Partition", "2")] +public class TerminalClientStreamTests +{ + [Fact] + public async Task DisposeAsync_WaitsForOutstandingIoAndDoesNotDisposeTransport() + { + var (transport, peer) = TestDuplexStream.CreatePair(); + using var transportOwner = transport; + using var peerOwner = peer; + using var gated = new GatedTerminalWriteStream(transport); + await using var stream = new TerminalClientStream(gated); + + var write = stream.WriteAsync("hello"u8.ToArray()).AsTask(); + await gated.WriteStarted.DefaultTimeout(); + var read = stream.ReadAsync(new byte[1]).AsTask(); + + var dispose = stream.DisposeAsync().AsTask(); + try + { + await gated.WriteCancelled.DefaultTimeout(); + await Assert.ThrowsAnyAsync(() => read).DefaultTimeout(); + Assert.False(dispose.IsCompleted); + Assert.False(stream.Released.IsCompleted); + Assert.False(transport.Disposed); + Assert.Same(dispose, stream.DisposeAsync().AsTask()); + } + finally + { + gated.ReleaseWrite(); + } + + await Assert.ThrowsAnyAsync(() => write).DefaultTimeout(); + await dispose.DefaultTimeout(); + Assert.False(transport.Disposed); + await Assert.ThrowsAsync(() => stream.WriteAsync(new byte[1]).AsTask()); + await Assert.ThrowsAsync(() => stream.ReadAsync(new byte[1]).AsTask()); + + // The owner can still use or dispose the underlying transport after Hex1b releases its wrapper. + await transport.WriteAsync("x"u8.ToArray()); + var buffer = new byte[1]; + Assert.Equal(1, await peer.ReadAsync(buffer).AsTask().DefaultTimeout()); + Assert.Equal((byte)'x', buffer[0]); + } +} diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 941a470c8fb..e9ac2055d90 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Threading.Channels; using Aspire.Hosting.Terminals; +using Aspire.Hosting.Tests.Dcp; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -13,9 +14,8 @@ namespace Aspire.Hosting.Tests.Terminals; /// -/// Guards 's registry and dock change fan-out. No test here starts a workload: -/// terminals are lazy, so creation, lookup, removal, and the dock subscription can all be exercised without a -/// PTY, which is what keeps these tests fast and platform-independent. +/// Guards 's registry and dock change fan-out. Most tests leave terminals lazy, +/// so creation, lookup, removal, and the dock subscription can be exercised without a PTY. /// [Trait("Partition", "2")] public class TerminalServiceTests @@ -230,6 +230,122 @@ public async Task CreateTerminal_AfterDispose_Throws() Assert.Throws(() => CreateInteractionTerminal(service, "Shell")); } + [Fact] + public async Task SubscribeDockTerminals_DuringCreation_DoesNotReplaySnapshotAsAdded() + { + var logger = new GatedLogger("Created Dock terminal"); + await using var service = new TerminalService(logger); + var create = Task.Run(() => CreateDockTerminal(service, "Dock")); + try + { + // The log is a deterministic interleaving point. Registry mutation and publication must + // already agree before any other code, including a logger, can subscribe. + await logger.Blocked.DefaultTimeout(); + using var subscription = service.SubscribeDockTerminals(); + var descriptor = Assert.Single(subscription.InitialState); + logger.Release(); + Assert.Equal(descriptor.Id, (await create.DefaultTimeout()).Id); + + await service.DisposeAsync(); + var changes = new List(); + await foreach (var change in subscription.Subscription) + { + changes.Add(change); + } + + var removed = Assert.Single(changes); + Assert.Equal(TerminalChangeType.Removed, removed.ChangeType); + Assert.Equal(descriptor.Id, removed.Terminal.Id); + } + finally + { + logger.Release(); + await create.DefaultTimeout(); + } + } + + [Fact] + public async Task SubscribeDockTerminals_DuringRemoval_DoesNotReceiveRemovalForAnAbsentSnapshotEntry() + { + var logger = new GatedLogger("Removed terminal"); + await using var service = new TerminalService(logger); + var terminal = CreateDockTerminal(service, "Dock"); + var remove = Task.Run(async () => await terminal.DisposeAsync()); + try + { + await logger.Blocked.DefaultTimeout(); + using var subscription = service.SubscribeDockTerminals(); + Assert.Empty(subscription.InitialState); + logger.Release(); + await remove.DefaultTimeout(); + + await service.DisposeAsync(); + await using var changes = subscription.Subscription.GetAsyncEnumerator(); + Assert.False(await changes.MoveNextAsync().AsTask().DefaultTimeout()); + } + finally + { + logger.Release(); + await remove.DefaultTimeout(); + } + } + + [Fact] + public async Task CreateTerminal_ConcurrentWithShutdown_DoesNotLeaveARegisteredTerminal() + { + for (var i = 0; i < 100; i++) + { + await using var service = TestTerminalService.Create(); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var create = Task.Run(async () => + { + await start.Task; + try + { + return CreateDockTerminal(service, "Dock"); + } + catch (ObjectDisposedException) + { + return null; + } + }); + var shutdown = Task.Run(async () => + { + await start.Task; + await service.DisposeAsync(); + }); + start.SetResult(); + await Task.WhenAll(create, shutdown).DefaultTimeout(); + + try + { + Assert.Empty(service.ListAll()); + Assert.Throws(() => CreateDockTerminal(service, "Late")); + } + finally + { + if (await create is { } terminal) + { + await terminal.DisposeAsync().DefaultTimeout(); + } + } + } + } + + [Fact] + public async Task SubscribeDockTerminals_AfterShutdown_ReturnsCompletedEmptySubscription() + { + await using var service = TestTerminalService.Create(); + CreateDockTerminal(service, "Dock"); + await service.DisposeAsync(); + + using var subscription = service.SubscribeDockTerminals(); + Assert.Empty(subscription.InitialState); + Assert.Empty(GetOutgoingChannels(service)); + await using var changes = subscription.Subscription.GetAsyncEnumerator(); + Assert.False(await changes.MoveNextAsync().AsTask().DefaultTimeout()); + } + [Fact] public void ListAll_IncludesTerminalsRegardlessOfPlacement() { diff --git a/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWriteStream.cs b/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWriteStream.cs new file mode 100644 index 00000000000..9e15f7b982b --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWriteStream.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Tests.Utils; + +internal sealed class GatedTerminalWriteStream(Stream inner) : Stream +{ + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _cancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _writes; + + public Task WriteStarted => _started.Task; + public Task WriteCancelled => _cancelled.Task; + public void ReleaseWrite() => _release.TrySetResult(); + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => inner.ReadAsync(buffer, cancellationToken); + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref _writes) == 1) + { + using var registration = cancellationToken.Register(() => _cancelled.TrySetResult()); + _started.TrySetResult(); + + // A transport can observe cancellation before its outstanding operation actually returns. + // Keep that window open deterministically so tests can verify that disposal waits for it. + await _release.Task; + cancellationToken.ThrowIfCancellationRequested(); + } + + await inner.WriteAsync(buffer, cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override void Flush() => inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => inner.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); +} diff --git a/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs b/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs new file mode 100644 index 00000000000..5b7ed6ce377 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; +using Hex1b; +using Hex1b.Automation; +using Microsoft.AspNetCore.InternalTesting; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Utils; + +internal sealed class TestAppHostTerminalViewer : IAsyncDisposable +{ + private readonly CancellationTokenSource _attachmentCts = new(); + private readonly CancellationTokenSource _clientCts = new(); + private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TestDuplexStream _serverStream; + private readonly TestDuplexStream _clientStream; + private readonly Hex1bTerminal _client; + private readonly Task _attachment; + private readonly Task _run; + private bool _disposed; + + private TestAppHostTerminalViewer(TerminalService service, string terminalId) + { + (_serverStream, _clientStream) = TestDuplexStream.CreatePair(); + _client = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(80, 24) + .WithHmp1Stream(_clientStream, options => + { + options.DefaultRole = Hmp1Role.Secondary; + options.OnConnected = (_, _) => + { + _connected.TrySetResult(); + return Task.CompletedTask; + }; + }) + .Build(); + + _attachment = service.AttachAsync(terminalId, _serverStream, _attachmentCts.Token); + _run = _client.RunAsync(_clientCts.Token); + } + + public static async Task ConnectAsync(TerminalService service, string terminalId) + { + var viewer = new TestAppHostTerminalViewer(service, terminalId); + try + { + var completed = await Task.WhenAny(viewer._connected.Task, viewer._run, viewer._attachment).DefaultTimeout(); + await completed; + Assert.True(viewer._connected.Task.IsCompletedSuccessfully, "The HMP1 connection ended before the handshake completed."); + return viewer; + } + catch + { + await viewer.DisposeAsync(); + throw; + } + } + + public Task WaitForTextAsync(string text) + => new Hex1bTerminalAutomator(_client, TimeSpan.FromSeconds(30)).WaitUntilTextAsync(text); + + public Task SendTextAsync(string text) + => new Hex1bTerminalAutomator(_client, TimeSpan.FromSeconds(30)).TypeAsync(text); + + public async Task DisconnectPeerAsync() + { + await StopClientAsync(); + // No attachment cancellation: the server must detect the peer's EOF and release the RPC itself. + await _attachment.DefaultTimeout(); + } + + private async Task StopClientAsync() + { + await _clientCts.CancelAsync(); + try + { + await _run.DefaultTimeout(); + } + catch (OperationCanceledException) when (_clientCts.IsCancellationRequested) + { + } + + await _client.DisposeAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + await _attachmentCts.CancelAsync(); + await _attachment.DefaultTimeout(); + } + finally + { + await StopClientAsync(); + _serverStream.Dispose(); + _clientStream.Dispose(); + _attachmentCts.Dispose(); + _clientCts.Dispose(); + } + } +} diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDuplexStream.cs b/tests/Aspire.Hosting.Tests/Utils/TestDuplexStream.cs new file mode 100644 index 00000000000..18db776710c --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/TestDuplexStream.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipelines; + +namespace Aspire.Hosting.Tests.Utils; + +internal sealed class TestDuplexStream(Stream reader, Stream writer) : Stream +{ + public static (TestDuplexStream First, TestDuplexStream Second) CreatePair() + { + var firstToSecond = new Pipe(); + var secondToFirst = new Pipe(); + return ( + new TestDuplexStream(secondToFirst.Reader.AsStream(), firstToSecond.Writer.AsStream()), + new TestDuplexStream(firstToSecond.Reader.AsStream(), secondToFirst.Writer.AsStream())); + } + + public bool Disposed { get; private set; } + + public override bool CanRead => reader.CanRead; + public override bool CanWrite => writer.CanWrite; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => reader.ReadAsync(buffer, cancellationToken); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => writer.WriteAsync(buffer, cancellationToken); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => reader.ReadAsync(buffer, offset, count, cancellationToken); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => writer.WriteAsync(buffer, offset, count, cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => reader.Read(buffer, offset, count); + public override void Write(byte[] buffer, int offset, int count) => writer.Write(buffer, offset, count); + public override void Flush() => writer.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => writer.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing && !Disposed) + { + Disposed = true; + reader.Dispose(); + writer.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/tests/Aspire.Hosting.Tests/Utils/TestResourceTerminalHost.cs b/tests/Aspire.Hosting.Tests/Utils/TestResourceTerminalHost.cs new file mode 100644 index 00000000000..5e03df9ca02 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/TestResourceTerminalHost.cs @@ -0,0 +1,74 @@ +// 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 Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Hosting.Tests.Utils; + +internal sealed class TestResourceTerminalHost : IAsyncDisposable +{ + private readonly Hex1bTerminal _terminal; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _runTask; + private Task? _disposeTask; + + private TestResourceTerminalHost(string socketPath) + { + SocketPath = socketPath; + _terminal = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(120, 40) + .WithPtyProcess("bash") + .WithHmp1UdsServer(socketPath) + .Build(); + _runTask = _terminal.RunAsync(_cts.Token); + } + + public string SocketPath { get; } + + public static async Task StartAsync(string socketPath) + { + var host = new TestResourceTerminalHost(socketPath); + try + { + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => + { + if (host._runTask.IsCompleted) + { + host._runTask.GetAwaiter().GetResult(); + throw new InvalidOperationException("The terminal host exited before its socket became available."); + } + + return File.Exists(socketPath); + }, $"The terminal host did not begin listening on '{socketPath}'."); + + return host; + } + catch + { + await host.DisposeAsync(); + throw; + } + } + + public ValueTask DisposeAsync() => new(_disposeTask ??= DisposeCoreAsync()); + + private async Task DisposeCoreAsync() + { + await _cts.CancelAsync(); + try + { + await _runTask; + } + catch (OperationCanceledException) when (_cts.IsCancellationRequested) + { + // Expected when the test shuts down the terminal host. + } + finally + { + await _terminal.DisposeAsync(); + _cts.Dispose(); + } + } +} diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index af0e7e6587d..ef355d011a6 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -17,18 +17,25 @@ public class TestDashboardClient : IDashboardClient private readonly Func>>? _consoleLogsChannelProvider; private readonly Func>>? _resourceChannelProvider; private readonly Func>? _interactionChannelProvider; + private readonly Func>? _terminalChannelProvider; private readonly Channel? _resourceCommandsChannel; private readonly Func>? _executeResourceCommand; private readonly Channel? _sendInteractionUpdateChannel; private readonly IList? _initialResources; + private int _terminalSubscriptionCount; + private int _activeTerminalSubscriptionCount; public bool IsEnabled { get; } - public bool IsReadOnly { get; } + public bool IsReadOnly { get; set; } public Task WhenConnected { get; } public string ApplicationName { get; } = "TestApp"; public string? MinRequiredVersion => null; public DashboardConnectionState ConnectionState => DashboardConnectionState.Connected; public ConcurrentQueue<(IReadOnlyList ResourceNames, DateTime ClearDate)> ClearedConsoleLogs { get; } = new(); + public ConcurrentQueue ClosedTerminals { get; } = new(); + public Action? OnTerminalSubscriptionDisposed { get; set; } + public int TerminalSubscriptionCount => Volatile.Read(ref _terminalSubscriptionCount); + public int ActiveTerminalSubscriptionCount => Volatile.Read(ref _activeTerminalSubscriptionCount); #pragma warning disable CS0067 // Event is never used - required by interface public event Action? ConnectionStateChanged; #pragma warning restore CS0067 @@ -45,7 +52,8 @@ public TestDashboardClient( Channel? sendInteractionUpdateChannel = null, IList? initialResources = null, Task? whenConnected = null, - bool isReadOnly = false) + bool isReadOnly = false, + Func>? terminalChannelProvider = null) { IsEnabled = isEnabled ?? false; IsReadOnly = isReadOnly; @@ -58,6 +66,7 @@ public TestDashboardClient( _executeResourceCommand = executeResourceCommand; _sendInteractionUpdateChannel = sendInteractionUpdateChannel; _initialResources = initialResources; + _terminalChannelProvider = terminalChannelProvider; } public ValueTask DisposeAsync() @@ -92,12 +101,28 @@ public Task AttachTerminalAsync(string terminalId, CancellationToken can public async IAsyncEnumerable SubscribeTerminalsAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - await Task.CompletedTask; - yield break; + Interlocked.Increment(ref _terminalSubscriptionCount); + Interlocked.Increment(ref _activeTerminalSubscriptionCount); + try + { + if (_terminalChannelProvider is { } provider) + { + await foreach (var update in provider().Reader.ReadAllAsync(cancellationToken)) + { + yield return update; + } + } + } + finally + { + Interlocked.Decrement(ref _activeTerminalSubscriptionCount); + OnTerminalSubscriptionDisposed?.Invoke(); + } } public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) { + ClosedTerminals.Enqueue(terminalId); return Task.CompletedTask; } From f3693ff0f04f40ed046c52338c34a8217fcf4ac2 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 6 Sep 2026 12:59:55 +1000 Subject: [PATCH 031/106] Reject required terminal interaction inputs Reject terminal inputs marked required before publishing an interaction. Cover both prompt APIs, prefilled values, and successful optional submission while preserving caller ownership. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- src/Aspire.Hosting/IInteractionService.cs | 3 + src/Aspire.Hosting/InteractionService.cs | 5 ++ .../InteractionServiceTerminalTests.cs | 61 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index 21d91ff8362..a9de7570f93 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -339,6 +339,9 @@ public required string Name /// /// Gets or sets a value indicating whether the input is required. /// + /// + /// Must be for inputs because they do not produce a value. + /// public bool Required { get => _required; diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 09ce9640ba6..d144b726bc4 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -171,6 +171,11 @@ public async Task> PromptInputsAsy var input = inputs[i]; if (input.InputType == InputType.Terminal) { + if (input.Required) + { + throw new InvalidOperationException($"The input '{input.Name}' has {nameof(InteractionInput.Required)} set to true, but {nameof(InputType.Terminal)} inputs do not produce a value and cannot be required."); + } + if (input.Terminal is null) { throw new InvalidOperationException($"The input '{input.Name}' is a {nameof(InputType.Terminal)} input, so {nameof(InteractionInput.Terminal)} must be set to a terminal created by the caller."); diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index c05fdf6a793..b2c590ae39f 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -21,6 +21,67 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class InteractionServiceTerminalTests { + [Theory] + [InlineData(false, null)] + [InlineData(false, "supplied-value")] + [InlineData(true, null)] + [InlineData(true, "supplied-value")] + public async Task PromptInputsAsync_RequiredTerminalInput_ThrowsBeforePublishing(bool singleInput, string? value) + { + var (interactionService, terminalService) = CreateInteractionService(); + await using var serviceOwner = terminalService; + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); + var input = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + Terminal = terminal, + Required = true, + Value = value + }; + + Func prompt = singleInput + ? () => interactionService.PromptInputAsync("Title", "Message", input) + : () => interactionService.PromptInputsAsync("Title", "Message", [input]); + var ex = await Assert.ThrowsAsync(prompt).DefaultTimeout(); + + Assert.Equal("The input 'shell' has Required set to true, but Terminal inputs do not produce a value and cannot be required.", ex.Message); + Assert.Empty(interactionService.GetCurrentInteractions()); + Assert.Null(input.TerminalId); + Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + } + + [Fact] + public async Task PromptInputsAsync_OptionalTerminalInput_SubmitsWithoutAValue() + { + var (interactionService, terminalService) = CreateInteractionService(); + await using var serviceOwner = terminalService; + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); + var input = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + Terminal = terminal, + Required = false + }; + + var prompt = interactionService.PromptInputsAsync("Title", "Message", [input]); + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + await interactionService.ProcessInteractionFromClientAsync( + interaction.InteractionId, + (_, _, _) => new InteractionCompletionState { Complete = true, State = new[] { input } }, + CancellationToken.None).DefaultTimeout(); + var result = await prompt.DefaultTimeout(); + + Assert.False(result.Canceled); + Assert.Same(input, Assert.Single(result.Data)); + Assert.Null(input.Value); + Assert.Empty(interactionService.GetCurrentInteractions()); + Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + } + [Fact] public async Task PromptInputsAsync_TerminalInputWithoutATerminal_Throws() { From 17847d8b372bb39670f1fafca4f40287a433c7da Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 6 Sep 2026 13:29:22 +1000 Subject: [PATCH 032/106] Respect disabled and loading terminal interaction inputs Keep terminal output connected while blocking user input and automatic primary promotion for read-only viewers. Reconcile dynamic presentation updates and cover initial state, in-flight changes, keyboard, paste, and output behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.cs | 49 +++++++++ .../Components/Controls/TerminalView.razor.js | 20 +++- .../Dialogs/InteractionsInputDialog.razor | 1 + .../Controls/TerminalViewTests.cs | 92 +++++++++++++++++ .../Dialogs/InteractionsInputDialogTests.cs | 51 ++++++++++ .../Shared/TerminalSetupHelpers.cs | 1 + .../TestTerminalConnectionResolver.cs | 7 ++ .../Integration/Playwright/TerminalTests.cs | 99 +++++++++++++++++++ 8 files changed, 319 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 5a377842d0d..cb9d30e481c 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -44,6 +44,8 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable // on a resource stop+restart where the dashboard fires a burst of // resource-snapshot-driven re-renders right after the page mounts. private bool _initStarted; + private bool _appliedReadOnly; + private bool _readOnlyUpdatePending; /// /// Gets or sets the user-facing display name of the resource that owns the @@ -97,6 +99,15 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Parameter] public string? EndpointPathAndQuery { get; set; } + /// + /// Gets or sets a value indicating whether user input is blocked while terminal output continues to display. + /// + /// + /// Changing this value does not reconnect the terminal or change the lifetime of its process. + /// + [Parameter] + public bool ReadOnly { get; set; } + /// /// Gets or sets a value indicating whether the terminal renders without its surrounding chrome — no card border, /// titlebar or internal padding, just the xterm grid and its footer. @@ -199,6 +210,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) currentEndpoint = ResolveEndpoint(); } + await UpdateReadOnlyAsync(); return; } @@ -248,6 +260,37 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // initialization was pending now, including removing the endpoint entirely. endpoint = ResolveEndpoint(); } + + await UpdateReadOnlyAsync(); + } + + private async Task UpdateReadOnlyAsync() + { + if (_readOnlyUpdatePending || _jsModule is null || _terminalId == 0) + { + return; + } + + _readOnlyUpdatePending = true; + try + { + // A render can arrive while JS interop is pending. Reconcile the latest value here because completing + // OnAfterRenderAsync does not trigger another render. + while (_appliedReadOnly != ReadOnly) + { + var readOnly = ReadOnly; + await _jsModule.InvokeVoidAsync("setReadOnly", _terminalId, readOnly); + _appliedReadOnly = readOnly; + } + } + catch (JSDisconnectedException) + { + // The browser disconnected while the presentation state was being updated. + } + finally + { + _readOnlyUpdatePending = false; + } } /// @@ -284,9 +327,11 @@ private async Task InitializeTerminalAsync(string endpoint) } _connectedGeneration = -1; + var readOnly = ReadOnly; _terminalId = await _jsModule.InvokeAsync( "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef, new TerminalViewOptions { + ReadOnly = readOnly, Chromeless = Chromeless, ShowDimensions = ShowDimensionsPicker, SizeMemoryKey = SizeMemoryKey, @@ -296,6 +341,7 @@ private async Task InitializeTerminalAsync(string endpoint) Fit = FitLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSizeAuto)], FocusControlsHint = FocusControlsHintLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalFocusControlsHint)], }); + _appliedReadOnly = readOnly; } catch (JSDisconnectedException) { @@ -539,6 +585,9 @@ public async ValueTask DisposeAsync() /// public sealed record TerminalViewOptions { + /// Whether user input is blocked without interrupting terminal output. + public bool ReadOnly { get; init; } + /// Whether to render without the card border, titlebar and internal padding. public bool Chromeless { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index a15e54d6c5c..66f9ca6cc8a 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -1350,6 +1350,7 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { }, // Layout / sizing state (per-instance — we never use globals). chromeless, + readOnly: !!options?.readOnly, // Whether the footer's fixed-resolution picker is offered. Dock panes // are sized by the dock splitter and always fit, so they get the font // stepper but not the picker. @@ -1416,6 +1417,7 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { const FitAddon = window.FitAddon.FitAddon; const fitAddon = new FitAddon(); const term = new window.Terminal({ + disableStdin: state.readOnly, cursorBlink: true, fontSize: state.currentFontPx, fontFamily: '"Cascadia Mono NF", "Cascadia Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace', @@ -1442,6 +1444,7 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { attachTerminalFocusNavigation(state, term); const helperTextArea = state.terminalBody.querySelector('.xterm-helper-textarea'); + helperTextArea?.setAttribute('aria-readonly', String(state.readOnly)); if (helperTextArea && state.terminalFocusHint) { helperTextArea.setAttribute('aria-keyshortcuts', 'F6 Shift+F6'); helperTextArea.setAttribute('aria-describedby', state.terminalFocusHint.id); @@ -1516,7 +1519,9 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { // promoting first ensures the keystroke lands. No-ops when we're already // primary or the client isn't connected yet. term.onData((data) => { - if (!state.client) return; + // Keep the transport open for output and other peers' automation. Gate the forwarding path as well as + // xterm's keyboard/paste handling so no input can promote this viewer while it is read-only. + if (state.readOnly || !state.client) return; maybeAutoPromote(state); state.client.sendInput(textEncoder.encode(data)); }); @@ -1812,6 +1817,18 @@ export function getSizePresets() { return SIZE_PRESETS.map((p) => ({ value: p.value, label: p.label, cols: p.cols, rows: p.rows })); } +export function setReadOnly(id, readOnly) { + const state = terminals.get(id); + if (!state) return; + + state.readOnly = readOnly; + state.term.options.disableStdin = readOnly; + state.terminalBody.querySelector('.xterm-helper-textarea')?.setAttribute('aria-readonly', String(readOnly)); + if (!readOnly && state.chromeless) { + maybeAutoPromote(state); + } +} + export function setFontSizeFromHost(id, newSize) { const state = terminals.get(id); if (!state || typeof newSize !== 'number') return; @@ -1844,6 +1861,7 @@ export function setSizeModeFromHost(id, sizeKey) { } function maybeAutoPromote(state) { + if (state.readOnly) return; const client = state.client; if (!client || client.peerId === null) return; if (client.isPrimary) return; diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor index d0707cd2da0..01024536f05 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor @@ -215,6 +215,7 @@ Context="input">
diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 880a7753fe2..802ddbfe40f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -12,6 +12,98 @@ namespace Aspire.Dashboard.Components.Tests.Controls; public class TerminalViewTests : DashboardTestContext { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReadOnly_InitialAndUpdatedStatePreservesConnection(bool initialReadOnly) + { + Services.AddLocalization(); + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var init = module.Setup("initTerminal", _ => true); + init.SetResult(1); + var update = module.SetupVoid("setReadOnly", _ => true); + update.SetVoidResult(); + var reconnect = module.Setup("reconnectTerminal", _ => true); + reconnect.SetResult(2); + var dispose = module.SetupVoid("disposeTerminal", _ => true); + dispose.SetVoidResult(); + + var cut = RenderComponent(builder => builder + .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal") + .Add(p => p.ReadOnly, initialReadOnly)); + + var options = Assert.IsType(Assert.Single(init.Invocations).Arguments[3]); + Assert.Equal(initialReadOnly, options.ReadOnly); + Assert.Empty(update.Invocations); + + cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, !initialReadOnly)); + cut.WaitForAssertion(() => + Assert.Equal(new object?[] { 1, !initialReadOnly }, Assert.Single(update.Invocations).Arguments)); + + cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, initialReadOnly)); + cut.WaitForAssertion(() => + { + Assert.Collection(update.Invocations, + invocation => Assert.Equal(new object?[] { 1, !initialReadOnly }, invocation.Arguments), + invocation => Assert.Equal(new object?[] { 1, initialReadOnly }, invocation.Arguments)); + }); + + cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, initialReadOnly)); + Assert.Equal(2, update.Invocations.Count); + Assert.Single(init.Invocations); + Assert.Empty(reconnect.Invocations); + Assert.Empty(dispose.Invocations); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReadOnly_ChangedDuringInitializationAppliesLatestState(bool initialReadOnly) + { + Services.AddLocalization(); + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var init = module.Setup("initTerminal", _ => true); + var update = module.SetupVoid("setReadOnly", _ => true); + update.SetVoidResult(); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + + var cut = RenderComponent(builder => builder + .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal") + .Add(p => p.ReadOnly, initialReadOnly)); + Assert.Single(init.Invocations); + + cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, !initialReadOnly)); + init.SetResult(1); + + cut.WaitForAssertion(() => + Assert.Equal(new object?[] { 1, !initialReadOnly }, Assert.Single(update.Invocations).Arguments)); + Assert.Single(init.Invocations); + } + + [Fact] + public void ReadOnly_ChangedDuringUpdateAppliesLatestState() + { + Services.AddLocalization(); + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + module.Setup("initTerminal", _ => true).SetResult(1); + var update = module.SetupVoid("setReadOnly", _ => true); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + + var cut = RenderComponent(builder => + builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal")); + cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, true)); + cut.WaitForAssertion(() => Assert.Single(update.Invocations)); + + cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, false)); + Assert.Single(update.Invocations); + update.SetVoidResult(); + + cut.WaitForAssertion(() => + Assert.Collection(update.Invocations, + invocation => Assert.Equal(new object?[] { 1, true }, invocation.Arguments), + invocation => Assert.Equal(new object?[] { 1, false }, invocation.Arguments))); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs index 22e0016c709..e70eb009a1f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Dialogs; using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Model.Interaction; @@ -17,6 +18,56 @@ namespace Aspire.Dashboard.Components.Tests.Dialogs; [UseCulture("en-US")] public sealed class InteractionsInputDialogTests : DashboardTestContext { + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Render_TerminalRespectsDisabledAndLoading(bool disabled, bool loading) + { + TerminalSetupHelpers.SetupTerminalView(this); + var cut = SetUpDialog(out var dialogService); + var input = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + TerminalId = "terminal", + Disabled = disabled, + Loading = loading + }; + var viewModel = new InteractionsInputsDialogViewModel + { + Interaction = new WatchInteractionsResponseUpdate + { + InteractionId = 1, + InputsDialog = new InteractionInputsDialog { InputItems = { input } } + }, + Message = string.Empty, + DashboardClient = new TestDashboardClient(), + OnSubmitCallback = (_, _) => Task.CompletedTask + }; + + await dialogService.ShowDialogAsync(viewModel, new DialogParameters { Title = "Shell" }); + cut.WaitForAssertion(() => Assert.Equal(disabled || loading, cut.FindComponent().Instance.ReadOnly)); + var terminal = cut.FindComponent().Instance; + + foreach (var state in new (bool Disabled, bool Loading)[] { (false, false), (true, false), (true, true), (false, true), (false, false) }) + { + var update = viewModel.Interaction.Clone(); + update.InputsDialog.InputItems[0].Disabled = state.Disabled; + update.InputsDialog.InputItems[0].Loading = state.Loading; + await cut.InvokeAsync(() => viewModel.UpdateInteractionAsync(update)); + + cut.WaitForAssertion(() => + { + var current = cut.FindComponent().Instance; + Assert.Same(terminal, current); + Assert.Equal(state.Disabled || state.Loading, current.ReadOnly); + Assert.Equal("/api/apphost-terminal?terminalId=terminal", current.EndpointPathAndQuery); + }); + } + } + [Fact] public async Task Render_FileUsesFallbackPlaceholderAndScopedBrowseLabel() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 06ddb0c3667..29a49bcf8b5 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -27,6 +27,7 @@ public static void SetupTerminalView(TestContext context) module.Setup("reconnectTerminal", _ => true).SetResult(2); module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); module.SetupVoid("refreshLayout", _ => true).SetVoidResult(); + module.SetupVoid("setReadOnly", _ => true).SetVoidResult(); } public static void SetupTerminalDock(TestContext context) diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs index ec6a15bbf1b..270ad44e283 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/TestTerminalConnectionResolver.cs @@ -4,6 +4,7 @@ using System.Buffers.Binary; using System.Net; using System.Net.Sockets; +using System.Text; using System.Text.Json; using System.Threading.Channels; using Aspire.Dashboard.Terminal; @@ -14,6 +15,7 @@ internal enum TestHmp1FrameType : byte { Hello = 0x01, StateSync = 0x02, + Output = 0x03, Input = 0x04, RequestPrimary = 0x07, ClientHello = 0x0B, @@ -137,6 +139,11 @@ public Task SendStateSyncAsync(CancellationToken cancellationToken) return SendFrameAsync(TestHmp1FrameType.StateSync, [], cancellationToken); } + public Task SendOutputAsync(string output, CancellationToken cancellationToken) + { + return SendFrameAsync(TestHmp1FrameType.Output, Encoding.UTF8.GetBytes(output), cancellationToken); + } + private async Task SendFrameAsync(TestHmp1FrameType type, byte[] payload, CancellationToken cancellationToken) { var frame = new byte[HeaderLength + payload.Length]; diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs index 87d2931170f..435e168a78c 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs @@ -30,6 +30,105 @@ public TerminalTests(TerminalDashboardServerFixture dashboardServerFixture) _dashboardServerFixture = dashboardServerFixture; } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task ReadOnly_BlocksKeyboardAndPasteWithoutInterruptingOutput(bool initialReadOnly, bool chromeless) + { + await RunTestAsync(async page => + { + await _dashboardServerFixture.TerminalResolver.DiscardPendingConnectionsAsync(); + await page.GotoAsync("/").DefaultTimeout(); + var terminalId = await page.EvaluateAsync(""" + async ({ resourceName, initialReadOnly, chromeless }) => { + const module = await import('/Components/Controls/TerminalView.razor.js'); + const container = document.createElement('div'); + container.style.cssText = 'position:fixed;inset:0;z-index:10000'; + document.body.appendChild(container); + const endpoint = new URL('/api/terminal', location.href); + endpoint.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + endpoint.searchParams.set('resource', resourceName); + endpoint.searchParams.set('replica', '0'); + return await module.initTerminal(container, endpoint.href, null, { readOnly: initialReadOnly, chromeless }); + } + """, new { resourceName = ResourceName, initialReadOnly, chromeless }); + + await using var connection = await _dashboardServerFixture.TerminalResolver.AcceptConnectionAsync(CancellationToken.None).DefaultTimeout(); + await connection.ReadUntilFrameAsync(TestHmp1FrameType.ClientHello, CancellationToken.None).DefaultTimeout(); + await connection.SendHelloAsync(ProducerColumns, ProducerRows, CancellationToken.None).DefaultTimeout(); + await connection.SendStateSyncAsync(CancellationToken.None).DefaultTimeout(); + await page.WaitForFunctionAsync(""" + async id => { + const module = await import('/Components/Controls/TerminalView.razor.js'); + return module.getToolbarState(id)?.role === 'secondary'; + } + """, terminalId).DefaultTimeout(); + if (chromeless && !initialReadOnly) + { + Assert.Equal(TestHmp1FrameType.RequestPrimary, (await connection.ReadFrameAsync(CancellationToken.None).DefaultTimeout()).Type); + } + + var terminalInput = page.Locator(".xterm-helper-textarea"); + await Assertions.Expect(terminalInput).ToHaveAttributeAsync("aria-readonly", initialReadOnly ? "true" : "false"); + if (!initialReadOnly) + { + await SetReadOnlyAsync(page, terminalId, true); + } + + await Assertions.Expect(terminalInput).ToHaveAttributeAsync("aria-readonly", "true"); + await connection.SendOutputAsync("Output while read-only\r\n", CancellationToken.None).DefaultTimeout(); + await Assertions.Expect(page.Locator(".xterm-rows")).ToContainTextAsync("Output while read-only"); + + await terminalInput.FocusAsync(); + await page.Keyboard.TypeAsync("blocked-keyboard"); + await PasteAsync(terminalInput, "blocked-paste"); + await page.Keyboard.PressAsync("F6"); + await Assertions.Expect(page.Locator("#font-minus")).ToBeFocusedAsync(); + await page.Locator("#font-plus").ClickAsync(); + + await SetReadOnlyAsync(page, terminalId, false); + await Assertions.Expect(terminalInput).ToHaveAttributeAsync("aria-readonly", "false"); + await terminalInput.FocusAsync(); + await page.Keyboard.TypeAsync("x"); + await PasteAsync(terminalInput, "allowed-paste"); + + // HMP preserves frame ordering. Only enabling a chromeless viewer and enabled input can request primary. + // Disabled typing, paste and font controls must not claim control from an automation peer. + if (chromeless) + { + Assert.Equal(TestHmp1FrameType.RequestPrimary, (await connection.ReadFrameAsync(CancellationToken.None).DefaultTimeout()).Type); + } + Assert.Equal(TestHmp1FrameType.RequestPrimary, (await connection.ReadFrameAsync(CancellationToken.None).DefaultTimeout()).Type); + var keyboard = await connection.ReadFrameAsync(CancellationToken.None).DefaultTimeout(); + Assert.Equal(TestHmp1FrameType.Input, keyboard.Type); + Assert.Equal(TestHmp1FrameType.RequestPrimary, (await connection.ReadFrameAsync(CancellationToken.None).DefaultTimeout()).Type); + var paste = await connection.ReadFrameAsync(CancellationToken.None).DefaultTimeout(); + Assert.Equal(TestHmp1FrameType.Input, paste.Type); + Assert.Equal("x", Encoding.UTF8.GetString(keyboard.Payload)); + Assert.Equal("allowed-paste", Encoding.UTF8.GetString(paste.Payload)); + }); + } + + private static Task SetReadOnlyAsync(IPage page, int terminalId, bool readOnly) => + page.EvaluateAsync(""" + async ({ terminalId, readOnly }) => { + const module = await import('/Components/Controls/TerminalView.razor.js'); + module.setReadOnly(terminalId, readOnly); + } + """, new { terminalId, readOnly }); + + private static Task PasteAsync(ILocator terminalInput, string text) => + terminalInput.EvaluateAsync(""" + (element, text) => { + const data = new DataTransfer(); + data.setData('text/plain', text); + element.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })); + } + """, text); + [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] public async Task TerminalFocusNavigation_MovesToExpectedControlsWithoutForwardingInput() From 4ef444c652535a64b9cc3310ecb2bc308a4fbf0b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 6 Sep 2026 15:32:52 +1000 Subject: [PATCH 033/106] Preserve caller-owned terminals when cloning command inputs Keep per-invocation input state separate while preserving terminal identity. Document borrowed ownership and cover repeated command prompts after submission, dismissal, and cancellation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../ResourceCommandService.cs | 5 +- src/Aspire.Hosting/InteractionService.cs | 2 + .../ResourceCommandServiceTests.cs | 120 ++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index 3c197fabcc5..8ac0851a4d0 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs @@ -9,6 +9,7 @@ namespace Aspire.Hosting.ApplicationModel; #pragma warning disable ASPIREINTERACTION001 // PromptProgressAsync and related types are experimental. +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. /// /// A service to execute resource commands. @@ -804,6 +805,9 @@ private static InteractionInput CloneInput(InteractionInput input, string? value Description = input.Description, EnableDescriptionMarkdown = input.EnableDescriptionMarkdown, InputType = input.InputType, + // Input state belongs to this invocation, but the terminal is borrowed from its caller and may be + // intentionally reused across interactions. Preserve its identity without creating or owning a process. + Terminal = input.Terminal, Required = input.Required, Options = input.Options, DynamicLoading = input.DynamicLoading, @@ -839,4 +843,3 @@ internal sealed class ResourceCommandExecutionOptions public bool NonInteractive { get; init; } } - diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index d144b726bc4..9ee0d097bd2 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -171,6 +171,8 @@ public async Task> PromptInputsAsy var input = inputs[i]; if (input.InputType == InputType.Terminal) { + // The input only borrows a terminal for presentation. Its caller-owned lifetime is independent + // of the dialog, so reusing the same terminal in later interactions is valid. if (input.Required) { throw new InvalidOperationException($"The input '{input.Name}' has {nameof(InteractionInput.Required)} set to true, but {nameof(InputType.Terminal)} inputs do not produce a value and cannot be required."); diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index f7d11b99276..21067fa1f5a 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -2,14 +2,17 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Threading.Channels; +using Aspire.Hosting.Terminals; using Aspire.Hosting.Testing; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Tests; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. [Trait("Partition", "2")] public class ResourceCommandServiceTests(ITestOutputHelper testOutputHelper) @@ -1310,6 +1313,123 @@ public async Task ExecuteCommandAsync_InteractiveWithoutArguments_PromptsForArgu Assert.Equal("#submit", capturedArguments.GetString("selector")); } + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task ExecuteCommandAsync_TerminalArguments_ReuseSessionAcrossInteractions(bool dismissFirst, bool cancelFirst) + { + using var builder = CreateBuilder(); + await using var terminalService = TestTerminalService.Create(); + builder.Services.AddSingleton(terminalService); + + // Exercise the real interaction lifecycle with prompting enabled, without starting a dashboard in the test. + builder.Services.AddSingleton(services => new InteractionService( + services.GetRequiredService>(), + new DistributedApplicationOptions(), + services, + builder.Configuration, + services.GetRequiredService())); + + await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + { + Title = "Shell", + Command = new TerminalCommand("bash"), + Placement = TerminalPlacement.Dialog + }); + var terminalDefinition = new InteractionInput + { + Name = "shell", + InputType = InputType.Terminal, + Terminal = terminal + }; + var messageDefinition = new InteractionInput + { + Name = "message", + InputType = InputType.Text, + Value = "default" + }; + InteractionInputCollection? capturedArguments = null; + var executionCount = 0; + var custom = builder.AddResource(new CustomResource("myResource")); + custom.WithCommand( + name: "mycommand", + displayName: "My command", + executeCommand: context => + { + capturedArguments = context.Arguments; + executionCount++; + return Task.FromResult(CommandResults.Success()); + }, + commandOptions: new CommandOptions { Arguments = [terminalDefinition, messageDefinition] }); + + await using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + var interactionService = app.Services.GetRequiredService(); + InteractionInput? previousInput = null; + + for (var invocation = 0; invocation < 2; invocation++) + { + capturedArguments = null; + using var cts = new CancellationTokenSource(); + var resultTask = app.ResourceCommands.ExecuteCommandAsync( + "myResource", + "mycommand", + new ResourceCommandExecutionOptions { NonInteractive = false }, + cts.Token); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + var inputs = Assert.IsType(interaction.InteractionInfo).Inputs; + var input = inputs["shell"]; + Assert.NotSame(terminalDefinition, input); + Assert.NotSame(previousInput, input); + Assert.Same(terminal, input.Terminal); + Assert.Equal(terminal.Id, input.TerminalId); + Assert.False(input.Disabled); + Assert.Equal("default", inputs.GetString("message")); + + input.Disabled = true; + inputs["message"].Value = $"invocation-{invocation}"; + var canceled = invocation == 0 && (dismissFirst || cancelFirst); + if (invocation == 0 && cancelFirst) + { + cts.Cancel(); + } + else + { + await interactionService.ProcessInteractionFromClientAsync( + interaction.InteractionId, + (_, _, _) => new InteractionCompletionState { Complete = true, State = canceled ? null : inputs }, + CancellationToken.None).DefaultTimeout(); + } + + var result = await resultTask.DefaultTimeout(); + Assert.Equal(!canceled, result.Success); + Assert.Equal(canceled, result.Canceled); + if (canceled) + { + Assert.Null(capturedArguments); + } + else + { + Assert.NotNull(capturedArguments); + Assert.Same(input, capturedArguments["shell"]); + Assert.Same(terminal, capturedArguments["shell"].Terminal); + Assert.Equal($"invocation-{invocation}", capturedArguments.GetString("message")); + } + + Assert.Empty(interactionService.GetCurrentInteractions()); + Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + Assert.False(terminalDefinition.Disabled); + Assert.Null(terminalDefinition.TerminalId); + Assert.Equal("default", messageDefinition.Value); + previousInput = input; + } + + Assert.Equal(dismissFirst || cancelFirst ? 1 : 2, executionCount); + } + [Fact] public async Task ExecuteCommandAsync_InteractiveDisabledDynamicArgumentWithDefaultValue_Succeeds() { From 41bd6764ebfb8f702408c1506a7275b840393353 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 6 Sep 2026 16:06:09 +1000 Subject: [PATCH 034/106] Validate terminal interaction registration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- src/Aspire.Hosting/IInteractionService.cs | 5 + src/Aspire.Hosting/InteractionService.cs | 12 ++ .../InteractionServiceTerminalTests.cs | 130 +++++++++++++++++- .../Utils/TestAspireTerminal.cs | 35 +++++ 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index a9de7570f93..b1a51142f90 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -479,6 +479,11 @@ public long? MaxFileSize /// stops showing it but does not stop the workload. /// /// + /// The supplied instance must still be registered with the resolved from the + /// current AppHost's service provider. Disposed terminals, terminals from another AppHost, and unregistered + /// implementations are rejected before the dialog is shown; matching a registered terminal's ID is not enough. + /// + /// /// Owning the terminal outside the interaction is what lets the AppHost script it through /// 's automation members — before the dialog is raised, while it is open, and after /// it closes — and lets the same terminal be shown by more than one dialog over its life. diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 9ee0d097bd2..9cba8b69b91 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -8,6 +8,7 @@ using System.Threading.Channels; using Aspire.Hosting.Terminals; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; #pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. @@ -189,6 +190,17 @@ public async Task> PromptInputsAsy { throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.Terminal)} to a terminal whose {nameof(IAspireTerminal.Placement)} is {input.Terminal.Placement}. Terminals shown by an interaction must be created with {nameof(TerminalPlacement)}.{nameof(TerminalPlacement.Dialog)}."); } + + // The dashboard resolves IDs in this AppHost's registry rather than using the supplied object. + // Require identity as well as registration; dialog placement above excludes resource-owned handles. + // Resolve the service only here so ordinary prompts do not require terminal infrastructure. + // Callers can still dispose after this check, so attachment must continue to validate availability. + if (_serviceProvider.GetService() is not { } terminalService || + !terminalService.TryGetTerminal(input.Terminal.Id, out var registeredTerminal) || + !ReferenceEquals(input.Terminal, registeredTerminal)) + { + throw new InvalidOperationException($"The input '{input.Name}' must reference the terminal instance registered with this AppHost's {nameof(TerminalService)}."); + } } if (input.DynamicLoading is { } dynamic) diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index b2c590ae39f..216d20b8ff7 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -95,6 +95,109 @@ public async Task PromptInputsAsync_TerminalInputWithoutATerminal_Throws() Assert.Contains(nameof(InteractionInput.Terminal), ex.Message, StringComparison.Ordinal); } + [Fact] + public async Task PromptInputsAsync_TerminalFromAnotherService_ThrowsBeforePublishing() + { + var (interactionService, terminalService) = CreateInteractionService(); + await using var serviceOwner = terminalService; + await using var otherService = TestTerminalService.Create(); + await using var terminal = CreateTerminal(otherService, TerminalPlacement.Dialog); + var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, Terminal = terminal }; + + await AssertTerminalRejectedAsync(interactionService, [input], input.Name); + + Assert.True(otherService.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + Assert.False(terminalService.TryGetTerminal(terminal.Id, out _)); + } + + [Fact] + public async Task PromptInputsAsync_DisposedTerminal_ThrowsBeforePublishing() + { + var (interactionService, terminalService) = CreateInteractionService(); + await using var serviceOwner = terminalService; + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); + await terminal.DisposeAsync(); + var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, Terminal = terminal }; + + await AssertTerminalRejectedAsync(interactionService, [input], input.Name); + + Assert.False(terminalService.TryGetTerminal(terminal.Id, out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PromptInputsAsync_UnregisteredTerminal_ThrowsBeforePublishing(bool useRegisteredId) + { + var (interactionService, terminalService) = CreateInteractionService(); + await using var serviceOwner = terminalService; + await using var registeredTerminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); + await using var unregisteredTerminal = new TestAspireTerminal(useRegisteredId ? registeredTerminal.Id : "unregistered"); + var validInput = new InteractionInput { Name = "valid", InputType = InputType.Terminal, Terminal = registeredTerminal }; + var invalidInput = new InteractionInput { Name = "invalid", InputType = InputType.Terminal, Terminal = unregisteredTerminal }; + Assert.Equal(useRegisteredId, unregisteredTerminal.Equals(registeredTerminal)); + + await AssertTerminalRejectedAsync(interactionService, [validInput, invalidInput], invalidInput.Name); + + Assert.False(unregisteredTerminal.IsDisposed); + Assert.True(terminalService.TryGetTerminal(registeredTerminal.Id, out var registered)); + Assert.Same(registeredTerminal, registered); + } + + [Fact] + public async Task PromptInputsAsync_NoTerminalService_ThrowsBeforePublishing() + { + var (interactionService, terminalService) = CreateInteractionService(registerTerminalService: false); + await using var serviceOwner = terminalService; + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); + var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, Terminal = terminal }; + + await AssertTerminalRejectedAsync(interactionService, [input], input.Name); + + Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + } + + [Fact] + public async Task PromptInputsAsync_TextInput_DoesNotRequireTerminalService() + { + var (interactionService, terminalService) = CreateInteractionService(registerTerminalService: false); + await using var serviceOwner = terminalService; + var input = new InteractionInput { Name = "text", InputType = InputType.Text }; + + var prompt = interactionService.PromptInputsAsync("Title", "Message", [input]); + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + await CancelInteractionAsync(interactionService, interaction.InteractionId).DefaultTimeout(); + var result = await prompt.DefaultTimeout(); + + Assert.True(result.Canceled); + Assert.Null(input.TerminalId); + Assert.Empty(interactionService.GetCurrentInteractions()); + } + + [Fact] + public async Task PromptInputsAsync_TerminalDisposedAfterPublishing_AttachmentStillRejectsIt() + { + var (interactionService, terminalService) = CreateInteractionService(); + await using var serviceOwner = terminalService; + await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); + var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, Terminal = terminal }; + using var cts = new CancellationTokenSource(); + + var prompt = interactionService.PromptInputsAsync("Title", "Message", [input], cancellationToken: cts.Token); + Assert.Single(interactionService.GetCurrentInteractions()); + await terminal.DisposeAsync(); + + var ex = await Assert.ThrowsAsync( + () => terminalService.AttachAsync(terminal.Id, Stream.Null, CancellationToken.None)).DefaultTimeout(); + Assert.Equal($"There is no terminal with id '{terminal.Id}'.", ex.Message); + cts.Cancel(); + var result = await prompt.DefaultTimeout(); + Assert.True(result.Canceled); + Assert.Empty(interactionService.GetCurrentInteractions()); + } + [Fact] public async Task PromptInputsAsync_TerminalOnTheDockSurface_Throws() { @@ -259,13 +362,36 @@ private static IAspireTerminal CreateTerminal(TerminalService service, TerminalP Placement = placement }); - private static (InteractionService InteractionService, TerminalService TerminalService) CreateInteractionService() + private static async Task AssertTerminalRejectedAsync(InteractionService interactionService, IReadOnlyList inputs, string invalidInputName) + { + using var cts = new CancellationTokenSource(); + var prompt = interactionService.PromptInputsAsync("Title", "Message", inputs, cancellationToken: cts.Token); + try + { + Assert.Empty(interactionService.GetCurrentInteractions()); + var ex = await Assert.ThrowsAsync(() => prompt).DefaultTimeout(); + Assert.Equal($"The input '{invalidInputName}' must reference the terminal instance registered with this AppHost's TerminalService.", ex.Message); + Assert.All(inputs, input => Assert.Null(input.TerminalId)); + } + finally + { + // Unwind any incorrectly published prompt too, so a regression cannot leave an interaction pending. + cts.Cancel(); + } + } + + private static (InteractionService InteractionService, TerminalService TerminalService) CreateInteractionService(bool registerTerminalService = true) { var terminalService = TestTerminalService.Create(); + var services = new ServiceCollection(); + if (registerTerminalService) + { + services.AddSingleton(terminalService); + } var interactionService = new InteractionService( NullLogger.Instance, new DistributedApplicationOptions(), - new ServiceCollection().BuildServiceProvider(), + services.BuildServiceProvider(), new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore()); diff --git a/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs b/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs new file mode 100644 index 00000000000..8923e0d1d01 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Utils; + +internal sealed class TestAspireTerminal(string id) : IAspireTerminal +{ + public string Id { get; } = id; + public string Title => "Test terminal"; + public TerminalOwner Owner => TerminalOwner.AppHost; + public TerminalPlacement Placement => TerminalPlacement.Dialog; + public bool IsDisposed { get; private set; } + + public void Start() => throw new NotSupportedException(); + public void Show() => throw new NotSupportedException(); + public Task SendTextAsync(string text, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public string GetScreenText() => throw new NotSupportedException(); + + // An implementation may compare terminals by ID, but that must not make it interchangeable with the + // registered instance: the dashboard attaches to the registered object, not the supplied implementation. + public override bool Equals(object? obj) => obj is IAspireTerminal other && string.Equals(Id, other.Id, StringComparison.Ordinal); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Id); + + public ValueTask DisposeAsync() + { + IsDisposed = true; + return ValueTask.CompletedTask; + } +} From 2ed8657fb9b8c2dae673649c5a474a83b3968de0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 6 Sep 2026 16:48:45 +1000 Subject: [PATCH 035/106] Reject unsupported AppHost terminal placements Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Terminals/TerminalLaunchOptions.cs | 5 ++ .../Terminals/TerminalService.cs | 12 ++++ .../Terminals/TerminalServiceTests.cs | 59 +++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs index 78ea20fbe42..6e9d6987ea6 100644 --- a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs +++ b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs @@ -25,5 +25,10 @@ public sealed class TerminalLaunchOptions /// /// Gets or sets where the terminal is displayed. Defaults to . /// + /// + /// AppHost-owned terminals support , , + /// and . is reserved for + /// terminals owned by resources and cannot be used when creating an AppHost-owned terminal. + /// public TerminalPlacement Placement { get; set; } = TerminalPlacement.Dock; } diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 6c1c9273351..41cc868b976 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -67,6 +67,10 @@ internal TerminalService(ILogger logger) /// terminal that is meant to outlive the call that created it should be left undisposed, and is torn down /// when the AppHost shuts down. /// + /// + /// The placement in is not , + /// , or . + /// public IAspireTerminal CreateTerminal(TerminalLaunchOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -116,6 +120,14 @@ internal IAspireTerminal CreateTerminal(string title, TerminalPlacement placemen ArgumentNullException.ThrowIfNull(title); ArgumentNullException.ThrowIfNull(builder); + // AppHost-owned terminals have no resource view. Validate both creation paths here before registration, + // while retaining None for terminals driven only through automation. + if (placement is not (TerminalPlacement.Dock or TerminalPlacement.Dialog or TerminalPlacement.None)) + { + throw new ArgumentOutOfRangeException(nameof(placement), placement, + $"AppHost-owned terminals must use {nameof(TerminalPlacement.Dock)}, {nameof(TerminalPlacement.Dialog)}, or {nameof(TerminalPlacement.None)} placement."); + } + // Terminal ids are opaque to the dashboard and appear in websocket query strings, so use a // non-guessable value rather than a sequence number. var id = Guid.NewGuid().ToString("n"); diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index e9ac2055d90..cf676031a59 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -7,6 +7,7 @@ using Aspire.Hosting.Terminals; using Aspire.Hosting.Tests.Dcp; using Aspire.Hosting.Utils; +using Hex1b; using Microsoft.AspNetCore.InternalTesting; #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. @@ -40,6 +41,54 @@ public void CreateTerminal_NullCommand_Throws() })); } + [Theory] + [InlineData(TerminalPlacement.ResourceView, false)] + [InlineData(TerminalPlacement.ResourceView, true)] + [InlineData((TerminalPlacement)(-1), false)] + [InlineData((TerminalPlacement)(-1), true)] + [InlineData((TerminalPlacement)4, false)] + [InlineData((TerminalPlacement)4, true)] + public async Task CreateTerminal_UnsupportedPlacement_ThrowsBeforeRegistration(TerminalPlacement placement, bool useBuilder) + { + await using var service = TestTerminalService.Create(); + + var ex = Assert.Throws(nameof(placement), () => CreateTerminal(service, placement, useBuilder)); + + Assert.Equal(placement, ex.ActualValue); + Assert.Empty(service.ListAll()); + } + + [Theory] + [InlineData(TerminalPlacement.Dock, false)] + [InlineData(TerminalPlacement.Dock, true)] + [InlineData(TerminalPlacement.Dialog, false)] + [InlineData(TerminalPlacement.Dialog, true)] + [InlineData(TerminalPlacement.None, false)] + [InlineData(TerminalPlacement.None, true)] + public async Task CreateTerminal_SupportedPlacement_RegistersTerminal(TerminalPlacement placement, bool useBuilder) + { + await using var service = TestTerminalService.Create(); + await using var terminal = CreateTerminal(service, placement, useBuilder); + + Assert.Equal(TerminalOwner.AppHost, terminal.Owner); + Assert.Equal(placement, terminal.Placement); + Assert.True(service.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + var listing = Assert.Single(service.ListAll()); + Assert.Equal(terminal.Id, listing.Id); + Assert.Equal(placement, listing.Placement); + + using var subscription = service.SubscribeDockTerminals(); + if (placement == TerminalPlacement.Dock) + { + Assert.Equal(terminal.Id, Assert.Single(subscription.InitialState).Id); + } + else + { + Assert.Empty(subscription.InitialState); + } + } + [Fact] public void CreateTerminal_RegistersTerminalUnderANonGuessableId() { @@ -405,6 +454,16 @@ public void ListAll_WithoutAResourceCatalogReturnsOnlyAppHostTerminals() Assert.Single(service.ListAll()); } + private static IAspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement, bool useBuilder) + => useBuilder + ? service.CreateTerminal("Shell", placement, Hex1bTerminal.CreateBuilder().WithPtyProcess("bash")) + : service.CreateTerminal(new TerminalLaunchOptions + { + Title = "Shell", + Command = new TerminalCommand("bash"), + Placement = placement + }); + private static IAspireTerminal CreateInteractionTerminal(TerminalService service, string title) => service.CreateTerminal(new TerminalLaunchOptions { From 48f14c4a3e010e74765a973bc12e115d5c5706aa Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 6 Sep 2026 21:31:37 +1000 Subject: [PATCH 036/106] Bound terminal disposal and notify on close timeout Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor | 2 +- .../Components/Layout/TerminalDock.razor.cs | 31 ++- .../Resources/Layout.Designer.cs | 20 +- src/Aspire.Dashboard/Resources/Layout.resx | 7 + .../Resources/xlf/Layout.cs.xlf | 10 + .../Resources/xlf/Layout.de.xlf | 10 + .../Resources/xlf/Layout.es.xlf | 10 + .../Resources/xlf/Layout.fr.xlf | 10 + .../Resources/xlf/Layout.it.xlf | 10 + .../Resources/xlf/Layout.ja.xlf | 10 + .../Resources/xlf/Layout.ko.xlf | 10 + .../Resources/xlf/Layout.pl.xlf | 10 + .../Resources/xlf/Layout.pt-BR.xlf | 10 + .../Resources/xlf/Layout.ru.xlf | 10 + .../Resources/xlf/Layout.tr.xlf | 10 + .../Resources/xlf/Layout.zh-Hans.xlf | 10 + .../Resources/xlf/Layout.zh-Hant.xlf | 10 + .../Dashboard/DashboardService.cs | 42 +++- .../Layout/TerminalDockTests.cs | 126 ++++++++++ .../Dashboard/DashboardServiceTests.cs | 226 ++++++++++++++++++ .../Utils/TestAspireTerminal.cs | 3 +- tests/Shared/TestDashboardClient.cs | 7 +- 22 files changed, 587 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 798e37b4892..a84080e3746 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -20,7 +20,7 @@ Class="terminal-dock-tab-close" Title="@Loc[nameof(Resources.Layout.TerminalDockCloseTab)]" aria-label="@Loc[nameof(Resources.Layout.TerminalDockCloseTab)]" - OnClick="@(() => CloseTerminalAsync(terminal.TerminalId))"> + OnClick="@(() => CloseTerminalAsync(terminal.TerminalId, terminal.Title))"> diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 101ac9ce1b5..28c8ff70740 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -3,9 +3,12 @@ using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; +using Grpc.Core; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; +using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; +using FluentMessageIntent = Microsoft.FluentUI.AspNetCore.Components.MessageIntent; namespace Aspire.Dashboard.Components.Layout; @@ -73,6 +76,12 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener [Inject] public required ILogger Logger { get; init; } + [Inject] + public required INotificationService NotificationService { get; init; } + + [Inject] + public required IToastService ToastService { get; init; } + [Inject] public required IJSRuntime JS { get; init; } @@ -285,12 +294,32 @@ private void ShowPanel() StateHasChanged(); } - private async Task CloseTerminalAsync(string terminalId) + private async Task CloseTerminalAsync(string terminalId, string terminalTitle) { try { await DashboardClient.CloseTerminalAsync(terminalId, _cts.Token).ConfigureAwait(true); } + catch (Exception ex) when (_cts.IsCancellationRequested && (ex is OperationCanceledException || ex is RpcException { StatusCode: StatusCode.Cancelled })) + { + Logger.LogDebug(ex, "Stopped waiting for dock terminal {TerminalId} to close because the dashboard disconnected.", terminalId); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded && !_disposed) + { + Logger.LogWarning(ex, "Timed out waiting for dock terminal {TerminalId} to close.", terminalId); + + // Removal can arrive on the watch stream before disposal times out. Keep the clicked title rather + // than looking it up in the remaining tabs, and retain the warning in the notification center. + var title = Loc[nameof(Resources.Layout.TerminalDockCloseTimedOutTitle)].Value; + var message = Loc[nameof(Resources.Layout.TerminalDockCloseTimedOutMessage), terminalTitle].Value; + NotificationService.AddNotification(new NotificationEntry + { + Title = title, + Body = message, + Intent = FluentMessageIntent.Warning + }); + ToastService.ShowWarning(message); + } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.LogWarning(ex, "Failed to close dock terminal {TerminalId}.", terminalId); diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 2900b9ea0ed..47df7e851f2 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -113,7 +113,25 @@ public static string TerminalDockCloseTab { return ResourceManager.GetString("TerminalDockCloseTab", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Terminal close timed out. + /// + public static string TerminalDockCloseTimedOutTitle { + get { + return ResourceManager.GetString("TerminalDockCloseTimedOutTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background.. + /// + public static string TerminalDockCloseTimedOutMessage { + get { + return ResourceManager.GetString("TerminalDockCloseTimedOutMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool.. /// diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 47a54852396..f5d4815b883 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -174,6 +174,13 @@ Close terminal + + Terminal close timed out + + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index 447b76eb31c..49e16fcb44d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 5850debb986..fa8f6acce41 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index df09005de54..93718369b50 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 5ab2bc30d2b..0e6ba341813 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 9c630858f71..b2effa7225c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index fd3e3b0e8c9..a74e6af3ecc 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 6abba65e531..bb7125590fb 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 145e610b45c..c97f3c8c7c5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index a3f36178a59..dff27a9abe6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 961c71baf9d..c6b355aee84 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index a81c2775c9c..500c1d5ee91 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 4fd916bd830..3cb8a5ee8d9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 610f43aeb8e..89317c9e61a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -152,6 +152,16 @@ Close terminal + + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. + {0} is the terminal's display title. + + + Terminal close timed out + Terminal close timed out + + Open terminal in a new window Open terminal in a new window diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 126b693eef8..64cd14d334f 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -44,6 +44,8 @@ internal sealed partial class DashboardService(DashboardServiceData serviceData, // Protobuf sends strings as UTF8. Be conservative and assume the average character byte size is 2. public const int LogMaxBatchCharacters = 1024 * 1024 * 2; + internal const int CloseTerminalTimeoutSeconds = 10; + /// /// The minimum dashboard version required by this AppHost build. /// Bump this when a new AppHost feature requires a newer dashboard. @@ -725,7 +727,7 @@ public override async Task CloseTerminal( { if (terminalService.TryGetTerminal(request.TerminalId, out var terminal)) { - await terminal.DisposeAsync().ConfigureAwait(false); + await CloseTerminalAsync(terminal, context.CancellationToken).ConfigureAwait(false); } // Closing an unknown terminal is not an error: the dashboard may be reacting to a tab the AppHost @@ -733,6 +735,44 @@ public override async Task CloseTerminal( return new CloseTerminalResponse(); } + /// + /// Requests disposal while bounding only the dashboard's wait for cleanup. + /// + internal async Task CloseTerminalAsync(Aspire.Hosting.Terminals.IAspireTerminal terminal, CancellationToken cancellationToken) + { + using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeout = Task.Delay(TimeSpan.FromSeconds(CloseTerminalTimeoutSeconds), waitCts.Token); + + // DisposeAsync can block synchronously in cancellation callbacks. Run it independently so even + // that work is bounded by the RPC's wait, without letting a disconnect cancel the disposal. + var disposal = Task.Run(async () => await terminal.DisposeAsync().ConfigureAwait(false), CancellationToken.None); + _ = disposal.ContinueWith( + task => logger.LogError(task.Exception, "Failed to dispose terminal {TerminalId}.", terminal.Id), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + try + { + if (await Task.WhenAny(disposal, timeout).ConfigureAwait(false) != disposal) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Only stop waiting. Cleanup still owns the attached transports until terminal teardown + // finishes, and the continuation observes any failure after this RPC has returned. + throw new RpcException(new Status(StatusCode.DeadlineExceeded, + $"Terminal '{terminal.Id}' did not finish disposing within {CloseTerminalTimeoutSeconds} seconds.")); + } + + // Preserve genuine disposal failures, including TimeoutException from the workload itself. + await disposal.ConfigureAwait(false); + } + finally + { + waitCts.Cancel(); + } + } + private static TerminalDescriptor ToProtoDescriptor(AppHostTerminalDescriptor descriptor) => new() { TerminalId = descriptor.Id, Title = descriptor.Title }; diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index ec52699d260..5c3413c4430 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Threading.Channels; using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Layout; @@ -9,12 +10,16 @@ using Aspire.Dashboard.Tests.Shared; using Aspire.DashboardService.Proto.V1; using Bunit; +using Grpc.Core; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; using Xunit; +using FluentMessageIntent = Microsoft.FluentUI.AspNetCore.Components.MessageIntent; namespace Aspire.Dashboard.Components.Tests.Layout; +[UseCulture("en-US")] public class TerminalDockTests : DashboardTestContext { [Fact] @@ -69,6 +74,7 @@ public async Task CloseInactiveTab_DoesNotChangeSelection() await cut.FindAll(".terminal-dock-tab-close")[1].ClickAsync(new()); Assert.Equal(["second"], client.ClosedTerminals.ToArray()); Assert.Equal("first", cut.Find(".terminal-dock-tab.active").TextContent.Trim()); + Assert.Empty(Services.GetRequiredService().GetNotifications()); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "second")); cut.WaitForAssertion(() => @@ -78,6 +84,126 @@ public async Task CloseInactiveTab_DoesNotChangeSelection() }); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CloseTab_TimesOut_NotifiesEvenAfterTabRemoval(bool removeWhileWaiting) + { + var updates = Channel.CreateUnbounded(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var client = new TestDashboardClient( + terminalChannelProvider: () => updates, + closeTerminal: (_, _) => completion.Task); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var toasts = new ConcurrentQueue(); + Services.GetRequiredService().OnShow += (_, parameters, _) => toasts.Enqueue(parameters); + var notifications = Services.GetRequiredService(); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + var snapshot = TerminalSetupHelpers.Snapshot("terminal-id"); + snapshot.Snapshot.Terminals[0].Title = "Setup shell"; + await updates.Writer.WriteAsync(snapshot); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock-tab"))); + + var close = cut.Find(".terminal-dock-tab-close").ClickAsync(new()); + cut.WaitForAssertion(() => Assert.Equal(["terminal-id"], client.ClosedTerminals.ToArray())); + Assert.Empty(notifications.GetNotifications()); + + if (removeWhileWaiting) + { + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "terminal-id")); + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".terminal-dock-tab"))); + } + + completion.SetException(new RpcException(new Status(StatusCode.DeadlineExceeded, "Terminal disposal timed out."))); + await close.DefaultTimeout(); + + var notification = Assert.Single(notifications.GetNotifications()).Entry; + Assert.Equal("Terminal close timed out", notification.Title); + Assert.Equal("Timed out waiting for terminal 'Setup shell' to shut down. Cleanup is continuing in the background.", notification.Body); + Assert.Equal(FluentMessageIntent.Warning, notification.Intent); + var toast = Assert.Single(toasts); + Assert.Equal(ToastIntent.Warning, toast.Intent); + Assert.Equal(notification.Body, toast.Title); + Assert.Equal(1, notifications.UnreadCount); + } + + [Fact] + public async Task HideDock_DoesNotCloseTerminals() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".terminal-dock-tab").Count)); + + await cut.Find(".terminal-dock-collapse").ClickAsync(new()); + + Assert.Single(cut.FindAll(".terminal-dock.collapsed")); + Assert.Empty(client.ClosedTerminals); + Assert.Empty(Services.GetRequiredService().GetNotifications()); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + Assert.Single(cut.FindAll(".terminal-dock.visible")); + Assert.Equal(2, cut.FindAll(".terminal-dock-tab").Count); + } + + [Fact] + public async Task CloseTab_ComponentDisposed_CancelsWaitWithoutNotification() + { + var updates = Channel.CreateUnbounded(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var client = new TestDashboardClient( + terminalChannelProvider: () => updates, + closeTerminal: (_, cancellationToken) => + { + started.SetResult(cancellationToken); + return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + }); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock-tab"))); + + var close = cut.Find(".terminal-dock-tab-close").ClickAsync(new()); + var token = await started.Task.DefaultTimeout(); + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + await close.DefaultTimeout(); + + Assert.True(token.IsCancellationRequested); + Assert.Empty(Services.GetRequiredService().GetNotifications()); + } + + [Theory] + [InlineData(StatusCode.Cancelled)] + [InlineData(StatusCode.DeadlineExceeded)] + public async Task CloseTab_ResponseAfterComponentDisposal_DoesNotNotify(StatusCode statusCode) + { + var updates = Channel.CreateUnbounded(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var client = new TestDashboardClient( + terminalChannelProvider: () => updates, + closeTerminal: (_, _) => completion.Task); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var toasts = new ConcurrentQueue(); + Services.GetRequiredService().OnShow += (_, parameters, _) => toasts.Enqueue(parameters); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock-tab"))); + + var close = cut.Find(".terminal-dock-tab-close").ClickAsync(new()); + cut.WaitForAssertion(() => Assert.Single(client.ClosedTerminals)); + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + completion.SetException(new RpcException(new Status(statusCode, "Close interrupted."))); + await close.DefaultTimeout(); + + Assert.Empty(Services.GetRequiredService().GetNotifications()); + Assert.Empty(toasts); + } + [Fact] public async Task RecoverySnapshot_ClosesWindowForMissingTerminal() { diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index f496aa6c9f1..cbfe09cd03e 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -3,6 +3,8 @@ #pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only +using System.Diagnostics; +using System.IO.Pipelines; using System.Text; using System.Threading.Channels; using Aspire.DashboardService.Proto.V1; @@ -16,6 +18,7 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using Hex1b; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -23,8 +26,10 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using DashboardServiceImpl = Aspire.Hosting.Dashboard.DashboardService; using Resource = Aspire.Hosting.ApplicationModel.Resource; +using WriteContext = Microsoft.Extensions.Logging.Testing.WriteContext; #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. @@ -1265,6 +1270,227 @@ public void ResolveFiles_UnknownInput_ReturnsNull() Assert.Empty(result); } + [Fact] + public async Task CloseTerminal_UnknownId_Succeeds() + { + using var serviceData = CreateDashboardServiceData(); + await using var terminalService = TestTerminalService.Create(); + var service = CreateDashboardService(serviceData, terminalService: terminalService); + + var response = await service.CloseTerminal( + new CloseTerminalRequest { TerminalId = "unknown" }, TestServerCallContext.Create()).DefaultTimeout(); + + Assert.NotNull(response); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CloseTerminal_DisposesTerminal_AndRepeatedCloseSucceeds(bool started) + { + using var serviceData = CreateDashboardServiceData(); + await using var terminalService = TestTerminalService.Create(); + var service = CreateDashboardService(serviceData, terminalService: terminalService); + var output = new Pipe(); + await using var reader = output.Reader.AsStream(); + await using var writer = output.Writer.AsStream(); + await using var terminal = terminalService.CreateTerminal("Close", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(reader, Stream.Null))); + if (started) + { + terminal.Start(); + } + + var request = new CloseTerminalRequest { TerminalId = terminal.Id }; + var response = await service.CloseTerminal(request, TestServerCallContext.Create()).DefaultTimeout(); + + Assert.NotNull(response); + Assert.False(terminalService.TryGetTerminal(terminal.Id, out _)); + Assert.True(terminal.DisposeAsync().AsTask().IsCompletedSuccessfully); + Assert.NotNull(await service.CloseTerminal(request, TestServerCallContext.Create()).DefaultTimeout()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CloseTerminal_PendingTransportCleanup_TimeoutOrCancellationDoesNotReleaseTransport(bool cancelRpc) + { + using var serviceData = CreateDashboardServiceData(); + await using var terminalService = TestTerminalService.Create(); + var service = CreateDashboardService(serviceData, terminalService: terminalService); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var terminal = terminalService.CreateTerminal("Closing", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null))); + var (serverStream, clientStream) = TestDuplexStream.CreatePair(); + using var serverOwner = serverStream; + using var clientOwner = clientStream; + using var gated = new GatedTerminalWriteStream(serverStream); + using var clientCts = new CancellationTokenSource(); + using var rpcCts = new CancellationTokenSource(); + await using var client = Hex1bTerminal.CreateBuilder().WithHeadless().WithHmp1Stream(clientStream).Build(); + var attachment = terminalService.AttachAsync(terminal.Id, gated, CancellationToken.None); + var run = client.RunAsync(clientCts.Token); + + try + { + await gated.WriteStarted.DefaultTimeout(); + var stopwatch = Stopwatch.StartNew(); + var close = service.CloseTerminal( + new CloseTerminalRequest { TerminalId = terminal.Id }, + TestServerCallContext.Create(cancellationToken: rpcCts.Token)); + await gated.WriteCancelled.DefaultTimeout(); + var cleanup = terminal.DisposeAsync().AsTask(); + + if (cancelRpc) + { + await rpcCts.CancelAsync(); + var exception = await Assert.ThrowsAnyAsync(() => close).DefaultTimeout(); + Assert.Equal(rpcCts.Token, exception.CancellationToken); + } + else + { + Assert.Equal(10, DashboardServiceImpl.CloseTerminalTimeoutSeconds); + var exception = await Assert.ThrowsAsync(() => close).TimeoutAfter(TimeSpan.FromSeconds(30)); + Assert.Equal(StatusCode.DeadlineExceeded, exception.StatusCode); + // Allow timer granularity without accepting a shorter production timeout. + Assert.True(stopwatch.Elapsed >= TimeSpan.FromSeconds(9.9), $"Close timed out after {stopwatch.Elapsed}."); + } + + Assert.False(cleanup.IsCompleted); + Assert.False(attachment.IsCompleted); + Assert.False(terminalService.TryGetTerminal(terminal.Id, out _)); + gated.ReleaseWrite(); + await cleanup.DefaultTimeout(); + await attachment.DefaultTimeout(); + } + finally + { + gated.ReleaseWrite(); + await terminal.DisposeAsync().AsTask().DefaultTimeout(); + await attachment.DefaultTimeout(); + await clientCts.CancelAsync(); + try + { + await run.DefaultTimeout(); + } + catch (OperationCanceledException) when (clientCts.IsCancellationRequested) + { + } + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task CloseTerminal_BlockingDisposal_TimeoutOrCancellationObservesLateFailure(bool fail, bool cancelRpc) + { + using var serviceData = CreateDashboardServiceData(); + await using var terminalService = TestTerminalService.Create(); + var sink = new TestSink(); + var logs = Channel.CreateUnbounded(); + sink.MessageLogged += log => logs.Writer.TryWrite(log); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var service = CreateDashboardService(serviceData, logger: logger, terminalService: terminalService); + using var rpcCts = new CancellationTokenSource(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var terminal = new TestAspireTerminal("blocking") + { + OnDispose = () => + { + started.TrySetResult(); + try + { + // A synchronous cancellation callback can block before DisposeAsync even returns a task. + release.Task.GetAwaiter().GetResult(); + return ValueTask.CompletedTask; + } + finally + { + completed.TrySetResult(); + } + } + }; + + try + { + // Keep the test's gate releasable even if disposal regresses to blocking the RPC synchronously. + var close = Task.Run(() => service.CloseTerminalAsync(terminal, rpcCts.Token)); + await started.Task.DefaultTimeout(); + if (cancelRpc) + { + await rpcCts.CancelAsync(); + var exception = await Assert.ThrowsAnyAsync(() => close).DefaultTimeout(); + Assert.Equal(rpcCts.Token, exception.CancellationToken); + } + else + { + var exception = await Assert.ThrowsAsync(() => close).TimeoutAfter(TimeSpan.FromSeconds(30)); + Assert.Equal(StatusCode.DeadlineExceeded, exception.StatusCode); + } + Assert.False(completed.Task.IsCompleted); + + var failure = new InvalidOperationException("Disposal callback failed."); + if (fail) + { + release.TrySetException(failure); + } + else + { + release.TrySetResult(); + } + await completed.Task.DefaultTimeout(); + + if (fail) + { + var log = await logs.Reader.ReadAsync().AsTask().DefaultTimeout(); + Assert.Equal(LogLevel.Error, log.LogLevel); + Assert.Equal($"Failed to dispose terminal {terminal.Id}.", log.Message); + var aggregate = Assert.IsType(log.Exception); + Assert.Same(failure, Assert.Single(aggregate.Flatten().InnerExceptions)); + } + } + finally + { + release.TrySetResult(); + await completed.Task.DefaultTimeout(); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task CloseTerminal_DisposalFailure_IsLoggedAndPropagated(bool timeoutException, bool synchronous) + { + using var serviceData = CreateDashboardServiceData(); + await using var terminalService = TestTerminalService.Create(); + var sink = new TestSink(); + var logs = Channel.CreateUnbounded(); + sink.MessageLogged += log => logs.Writer.TryWrite(log); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var service = CreateDashboardService(serviceData, logger: logger, terminalService: terminalService); + Exception failure = timeoutException ? new TimeoutException("Workload timeout.") : new InvalidOperationException("Disposal failed."); + var terminal = new TestAspireTerminal("failing") + { + OnDispose = () => synchronous ? throw failure : ValueTask.FromException(failure) + }; + + var exception = await Record.ExceptionAsync(() => service.CloseTerminalAsync(terminal, CancellationToken.None)).DefaultTimeout(); + + Assert.Same(failure, exception); + var log = await logs.Reader.ReadAsync().AsTask().DefaultTimeout(); + Assert.Equal(LogLevel.Error, log.LogLevel); + Assert.Equal($"Failed to dispose terminal {terminal.Id}.", log.Message); + Assert.Same(failure, Assert.Single(Assert.IsType(log.Exception).InnerExceptions)); + } + private static DashboardServiceImpl CreateDashboardService( DashboardServiceData dashboardServiceData, IHostEnvironment? hostEnvironment = null, diff --git a/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs b/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs index 8923e0d1d01..2fc97bef7b5 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs @@ -14,6 +14,7 @@ internal sealed class TestAspireTerminal(string id) : IAspireTerminal public TerminalOwner Owner => TerminalOwner.AppHost; public TerminalPlacement Placement => TerminalPlacement.Dialog; public bool IsDisposed { get; private set; } + public Func? OnDispose { get; set; } public void Start() => throw new NotSupportedException(); public void Show() => throw new NotSupportedException(); @@ -30,6 +31,6 @@ internal sealed class TestAspireTerminal(string id) : IAspireTerminal public ValueTask DisposeAsync() { IsDisposed = true; - return ValueTask.CompletedTask; + return OnDispose?.Invoke() ?? ValueTask.CompletedTask; } } diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index ef355d011a6..02b61444c16 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -18,6 +18,7 @@ public class TestDashboardClient : IDashboardClient private readonly Func>>? _resourceChannelProvider; private readonly Func>? _interactionChannelProvider; private readonly Func>? _terminalChannelProvider; + private readonly Func? _closeTerminal; private readonly Channel? _resourceCommandsChannel; private readonly Func>? _executeResourceCommand; private readonly Channel? _sendInteractionUpdateChannel; @@ -53,7 +54,8 @@ public TestDashboardClient( IList? initialResources = null, Task? whenConnected = null, bool isReadOnly = false, - Func>? terminalChannelProvider = null) + Func>? terminalChannelProvider = null, + Func? closeTerminal = null) { IsEnabled = isEnabled ?? false; IsReadOnly = isReadOnly; @@ -67,6 +69,7 @@ public TestDashboardClient( _sendInteractionUpdateChannel = sendInteractionUpdateChannel; _initialResources = initialResources; _terminalChannelProvider = terminalChannelProvider; + _closeTerminal = closeTerminal; } public ValueTask DisposeAsync() @@ -123,7 +126,7 @@ public async IAsyncEnumerable SubscribeTerminalsAsync([Enu public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) { ClosedTerminals.Enqueue(terminalId); - return Task.CompletedTask; + return _closeTerminal?.Invoke(terminalId, cancellationToken) ?? Task.CompletedTask; } public async IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) From 395cbc15f1ae4982d93c52a3ea1feac2d796710a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 11:27:57 +1000 Subject: [PATCH 037/106] Handle ended AppHost terminals without removing their tabs Report workload completion independently from transport teardown, reject further automation, and show an ended state without reconnecting. Keep registry entries until explicit disposal without retaining the terminal screen. Track native retained-session support in mitchdenny/hex1b#483. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.cs | 9 +- .../Components/Controls/TerminalView.razor.js | 48 +++++++-- .../ServiceClient/GrpcTerminalClientStream.cs | 9 ++ .../Terminal/TerminalWebSocketProxy.cs | 9 ++ .../wwwroot/js/hmp1-client.js | 7 ++ .../Dashboard/DashboardService.cs | 10 +- .../Dashboard/GrpcTerminalStream.cs | 8 ++ .../Dashboard/proto/dashboard_service.proto | 4 + .../Terminals/Hex1bAspireTerminal.cs | 54 +++++++--- .../Terminals/IAspireTerminal.cs | 8 ++ .../Terminals/TerminalService.cs | 4 +- .../Controls/TerminalViewTests.cs | 1 + .../Integration/Playwright/TerminalTests.cs | 84 ++++++++++++++++ .../Model/DashboardClientTests.cs | 98 +++++++++++++++++++ .../Dashboard/DashboardServiceTests.cs | 52 +++++++++- .../Dashboard/GrpcTerminalStreamTests.cs | 19 ++++ .../Terminals/Hex1bAspireTerminalTests.cs | 52 +++++++++- .../InteractionServiceTerminalTests.cs | 2 +- .../Terminals/TerminalServiceTests.cs | 2 +- .../Utils/TestAppHostTerminalViewer.cs | 2 +- tests/Shared/TestDashboardClient.cs | 7 +- 21 files changed, 456 insertions(+), 33 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index cb9d30e481c..96dd1d1c1b8 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -158,6 +158,9 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Inject] public required IStringLocalizer Loc { get; init; } + [Inject] + public required IStringLocalizer LayoutLoc { get; init; } + [Inject] public required NavigationManager NavigationManager { get; init; } @@ -340,6 +343,7 @@ private async Task InitializeTerminalAsync(string endpoint) TerminalDimensions = TerminalDimensionsLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSize)], Fit = FitLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSizeAuto)], FocusControlsHint = FocusControlsHintLabel ?? Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalFocusControlsHint)], + TerminalEnded = LayoutLoc[nameof(Dashboard.Resources.Layout.TerminalWindowEnded)], }); _appliedReadOnly = readOnly; } @@ -611,6 +615,9 @@ public sealed record TerminalViewOptions /// Hint describing how to move focus from the terminal to its controls. public required string FocusControlsHint { get; init; } + + /// Message displayed when the AppHost terminal's workload has ended. + public required string TerminalEnded { get; init; } } /// @@ -625,7 +632,7 @@ public sealed record TerminalToolbarState public int Generation { get; init; } /// - /// One of connecting, primary, viewer, no-primary. + /// One of connecting, primary, viewer, no-primary, ended. /// public string Status { get; init; } = "connecting"; diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 66f9ca6cc8a..46d5db320c3 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -102,7 +102,7 @@ function pickReconnectDelay(attempt) { } function scheduleReconnect(state) { - if (!state.reconnect.enabled) { + if (!state.reconnect.enabled || state.ended) { return; } if (state.reconnect.timer !== null) { @@ -120,7 +120,7 @@ function scheduleReconnect(state) { dbg(state, 'scheduleReconnect: scheduled', { attempt: state.reconnect.attempts, delayMs: delay }); state.reconnect.timer = setTimeout(() => { state.reconnect.timer = null; - if (!state.reconnect.enabled) { + if (!state.reconnect.enabled || state.ended) { return; } connectClient(state, state.wsUrl); @@ -180,6 +180,7 @@ const DEFAULT_CONTROL_LABELS = { terminalDimensions: "Terminal dimensions", fit: "Fit", focusControlsHint: "F6: Focus terminal controls", + terminalEnded: "This terminal has ended.", }; // Inject the WebMuxerDemo terminal-frame styles into exactly once @@ -1231,7 +1232,9 @@ function buildToolbarSnapshot(state) { let canTakeControl = false; let isPrimary = false; - if (!client || client.peerId === null) { + if (state.ended) { + status = 'ended'; + } else if (!client || client.peerId === null) { status = 'connecting'; } else if (client.isPrimary) { status = 'primary'; @@ -1254,7 +1257,7 @@ function buildToolbarSnapshot(state) { // after the JS terminal was disposed / replaced by another resource. generation: state.reconnect.generation, status, - connected: !!client && client.peerId !== null, + connected: !state.ended && !!client && client.peerId !== null, isPrimary, canTakeControl, sizeMode: state.sizeMode, @@ -1351,6 +1354,7 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { // Layout / sizing state (per-instance — we never use globals). chromeless, readOnly: !!options?.readOnly, + ended: false, // Whether the footer's fixed-resolution picker is offered. Dock panes // are sized by the dock splitter and always fit, so they get the font // stepper but not the picker. @@ -1503,7 +1507,7 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { // a cellWRatio ~half of the true value. That in turn made the Fit // dimensions report roughly double the real cols×rows. term.onResize(({ cols, rows }) => { - if (state.client) state.client.sendResize(cols, rows); + if (state.client && !state.ended) state.client.sendResize(cols, rows); updateTerminalControls(state); requestAnimationFrame(() => { if (state.term !== term) return; @@ -1521,7 +1525,7 @@ export async function initTerminal(element, wsUrl, dotNetRef, options) { term.onData((data) => { // Keep the transport open for output and other peers' automation. Gate the forwarding path as well as // xterm's keyboard/paste handling so no input can promote this viewer while it is read-only. - if (state.readOnly || !state.client) return; + if (state.readOnly || state.ended || !state.client) return; maybeAutoPromote(state); state.client.sendInput(textEncoder.encode(data)); }); @@ -1550,6 +1554,11 @@ function connectClient(state, wsUrl) { state.reconnect.generation++; const myGeneration = state.reconnect.generation; state.wsUrl = wsUrl; + state.ended = false; + updateReadOnly(state); + state.term.options.cursorBlink = true; + state.terminalFocusHint.textContent = state.labels.focusControlsHint; + state.terminalFocusHint.removeAttribute('role'); dbg(state, 'connectClient', { generation: myGeneration, attempts: state.reconnect.attempts, hadPriorClient: !!state.client }); @@ -1566,6 +1575,7 @@ function connectClient(state, wsUrl) { stale.onPeerLeave = null; stale.onResize = null; stale.onExit = null; + stale.onTerminalEnded = null; stale.onClose = null; try { stale.close(); } catch { /* ignore */ } state.client = null; @@ -1699,6 +1709,18 @@ function connectClient(state, wsUrl) { } catch { /* ignore */ } }; + client.onTerminalEnded = () => { + if (myGeneration !== state.reconnect.generation) return; + state.ended = true; + cancelPendingReconnect(state); + updateReadOnly(state); + state.term.options.cursorBlink = false; + state.terminalFocusHint.setAttribute('role', 'status'); + state.terminalFocusHint.textContent = state.labels.terminalEnded; + client.close(); + notifyToolbar(state); + }; + client.onClose = (ev) => { // Always log close events — this is the key forensic signal for // periodic-reconnect investigations. code/reason/wasClean tell @@ -1727,7 +1749,7 @@ function connectClient(state, wsUrl) { if (myGeneration !== state.reconnect.generation) { return; } - if (!state.reconnect.enabled) { + if (!state.reconnect.enabled || state.ended) { return; } notifyToolbar(state); // back to "connecting" @@ -1792,6 +1814,7 @@ export function disposeTerminal(id) { stale.onPeerLeave = null; stale.onResize = null; stale.onExit = null; + stale.onTerminalEnded = null; stale.onClose = null; try { stale.close(); } catch { /* ignore */ } state.client = null; @@ -1822,13 +1845,18 @@ export function setReadOnly(id, readOnly) { if (!state) return; state.readOnly = readOnly; - state.term.options.disableStdin = readOnly; - state.terminalBody.querySelector('.xterm-helper-textarea')?.setAttribute('aria-readonly', String(readOnly)); + updateReadOnly(state); if (!readOnly && state.chromeless) { maybeAutoPromote(state); } } +function updateReadOnly(state) { + const readOnly = state.readOnly || state.ended; + state.term.options.disableStdin = readOnly; + state.terminalBody.querySelector('.xterm-helper-textarea')?.setAttribute('aria-readonly', String(readOnly)); +} + export function setFontSizeFromHost(id, newSize) { const state = terminals.get(id); if (!state || typeof newSize !== 'number') return; @@ -1861,7 +1889,7 @@ export function setSizeModeFromHost(id, sizeKey) { } function maybeAutoPromote(state) { - if (state.readOnly) return; + if (state.readOnly || state.ended) return; const client = state.client; if (!client || client.peerId === null) return; if (client.isPrimary) return; diff --git a/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs b/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs index d4647db7b6b..7a6f124d394 100644 --- a/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs +++ b/src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs @@ -29,6 +29,8 @@ internal sealed class GrpcTerminalClientStream : Stream private bool _completed; private bool _disposed; + public bool TerminalEnded { get; private set; } + public GrpcTerminalClientStream( AsyncDuplexStreamingCall call, string terminalId, @@ -83,6 +85,13 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation return 0; } + if (_call.ResponseStream.Current.Ended) + { + TerminalEnded = true; + _completed = true; + return 0; + } + // A zero-length payload is not end of stream; keep waiting for real bytes. _remainder = _call.ResponseStream.Current.Data.Memory; } diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index 47ee16e400e..ee3cc5865cd 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -492,6 +492,15 @@ private static async Task BridgeAsync(WebSocket ws, var read = await upstream.ReadAsync(buffer.AsMemory(0, OutboundBufferSize), token).ConfigureAwait(false); if (read == 0) { + if (upstream is GrpcTerminalClientStream { TerminalEnded: true }) + { + // Binary messages remain opaque HMP1. The text control message "terminal-ended" + // distinguishes workload completion from a retryable transport disconnect, including + // terminals that ended before the browser could send its HMP1 ClientHello. + await ws.SendAsync("terminal-ended"u8.ToArray(), WebSocketMessageType.Text, + endOfMessage: true, token).ConfigureAwait(false); + } + // Upstream EOF — terminal host process died, the // replica recycled, or the host evicted this peer // (e.g. slow-consumer policy). Tear the WS down so diff --git a/src/Aspire.Dashboard/wwwroot/js/hmp1-client.js b/src/Aspire.Dashboard/wwwroot/js/hmp1-client.js index eb01485fd65..9a54cdd7a1d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hmp1-client.js +++ b/src/Aspire.Dashboard/wwwroot/js/hmp1-client.js @@ -212,6 +212,7 @@ export class Hmp1Client { this.onPeerLeave = null; this.onResize = null; this.onExit = null; + this.onTerminalEnded = null; this.onClose = null; } @@ -233,6 +234,12 @@ export class Hmp1Client { }); ws.addEventListener("message", (ev) => { + // Aspire's AppHost tunnel sends the text control message "terminal-ended" independently + // of binary HMP1 frames, including when the workload ended before the HMP1 handshake. + if (ev.data === "terminal-ended") { + if (this.onTerminalEnded) this.onTerminalEnded(); + return; + } this._buffer.push(ev.data); try { for (const frame of this._buffer.drain()) { diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 64cd14d334f..b1201cdd594 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -645,7 +645,15 @@ public override async Task AttachTerminal( // Returns once the terminal ends or the caller disconnects. Holding the call open for that whole time is // what keeps the tunnel alive, so this must not be fire-and-forget. - await terminalService.AttachAsync(selector.TerminalId, stream, cancellationToken).ConfigureAwait(false); + await terminalService.AttachAsync(selector.TerminalId, stream, async ct => + { + await stream.WriteEndedAsync(ct).ConfigureAwait(false); + + // Let the dashboard consume the ended notification and close the tunnel. Returning immediately + // can fail a concurrent ClientHello write, cancelling the proxy's reader before it sees the status. + // This retains only the viewer's RPC, not the completed Hex1b workload. + await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException ex) { diff --git a/src/Aspire.Hosting/Dashboard/GrpcTerminalStream.cs b/src/Aspire.Hosting/Dashboard/GrpcTerminalStream.cs index 083d0b74513..f4440f8ab65 100644 --- a/src/Aspire.Hosting/Dashboard/GrpcTerminalStream.cs +++ b/src/Aspire.Hosting/Dashboard/GrpcTerminalStream.cs @@ -86,6 +86,14 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella // and gRPC does not guarantee the payload is serialized before the write task completes. var frame = new TerminalServerFrame { Data = ByteString.CopyFrom(buffer.Span) }; + await WriteFrameAsync(frame, cancellationToken).ConfigureAwait(false); + } + + public Task WriteEndedAsync(CancellationToken cancellationToken) + => WriteFrameAsync(new TerminalServerFrame { Ended = true }, cancellationToken); + + private async Task WriteFrameAsync(TerminalServerFrame frame, CancellationToken cancellationToken) + { await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 4ee51620f6c..29538527dd6 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -506,6 +506,10 @@ message TerminalClientFrame { message TerminalServerFrame { // A chunk of the HMP1 byte stream flowing from the AppHost to the browser. bytes data = 1; + + // The workload has ended. Sent separately from HMP1 bytes so it also works before + // the HMP1 handshake and does not depend on Hex1b delivering its Exit frame. + bool ended = 2; } //////////////////////////////////////////// diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index cd31f2df45d..d3494e4b009 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -25,10 +25,11 @@ internal sealed class Hex1bAspireTerminal : IAspireTerminal // dropping or blocking an attach would strand the RPC that is waiting to be served. private readonly Channel _clients = Channel.CreateUnbounded(); - // Two distinct signals, deliberately. _workloadCts stops the workload; _sessionEnded reports that - // teardown has *finished*. Collapsing them into one token releases attached clients while Hex1b is - // still disposing, which lets a gRPC handler return and dispose the transport out from under it. + // Cancellation requests a stop; workload completion updates viewers; session completion reports that + // teardown has finished. Keeping these separate lets viewers display an ended state without allowing + // the gRPC handler to dispose a transport that Hex1b is still accessing. private readonly CancellationTokenSource _workloadCts = new(); + private readonly TaskCompletionSource _workloadEnded = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _sessionEnded = new(TaskCreationOptions.RunContinuationsAsynchronously); // Aspire.Hosting targets net8.0, which predates System.Threading.Lock, so this is a plain monitor gate. @@ -63,6 +64,8 @@ public Hex1bAspireTerminal(TerminalService owner, string id, string title, Termi public TerminalDescriptor Descriptor => new(Id, Title); + internal Task WorkloadEnded => _workloadEnded.Task; + public void Start() => EnsureStarted(); public void Show() @@ -99,21 +102,33 @@ public void Retitle(string title) /// A task that completes once this viewer disconnects or the terminal ends, and all operations on the /// caller's transport have finished. Callers keep their transport open until it completes. /// - public async Task AttachAsync(Stream clientStream, CancellationToken cancellationToken) + public async Task AttachAsync(Stream clientStream, Func onEnded, CancellationToken cancellationToken) { - EnsureStarted(); - // Hex1b owns and disposes the wrapper, never the gRPC stream. Closing it cancels only this viewer's // I/O and waits for outstanding accesses, even when Hex1b's other pump is still winding down. var attachment = new TerminalClientStream(clientStream); await using var _ = attachment.ConfigureAwait(false); - if (!_clients.Writer.TryWrite(attachment)) + lock (_gate) { - throw new InvalidOperationException($"Terminal '{Id}' is no longer accepting clients."); + if (!_workloadEnded.Task.IsCompleted) + { + EnsureStarted(); + if (!_clients.Writer.TryWrite(attachment)) + { + throw new InvalidOperationException($"Terminal '{Id}' is no longer accepting clients."); + } + } } var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled); + await Task.WhenAny(_workloadEnded.Task, attachment.Released, cancelled.Task).ConfigureAwait(false); + if (_workloadEnded.Task.IsCompleted) + { + cancellationToken.ThrowIfCancellationRequested(); + await onEnded(cancellationToken).ConfigureAwait(false); + } + await Task.WhenAny(_sessionEnded.Task, attachment.Released, cancelled.Task).ConfigureAwait(false); } @@ -129,7 +144,7 @@ private Hex1bTerminal EnsureStarted() { lock (_gate) { - if (_stopped) + if (_stopped || _workloadEnded.Task.IsCompleted) { throw new InvalidOperationException($"Terminal '{Id}' has already stopped."); } @@ -177,9 +192,18 @@ private async Task RunTerminalAsync(Hex1bTerminal terminal) } finally { - // Dispose *before* releasing attached clients. Hex1b may still write to the attached transports - // while it tears the terminal down; signalling completion first would let an attach caller return - // and dispose its transport out from under Hex1b. + lock (_gate) + { + // Hex1b cannot serve completion to later HMP clients. Keep Aspire's registry entry (and dock tab), + // but report completion ourselves rather than attaching to the disposed terminal. + // Replace the separate notification when native ended-session support is available: + // https://github.com/mitchdenny/hex1b/issues/483. + _clients.Writer.TryComplete(); + _workloadEnded.TrySetResult(); + } + + // Complete _sessionEnded only after disposal. Unlike _workloadEnded's UI notification, this signal + // releases attached clients; Hex1b may still write to their transports during teardown. try { await terminal.DisposeAsync().ConfigureAwait(false); @@ -219,6 +243,11 @@ public string GetScreenText() { lock (_gate) { + if (_stopped || _workloadEnded.Task.IsCompleted) + { + throw new InvalidOperationException($"Terminal '{Id}' has already stopped."); + } + return TerminalAutomation.GetScreenText(_automator); } } @@ -243,6 +272,7 @@ public Task StopAsync() // Registered but never started, so there is nothing to wind down. _workloadCts.Cancel(); _workloadCts.Dispose(); + _workloadEnded.TrySetResult(); _sessionEnded.TrySetResult(); return _sessionEnded.Task; } diff --git a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs index 41934d8d603..cf4588994aa 100644 --- a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/IAspireTerminal.cs @@ -28,6 +28,10 @@ namespace Aspire.Hosting.Terminals; /// the workload belongs to the resource, so disposing only releases /// Aspire's handle and leaves the workload running. /// +/// +/// When an AppHost-owned workload ends, its terminal remains in the dashboard until disposed, but no longer +/// accepts input or automation. Reopening an ended terminal displays its ended state rather than replaying output. +/// /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] public interface IAspireTerminal : IAsyncDisposable @@ -83,11 +87,13 @@ public interface IAspireTerminal : IAsyncDisposable /// /// Sends text to the terminal's workload as though it had been typed. /// + /// The AppHost-owned terminal has already stopped. Task SendTextAsync(string text, CancellationToken cancellationToken = default); /// /// Sends a single non-printable key to the terminal's workload. /// + /// The AppHost-owned terminal has already stopped. Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default); /// @@ -97,10 +103,12 @@ public interface IAspireTerminal : IAsyncDisposable /// How long to wait before giving up. Defaults to 30 seconds. /// Cancellation token. /// The text did not appear before elapsed. + /// The AppHost-owned terminal has already stopped. Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default); /// /// Gets the current contents of the terminal screen, with lines separated by newlines. /// + /// The AppHost-owned terminal has already stopped. string GetScreenText(); } diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 41cc868b976..71dc4049363 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -159,14 +159,14 @@ internal IAspireTerminal CreateTerminal(string title, TerminalPlacement placemen /// A task that completes after this viewer disconnects or the terminal ends, once all operations on the /// caller's transport have finished. Cancellation disconnects only this viewer. /// - internal Task AttachAsync(string terminalId, Stream clientStream, CancellationToken cancellationToken) + internal Task AttachAsync(string terminalId, Stream clientStream, Func onEnded, CancellationToken cancellationToken) { if (!_terminals.TryGetValue(terminalId, out var terminal)) { throw new InvalidOperationException($"There is no terminal with id '{terminalId}'."); } - return terminal.AttachAsync(clientStream, cancellationToken); + return terminal.AttachAsync(clientStream, onEnded, cancellationToken); } /// diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 802ddbfe40f..e25c1f0f620 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -34,6 +34,7 @@ public void ReadOnly_InitialAndUpdatedStatePreservesConnection(bool initialReadO var options = Assert.IsType(Assert.Single(init.Invocations).Arguments[3]); Assert.Equal(initialReadOnly, options.ReadOnly); + Assert.Equal(Dashboard.Resources.Layout.TerminalWindowEnded, options.TerminalEnded); Assert.Empty(update.Invocations); cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, !initialReadOnly)); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs index 435e168a78c..e5f4082e2cc 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Binary; using System.Text; using System.Text.Json; +using System.Threading.Channels; using Aspire.Dashboard.Model; using Aspire.Dashboard.Terminal; using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; @@ -30,6 +32,88 @@ public TerminalTests(TerminalDashboardServerFixture dashboardServerFixture) _dashboardServerFixture = dashboardServerFixture; } + [Theory] + [InlineData(false)] + [InlineData(true)] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task AppHostWorkloadEnded_DisablesInputAndReconnectUntilEndpointChanges(bool beforeHandshake) + { + await RunTestAsync(async page => + { + await page.GotoAsync("/").DefaultTimeout(); + await page.Clock.InstallAsync(); + var connections = Channel.CreateUnbounded(); + var connectionCount = 0; + await page.RouteWebSocketAsync("**/api/apphost-terminal?*", route => + { + Interlocked.Increment(ref connectionCount); + connections.Writer.TryWrite(route); + }); + var terminalId = await page.EvaluateAsync(""" + async () => { + const module = await import('/Components/Controls/TerminalView.razor.js'); + const container = document.createElement('div'); + container.style.cssText = 'position:fixed;inset:0;z-index:10000'; + document.body.appendChild(container); + const endpoint = new URL('/api/apphost-terminal?terminalId=ended', location.href); + endpoint.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return await module.initTerminal(container, endpoint.href, null, { + chromeless: true, + terminalEnded: 'Terminal ended' + }); + } + """); + var connection = await connections.Reader.ReadAsync().AsTask().DefaultTimeout(); + if (!beforeHandshake) + { + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + peerId = "viewer", + width = ProducerColumns, + height = ProducerRows + }); + var hello = new byte[5 + payload.Length]; + hello[0] = (byte)TestHmp1FrameType.Hello; + BinaryPrimitives.WriteInt32LittleEndian(hello.AsSpan(1), payload.Length); + payload.CopyTo(hello.AsSpan(5)); + connection.Send(hello); + await page.WaitForFunctionAsync(""" + async id => { + const module = await import('/Components/Controls/TerminalView.razor.js'); + return module.getToolbarState(id)?.connected === true; + } + """, terminalId).DefaultTimeout(); + } + + connection.Send("terminal-ended"); + var terminalInput = page.Locator(".xterm-helper-textarea"); + await Assertions.Expect(terminalInput).ToHaveAttributeAsync("aria-readonly", "true"); + await Assertions.Expect(page.GetByRole(AriaRole.Status).Filter(new() { HasText = "Terminal ended" })) + .ToBeVisibleAsync(); + await SetReadOnlyAsync(page, terminalId, false); + await Assertions.Expect(terminalInput).ToHaveAttributeAsync("aria-readonly", "true"); + await page.Clock.RunForAsync(5_000); + Assert.Equal(1, Volatile.Read(ref connectionCount)); + Assert.Equal("ended", await page.EvaluateAsync(""" + async id => { + const module = await import('/Components/Controls/TerminalView.razor.js'); + return module.getToolbarState(id).status; + } + """, terminalId)); + + await page.EvaluateAsync(""" + async id => { + const module = await import('/Components/Controls/TerminalView.razor.js'); + const endpoint = new URL('/api/apphost-terminal?terminalId=next', location.href); + endpoint.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + module.reconnectTerminal(id, endpoint.href); + } + """, terminalId); + await connections.Reader.ReadAsync().AsTask().DefaultTimeout(); + await Assertions.Expect(terminalInput).ToHaveAttributeAsync("aria-readonly", "false"); + }); + } + [Theory] [InlineData(false, false)] [InlineData(false, true)] diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index eae2d9c07ed..eecab685409 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -3,17 +3,27 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Net.WebSockets; +using System.Text; using System.Threading.Channels; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Terminal; +using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Utils; using Aspire.DashboardService.Proto.V1; using Aspire.Tests; +using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Semver; @@ -24,6 +34,94 @@ namespace Aspire.Dashboard.Tests.Model; public sealed class DashboardClientTests(ITestOutputHelper testOutputHelper) : IDisposable { + [Fact] + public async Task TerminalStream_ProxySendsEndedMessageBeforeClosingWebSocket() + { + var channel = Channel.CreateUnbounded(); + channel.Writer.TryWrite(new TerminalServerFrame { Ended = true }); + var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var call = new AsyncDuplexStreamingCall( + new ClientStreamWriter(), + new AsyncStreamReader(channel: channel.Reader), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => disposed.TrySetResult()); + using var stream = new GrpcTerminalClientStream(call, "terminal"); + var dashboardClient = new TestDashboardClient(attachTerminal: (_, _) => Task.FromResult(stream)); + using var server = new TestServer(new WebHostBuilder().Configure(app => + { + app.UseWebSockets(); + app.Run(context => + { + context.Request.Scheme = "https"; + context.Request.Host = new HostString("dashboard.example.com"); + return TerminalWebSocketProxy.HandleAppHostTerminalAsync(context, dashboardClient, NullLogger.Instance, "test"); + }); + })); + var client = server.CreateWebSocketClient(); + client.ConfigureRequest = request => request.Headers.Origin = "https://dashboard.example.com"; + using var socket = await client.ConnectAsync( + new Uri("wss://dashboard.example.com/api/apphost-terminal?terminalId=terminal"), CancellationToken.None).DefaultTimeout(); + var buffer = new byte[64]; + + var message = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None).DefaultTimeout(); + Assert.Equal(WebSocketMessageType.Text, message.MessageType); + Assert.True(message.EndOfMessage); + Assert.Equal("terminal-ended", Encoding.UTF8.GetString(buffer, 0, message.Count)); + await disposed.Task.DefaultTimeout(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TerminalStream_EndedFrameIsDistinctFromTransportEof(bool ended) + { + using var call = new AsyncDuplexStreamingCall( + new ClientStreamWriter(), + new AsyncStreamReader( + [ + new TerminalServerFrame(), + new TerminalServerFrame { Data = ByteString.CopyFromUtf8("output") }, + new TerminalServerFrame { Ended = ended } + ]), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => { }); + using var stream = new GrpcTerminalClientStream(call, "terminal"); + var buffer = new byte[3]; + + Assert.Equal(3, await stream.ReadAsync(buffer)); + Assert.Equal("out"u8.ToArray(), buffer); + Assert.False(stream.TerminalEnded); + Assert.Equal(3, await stream.ReadAsync(buffer)); + Assert.Equal("put"u8.ToArray(), buffer); + Assert.False(stream.TerminalEnded); + Assert.Equal(0, await stream.ReadAsync(buffer)); + Assert.Equal(ended, stream.TerminalEnded); + Assert.Equal(0, await stream.ReadAsync(buffer)); + } + + [Fact] + public async Task TerminalStream_EndedBeforeHandshakeDoesNotWaitForMoreFrames() + { + var channel = Channel.CreateUnbounded(); + channel.Writer.TryWrite(new TerminalServerFrame { Ended = true }); + using var call = new AsyncDuplexStreamingCall( + new ClientStreamWriter(), + new AsyncStreamReader(channel: channel.Reader), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => { }); + using var stream = new GrpcTerminalClientStream(call, "terminal"); + + // The server keeps the RPC open until the proxy consumes this status and disconnects. + Assert.Equal(0, await stream.ReadAsync(new byte[1]).AsTask().DefaultTimeout()); + Assert.True(stream.TerminalEnded); + } + private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => { builder.AddXunit(testOutputHelper, LogLevel.Trace, DateTimeOffset.UtcNow); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index cbfe09cd03e..a87949e9e1b 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -1270,6 +1270,56 @@ public void ResolveFiles_UnknownInput_ReturnsNull() Assert.Empty(result); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AttachTerminal_WorkloadEndedReportsStatusWithoutHmpHandshake(bool endedBeforeAttach) + { + using var serviceData = CreateDashboardServiceData(); + await using var terminalService = TestTerminalService.Create(); + var service = CreateDashboardService(serviceData, terminalService: terminalService); + var output = new Pipe(); + await using var reader = output.Reader.AsStream(); + await using var writer = output.Writer.AsStream(); + var workload = new StreamWorkloadAdapter(reader, Stream.Null); + await using var terminal = terminalService.CreateTerminal("Ended", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + terminal.Start(); + await writer.WriteAsync("ready\r\n"u8.ToArray()); + await terminal.WaitForTextAsync("ready").DefaultTimeout(); + + if (endedBeforeAttach) + { + workload.SignalDisconnected(); + await Assert.IsType(terminal).WorkloadEnded.DefaultTimeout(); + } + + using var cts = new CancellationTokenSource(); + var context = TestServerCallContext.Create(cancellationToken: cts.Token); + var requests = new TestAsyncStreamReader(context); + var responses = new TestServerStreamWriter(context); + requests.AddMessage(new TerminalClientFrame { TerminalId = terminal.Id }); + var attachment = service.AttachTerminal(requests, responses, context); + try + { + if (!endedBeforeAttach) + { + workload.SignalDisconnected(); + } + + var status = await responses.ReadNextAsync().DefaultTimeout(); + Assert.True(status.Ended); + Assert.True(status.Data.IsEmpty); + Assert.False(attachment.IsCompleted); + Assert.True(terminalService.TryGetTerminal(terminal.Id, out _)); + } + finally + { + await cts.CancelAsync(); + await attachment.DefaultTimeout(); + } + } + [Fact] public async Task CloseTerminal_UnknownId_Succeeds() { @@ -1330,7 +1380,7 @@ public async Task CloseTerminal_PendingTransportCleanup_TimeoutOrCancellationDoe using var clientCts = new CancellationTokenSource(); using var rpcCts = new CancellationTokenSource(); await using var client = Hex1bTerminal.CreateBuilder().WithHeadless().WithHmp1Stream(clientStream).Build(); - var attachment = terminalService.AttachAsync(terminal.Id, gated, CancellationToken.None); + var attachment = terminalService.AttachAsync(terminal.Id, gated, _ => Task.CompletedTask, CancellationToken.None); var run = client.RunAsync(clientCts.Token); try diff --git a/tests/Aspire.Hosting.Tests/Dashboard/GrpcTerminalStreamTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/GrpcTerminalStreamTests.cs index ec679794d58..95e512370eb 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/GrpcTerminalStreamTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/GrpcTerminalStreamTests.cs @@ -10,6 +10,25 @@ namespace Aspire.Hosting.Tests.Dashboard; public class GrpcTerminalStreamTests { + [Fact] + public async Task WriteEndedAsync_WritesLifecycleFrameSeparatelyFromHmpBytes() + { + var context = TestServerCallContext.Create(); + var requestStream = new TestAsyncStreamReader(context); + var responseStream = new TestServerStreamWriter(context); + await using var stream = new GrpcTerminalStream(requestStream, responseStream); + + await stream.WriteAsync("output"u8.ToArray()); + await stream.WriteEndedAsync(CancellationToken.None); + + var output = await responseStream.ReadNextAsync(); + Assert.Equal("output", output.Data.ToStringUtf8()); + Assert.False(output.Ended); + var ended = await responseStream.ReadNextAsync(); + Assert.True(ended.Ended); + Assert.True(ended.Data.IsEmpty); + } + [Fact] public async Task ReadAsync_SplitsSingleFrameAcrossReads() { diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs index dd5b5bba7bf..b420cface56 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -15,6 +15,56 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class Hex1bAspireTerminalTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WorkloadExit_EndsAutomationAndKeepsTabUntilDisposed(bool attachViewer) + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + var workload = new StreamWorkloadAdapter(outputReader, Stream.Null); + await using var terminal = service.CreateTerminal("Ended", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + await using var viewer = attachViewer ? await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id) : null; + terminal.Start(); + await outputWriter.WriteAsync("ready\r\n"u8.ToArray()); + await terminal.WaitForTextAsync("ready").DefaultTimeout(); + + // Raw stream workloads report disconnection explicitly. Observe output first; Hex1b's completion is + // not an output-drain barrier, and this test makes no claim about preserving the final screen. + workload.SignalDisconnected(); + await Assert.IsType(terminal).WorkloadEnded.DefaultTimeout(); + + Assert.True(service.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + using var subscription = service.SubscribeDockTerminals(); + Assert.Equal(terminal.Id, Assert.Single(subscription.InitialState).Id); + Assert.Throws(terminal.Start); + Assert.Throws(terminal.GetScreenText); + await Assert.ThrowsAsync(() => terminal.SendTextAsync("input")); + await Assert.ThrowsAsync(() => terminal.SendKeyAsync(AspireTerminalKey.Enter)); + await Assert.ThrowsAsync(() => terminal.WaitForTextAsync("never")); + + // Reopening an ended tab must report completion without queuing a client for Hex1b's disposed server. + // In particular, it must not need a ClientHello or restart the workload. + using var reconnected = new MemoryStream(); + var endedNotifications = 0; + await service.AttachAsync(terminal.Id, reconnected, _ => + { + endedNotifications++; + return Task.CompletedTask; + }, CancellationToken.None).DefaultTimeout(); + Assert.Equal(1, endedNotifications); + Assert.Equal(0, reconnected.Length); + + await terminal.DisposeAsync().AsTask().DefaultTimeout(); + Assert.False(service.TryGetTerminal(terminal.Id, out _)); + using var afterClose = service.SubscribeDockTerminals(); + Assert.Empty(afterClose.InitialState); + } + [Fact] public async Task AttachAsync_MultipleViewersCanDisconnectAndReconnectWithoutStoppingTheWorkload() { @@ -80,7 +130,7 @@ public async Task AttachAsync_CancellationDuringHandshakeWaitsForTheOutstandingW using var attachmentCts = new CancellationTokenSource(); using var clientCts = new CancellationTokenSource(); await using var client = Hex1bTerminal.CreateBuilder().WithHeadless().WithHmp1Stream(clientStream).Build(); - var attachment = service.AttachAsync(terminal.Id, gated, attachmentCts.Token); + var attachment = service.AttachAsync(terminal.Id, gated, _ => Task.CompletedTask, attachmentCts.Token); var run = client.RunAsync(clientCts.Token); try diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index 216d20b8ff7..a29a5d1691b 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -190,7 +190,7 @@ public async Task PromptInputsAsync_TerminalDisposedAfterPublishing_AttachmentSt await terminal.DisposeAsync(); var ex = await Assert.ThrowsAsync( - () => terminalService.AttachAsync(terminal.Id, Stream.Null, CancellationToken.None)).DefaultTimeout(); + () => terminalService.AttachAsync(terminal.Id, Stream.Null, _ => Task.CompletedTask, CancellationToken.None)).DefaultTimeout(); Assert.Equal($"There is no terminal with id '{terminal.Id}'.", ex.Message); cts.Cancel(); var result = await prompt.DefaultTimeout(); diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index cf676031a59..85ed22d61e6 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -161,7 +161,7 @@ public async Task AttachAsync_UnknownTerminal_Throws() using var stream = new MemoryStream(); await Assert.ThrowsAsync( - () => service.AttachAsync("does-not-exist", stream, CancellationToken.None)).DefaultTimeout(); + () => service.AttachAsync("does-not-exist", stream, _ => Task.CompletedTask, CancellationToken.None)).DefaultTimeout(); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs b/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs index 5b7ed6ce377..f2f4c4e024e 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs @@ -39,7 +39,7 @@ private TestAppHostTerminalViewer(TerminalService service, string terminalId) }) .Build(); - _attachment = service.AttachAsync(terminalId, _serverStream, _attachmentCts.Token); + _attachment = service.AttachAsync(terminalId, _serverStream, _ => Task.CompletedTask, _attachmentCts.Token); _run = _client.RunAsync(_clientCts.Token); } diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index 02b61444c16..ac67f01ef37 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -19,6 +19,7 @@ public class TestDashboardClient : IDashboardClient private readonly Func>? _interactionChannelProvider; private readonly Func>? _terminalChannelProvider; private readonly Func? _closeTerminal; + private readonly Func>? _attachTerminal; private readonly Channel? _resourceCommandsChannel; private readonly Func>? _executeResourceCommand; private readonly Channel? _sendInteractionUpdateChannel; @@ -55,7 +56,8 @@ public TestDashboardClient( Task? whenConnected = null, bool isReadOnly = false, Func>? terminalChannelProvider = null, - Func? closeTerminal = null) + Func? closeTerminal = null, + Func>? attachTerminal = null) { IsEnabled = isEnabled ?? false; IsReadOnly = isReadOnly; @@ -70,6 +72,7 @@ public TestDashboardClient( _initialResources = initialResources; _terminalChannelProvider = terminalChannelProvider; _closeTerminal = closeTerminal; + _attachTerminal = attachTerminal; } public ValueTask DisposeAsync() @@ -99,7 +102,7 @@ public Task UploadFileAsync(Stream fileStream, string fileName, long exp public Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) { - return Task.FromResult(new MemoryStream()); + return _attachTerminal?.Invoke(terminalId, cancellationToken) ?? Task.FromResult(new MemoryStream()); } public async IAsyncEnumerable SubscribeTerminalsAsync([EnumeratorCancellation] CancellationToken cancellationToken) From 814825fd97cd58342e871b30be95beff3dc66f5a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 11:41:39 +1000 Subject: [PATCH 038/106] Rebind detached terminal watchers on route changes Reset route-specific title and ended state, cancel and join superseded subscriptions, ignore stale updates, and stop AppHost watches for resource routes. Cover repeated parameters, rapid navigation, and disposal during rebinding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Pages/TerminalWindow.razor.cs | 100 +++++--- .../Pages/TerminalWindowTests.cs | 217 ++++++++++++++++++ .../Shared/TerminalSetupHelpers.cs | 1 + tests/Shared/TestDashboardClient.cs | 7 + 4 files changed, 290 insertions(+), 35 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs index b71b6bba6ff..104d5915d44 100644 --- a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs @@ -23,13 +23,15 @@ namespace Aspire.Dashboard.Components.Pages; /// public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable { - private readonly CancellationTokenSource _cts = new(); - private string? _endpoint; private string _title = string.Empty; private bool _ended; private bool _disposed; - private Task? _watchTask; + private (string? TerminalId, string? ResourceName, int ReplicaIndex)? _routeIdentity; + private int _watchGeneration; + private CancellationTokenSource? _watchCts; + // Also tracks in-flight cancellation so overlapping route changes and disposal join the same cleanup. + private Task _watchTask = Task.CompletedTask; /// /// Gets or sets the id of an AppHost-owned dock terminal to attach to. @@ -58,27 +60,39 @@ public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable [Inject] public required ILogger Logger { get; init; } - protected override void OnParametersSet() + protected override async Task OnParametersSetAsync() { - if (TerminalId is { Length: > 0 } terminalId) + var terminalId = TerminalId is { Length: > 0 } ? TerminalId : null; + var resourceName = terminalId is null && ResourceName is { Length: > 0 } ? ResourceName : null; + var replicaIndex = resourceName is not null ? ReplicaIndex : 0; + var routeIdentity = (terminalId, resourceName, replicaIndex); + if (_disposed || _routeIdentity == routeIdentity) { - _endpoint = $"/api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}"; - - // The title of an AppHost terminal is owned by the AppHost and can change while the window is open, and - // the terminal can also be closed out from under it. Both arrive on the watch stream, so the window - // follows it rather than showing a stale name or a dead grid. - _title = terminalId; - _watchTask ??= Task.Run(() => WatchTerminalsAsync(terminalId, _cts.Token), _cts.Token); + return; } - else if (ResourceName is { Length: > 0 } resourceName) + + _routeIdentity = routeIdentity; + var generation = ++_watchGeneration; + _ended = false; + _endpoint = terminalId is not null ? $"/api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}" : null; + _title = terminalId ?? (resourceName is not null + ? replicaIndex > 0 ? $"{resourceName} #{replicaIndex}" : resourceName + : string.Empty); + + await StopWatchingAsync(); + if (_disposed || generation != _watchGeneration || terminalId is null) { - // Resource terminals are named by the resource, which does not change for the life of the window. - _endpoint = null; - _title = ReplicaIndex > 0 ? $"{resourceName} #{ReplicaIndex}" : resourceName; + return; } + + // Only AppHost terminals need metadata updates. A newer route may have replaced this one while + // cancellation was awaiting an old watch, so don't start a subscription until its identity is rechecked. + _watchCts = new CancellationTokenSource(); + var cancellationToken = _watchCts.Token; + _watchTask = Task.Run(() => WatchTerminalsAsync(terminalId, generation, cancellationToken), cancellationToken); } - private async Task WatchTerminalsAsync(string terminalId, CancellationToken cancellationToken) + private async Task WatchTerminalsAsync(string terminalId, int generation, CancellationToken cancellationToken) { try { @@ -86,7 +100,9 @@ private async Task WatchTerminalsAsync(string terminalId, CancellationToken canc { await InvokeAsync(() => { - if (_disposed) + // An update can already be queued on the renderer when its subscription is cancelled. + // Compare generations, not just IDs: navigating away and back also replaces the watch. + if (_disposed || generation != _watchGeneration || cancellationToken.IsCancellationRequested) { return; } @@ -105,9 +121,9 @@ await InvokeAsync(() => }).ConfigureAwait(false); } } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // The window is closing. + // The window is closing or has switched to another terminal. } catch (Exception ex) { @@ -162,6 +178,34 @@ private bool MarkEnded() return true; } + private Task StopWatchingAsync() + { + if (_watchCts is { } cts) + { + _watchCts = null; + _watchTask = CancelWatchAsync(cts, _watchTask); + } + + return _watchTask; + } + + private static async Task CancelWatchAsync(CancellationTokenSource cts, Task watchTask) + { + try + { + await cts.CancelAsync().ConfigureAwait(false); + await watchTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + // Task.Run can be cancelled before the watch delegate starts. + } + finally + { + cts.Dispose(); + } + } + /// public async ValueTask DisposeAsync() { @@ -171,20 +215,6 @@ public async ValueTask DisposeAsync() } _disposed = true; - await _cts.CancelAsync().ConfigureAwait(false); - - if (_watchTask is { } watchTask) - { - try - { - await watchTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected. We cancelled _cts immediately above, so the watch task ends by design. - } - } - - _cts.Dispose(); + await StopWatchingAsync().ConfigureAwait(false); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs index 4f0835bffd1..e0976111a30 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs @@ -8,6 +8,8 @@ using Aspire.Dashboard.Tests.Shared; using Aspire.DashboardService.Proto.V1; using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.InternalTesting; using Xunit; @@ -15,6 +17,217 @@ namespace Aspire.Dashboard.Components.Tests.Pages; public class TerminalWindowTests : DashboardTestContext { + [Fact] + public async Task SameRoute_PreservesTitleEndedStateAndSubscription() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var head = RenderComponent(); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "terminal", "Shell")); + head.WaitForAssertion(() => Assert.Equal("Shell", head.Find("title").TextContent)); + + cut.SetParametersAndRender(builder => builder + .Add(p => p.TerminalId, "terminal") + .Add(p => p.ResourceName, "unused") + .Add(p => p.ReplicaIndex, 3)); + Assert.Equal("Shell", head.Find("title").TextContent); + Assert.Equal(1, client.TerminalSubscriptionCount); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "terminal")); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-window-ended"))); + cut.SetParametersAndRender(builder => builder.Add(p => p.TerminalId, "terminal")); + Assert.Single(cut.FindAll(".terminal-window-ended")); + Assert.Equal(1, client.ActiveTerminalSubscriptionCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AppHostRouteChange_ReplacesWatchAndResetsEndedState(bool firstTerminalEnded) + { + var firstUpdates = Channel.CreateUnbounded(); + var secondUpdates = Channel.CreateUnbounded(); + var subscriptions = 0; + var client = new TestDashboardClient(terminalChannelProvider: () => + Interlocked.Increment(ref subscriptions) == 1 ? firstUpdates : secondUpdates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var head = RenderComponent(); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "first")); + await firstUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "first", "First shell")); + head.WaitForAssertion(() => Assert.Equal("First shell", head.Find("title").TextContent)); + if (firstTerminalEnded) + { + await firstUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "first")); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-window-ended"))); + } + + await SetTerminalAsync(cut, "second").DefaultTimeout(); + cut.WaitForAssertion(() => + { + Assert.Equal(2, client.TerminalSubscriptionCount); + Assert.Equal(1, client.ActiveTerminalSubscriptionCount); + Assert.Equal("/api/apphost-terminal?terminalId=second", cut.FindComponent().Instance.EndpointPathAndQuery); + Assert.Equal("second", head.Find("title").TextContent); + Assert.Empty(cut.FindAll(".terminal-window-ended")); + }); + await secondUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "second", "Second shell")); + head.WaitForAssertion(() => Assert.Equal("Second shell", head.Find("title").TextContent)); + + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + Assert.Equal(0, client.ActiveTerminalSubscriptionCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResourceRoute_CancelsAppHostWatchAndResetsRouteState(bool firstTerminalEnded) + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var head = RenderComponent(); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "terminal", "Shell")); + head.WaitForAssertion(() => Assert.Equal("Shell", head.Find("title").TextContent)); + if (firstTerminalEnded) + { + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "terminal")); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-window-ended"))); + } + + cut.SetParametersAndRender(builder => builder + .Add(p => p.TerminalId, null) + .Add(p => p.ResourceName, "resource") + .Add(p => p.ReplicaIndex, 2)); + cut.WaitForAssertion(() => + { + Assert.Equal(0, client.ActiveTerminalSubscriptionCount); + Assert.Empty(cut.FindAll(".terminal-window-ended")); + var terminal = cut.FindComponent().Instance; + Assert.Null(terminal.EndpointPathAndQuery); + Assert.Equal("resource", terminal.ResourceName); + Assert.Equal(2, terminal.ReplicaIndex); + Assert.Equal("resource #2", head.Find("title").TextContent); + }); + + cut.SetParametersAndRender(builder => builder.Add(p => p.ReplicaIndex, 3)); + Assert.Equal("resource #3", head.Find("title").TextContent); + Assert.Equal(3, cut.FindComponent().Instance.ReplicaIndex); + Assert.Equal(1, client.TerminalSubscriptionCount); + + await SetTerminalAsync(cut, "next").DefaultTimeout(); + cut.WaitForAssertion(() => Assert.Equal(2, client.TerminalSubscriptionCount)); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "next", "Next shell")); + head.WaitForAssertion(() => Assert.Equal("Next shell", head.Find("title").TextContent)); + Assert.Equal("/api/apphost-terminal?terminalId=next", cut.FindComponent().Instance.EndpointPathAndQuery); + } + + [Theory] + [InlineData(false, TerminalChangeType.Removed)] + [InlineData(false, TerminalChangeType.Retitled)] + [InlineData(true, TerminalChangeType.Removed)] + [InlineData(true, TerminalChangeType.Retitled)] + public async Task RapidRouteChanges_IgnoreOldUpdatesAndOnlyWatchLatest(bool returnToFirst, TerminalChangeType changeType) + { + var firstUpdates = Channel.CreateUnbounded(); + var latestUpdates = Channel.CreateUnbounded(); + var subscriptions = 0; + var delayedUpdate = TerminalSetupHelpers.Change(changeType, "first", "Stale title"); + var updateReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseUpdate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var client = new TestDashboardClient(terminalChannelProvider: () => + Interlocked.Increment(ref subscriptions) == 1 ? firstUpdates : latestUpdates) + { + BeforeTerminalUpdateAsync = update => + { + if (ReferenceEquals(update, delayedUpdate)) + { + updateReceived.TrySetResult(); + return releaseUpdate.Task; + } + + return Task.CompletedTask; + } + }; + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var head = RenderComponent(); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "first")); + + try + { + // Hold an already-received update across cancellation, including navigation back to the same ID. + // This deterministically exercises stale delivery without sleeps or thread-pool timing assumptions. + await firstUpdates.Writer.WriteAsync(delayedUpdate); + await updateReceived.Task.DefaultTimeout(); + var intermediateRoute = SetTerminalAsync(cut, "intermediate"); + cut.WaitForAssertion(() => Assert.Equal("/api/apphost-terminal?terminalId=intermediate", + cut.FindComponent().Instance.EndpointPathAndQuery)); + var latestId = returnToFirst ? "first" : "latest"; + var latestRoute = SetTerminalAsync(cut, latestId); + cut.WaitForAssertion(() => Assert.Equal($"/api/apphost-terminal?terminalId={latestId}", + cut.FindComponent().Instance.EndpointPathAndQuery)); + Assert.False(intermediateRoute.IsCompleted); + Assert.False(latestRoute.IsCompleted); + Assert.Equal(1, client.TerminalSubscriptionCount); + + releaseUpdate.TrySetResult(); + await Task.WhenAll(intermediateRoute, latestRoute).DefaultTimeout(); + cut.WaitForAssertion(() => + { + Assert.Equal(2, client.TerminalSubscriptionCount); + Assert.Equal(1, client.ActiveTerminalSubscriptionCount); + Assert.Single(cut.FindComponents()); + Assert.Equal(latestId, head.Find("title").TextContent); + }); + await latestUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, latestId, "Latest title")); + head.WaitForAssertion(() => Assert.Equal("Latest title", head.Find("title").TextContent)); + } + finally + { + releaseUpdate.TrySetResult(); + } + } + + [Fact] + public async Task DisposeDuringRouteChange_JoinsOldWatchWithoutStartingReplacement() + { + var updates = Channel.CreateUnbounded(); + var updateReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseUpdate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var client = new TestDashboardClient(terminalChannelProvider: () => updates) + { + BeforeTerminalUpdateAsync = _ => + { + updateReceived.TrySetResult(); + return releaseUpdate.Task; + } + }; + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "first")); + + try + { + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot()); + await updateReceived.Task.DefaultTimeout(); + var routeChange = SetTerminalAsync(cut, "next"); + cut.WaitForAssertion(() => Assert.Equal("/api/apphost-terminal?terminalId=next", + cut.FindComponent().Instance.EndpointPathAndQuery)); + var disposal = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); + Assert.False(disposal.IsCompleted); + + releaseUpdate.TrySetResult(); + await Task.WhenAll(routeChange, disposal).DefaultTimeout(); + Assert.Equal(0, client.ActiveTerminalSubscriptionCount); + Assert.Equal(1, client.TerminalSubscriptionCount); + } + finally + { + releaseUpdate.TrySetResult(); + } + } + [Fact] public async Task RecoverySnapshot_RemovesMissingTerminalAndDisposalCancelsWatch() { @@ -35,4 +248,8 @@ public async Task RecoverySnapshot_RemovesMissingTerminalAndDisposalCancelsWatch await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); Assert.Equal(0, client.ActiveTerminalSubscriptionCount); } + + private static Task SetTerminalAsync(IRenderedComponent component, string terminalId) + => component.InvokeAsync(() => component.Instance.SetParametersAsync(ParameterView.FromDictionary( + new Dictionary { [nameof(TerminalWindow.TerminalId)] = terminalId }))); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 29a49bcf8b5..4e1570cb3d9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -16,6 +16,7 @@ public static void SetupTerminalComponents(TestContext context, TestDashboardCli FluentUISetupHelpers.SetupFluentUIComponents(context); FluentUISetupHelpers.SetupFluentButton(context); context.Services.AddSingleton(client); + context.JSInterop.Setup("Blazor._internal.PageTitle.getAndRemoveExistingTitle", _ => true).SetResult(string.Empty); SetupTerminalView(context); SetupTerminalDock(context); } diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index ac67f01ef37..ea8cf9a5f96 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -36,6 +36,7 @@ public class TestDashboardClient : IDashboardClient public ConcurrentQueue<(IReadOnlyList ResourceNames, DateTime ClearDate)> ClearedConsoleLogs { get; } = new(); public ConcurrentQueue ClosedTerminals { get; } = new(); public Action? OnTerminalSubscriptionDisposed { get; set; } + public Func? BeforeTerminalUpdateAsync { get; set; } public int TerminalSubscriptionCount => Volatile.Read(ref _terminalSubscriptionCount); public int ActiveTerminalSubscriptionCount => Volatile.Read(ref _activeTerminalSubscriptionCount); #pragma warning disable CS0067 // Event is never used - required by interface @@ -115,6 +116,12 @@ public async IAsyncEnumerable SubscribeTerminalsAsync([Enu { await foreach (var update in provider().Reader.ReadAllAsync(cancellationToken)) { + // Allow a test to hold an already-received update while its subscriber is being replaced. + if (BeforeTerminalUpdateAsync is { } beforeUpdate) + { + await beforeUpdate(update); + } + yield return update; } } From 47b1b0a5a9f63a9ae3a44bac650a9d8123ff1b55 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 12:19:27 +1000 Subject: [PATCH 039/106] Keep collapsed terminal dock out of keyboard navigation Mark the collapsed dock inert and hidden from assistive technology while preserving its layout, mounted terminal views, and connections. Restore interaction when the dock reopens. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor | 6 ++- .../Layout/TerminalDockTests.cs | 53 ++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index a84080e3746..0f3ef8e9eae 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -6,7 +6,11 @@ rather than unmounting it so xterm keeps its buffer, its measured cell metrics, and its WebSocket. *@ @if (_hasBeenOpened) { -
+
@foreach (var terminal in _terminals) diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 5c3413c4430..7d44ed45e64 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -128,8 +128,12 @@ public async Task CloseTab_TimesOut_NotifiesEvenAfterTabRemoval(bool removeWhile Assert.Equal(1, notifications.UnreadCount); } - [Fact] - public async Task HideDock_DoesNotCloseTerminals() + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task HideDock_IsInertWithoutClosingOrRemountingTerminals(bool hideWithShortcut, bool reopenFromAppHost) { var updates = Channel.CreateUnbounded(); var client = new TestDashboardClient(terminalChannelProvider: () => updates); @@ -138,15 +142,52 @@ public async Task HideDock_DoesNotCloseTerminals() await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".terminal-dock-tab").Count)); + var terminals = cut.FindComponents().Select(view => view.Instance).ToArray(); + var height = cut.Find(".terminal-dock").GetAttribute("style"); + Assert.False(cut.Find(".terminal-dock").HasAttribute("inert")); + Assert.Equal("false", cut.Find(".terminal-dock").GetAttribute("aria-hidden")); - await cut.Find(".terminal-dock-collapse").ClickAsync(new()); + if (hideWithShortcut) + { + await Services.GetRequiredService().OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock); + } + else + { + await cut.Find(".terminal-dock-collapse").ClickAsync(new()); + } - Assert.Single(cut.FindAll(".terminal-dock.collapsed")); + var collapsed = Assert.Single(cut.FindAll(".terminal-dock.collapsed")); + Assert.True(collapsed.HasAttribute("inert")); + Assert.Equal("true", collapsed.GetAttribute("aria-hidden")); + Assert.Equal(height, collapsed.GetAttribute("style")); + Assert.Equal(terminals, cut.FindComponents().Select(view => view.Instance).ToArray()); Assert.Empty(client.ClosedTerminals); Assert.Empty(Services.GetRequiredService().GetNotifications()); - await cut.InvokeAsync(cut.Instance.ToggleAsync); - Assert.Single(cut.FindAll(".terminal-dock.visible")); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "first", "Updated while hidden")); + cut.WaitForAssertion(() => Assert.Equal("Updated while hidden", cut.Find(".terminal-dock-tab-title").TextContent)); + Assert.True(cut.Find(".terminal-dock").HasAttribute("inert")); + + if (reopenFromAppHost) + { + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "second")); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock.visible"))); + } + else + { + await cut.InvokeAsync(cut.Instance.ToggleAsync); + } + + var visible = Assert.Single(cut.FindAll(".terminal-dock.visible")); + Assert.False(visible.HasAttribute("inert")); + Assert.Equal("false", visible.GetAttribute("aria-hidden")); + Assert.Equal(height, visible.GetAttribute("style")); Assert.Equal(2, cut.FindAll(".terminal-dock-tab").Count); + Assert.Equal(terminals, cut.FindComponents().Select(view => view.Instance).ToArray()); + Assert.Equal(["initTerminal", "initTerminal"], JSInterop.Invocations + .Where(invocation => invocation.Identifier is "initTerminal" or "disposeTerminal" or "reconnectTerminal") + .Select(invocation => invocation.Identifier)); + Assert.Empty(client.ClosedTerminals); } [Fact] From a5594d11a921446f7cccb064619620158b4dfb2e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 14:11:56 +1000 Subject: [PATCH 040/106] Add accessible keyboard navigation to terminal dock Add tab semantics, roving focus, keyboard selection and close handling while preserving mounted terminal panes. Remove the unused new-terminal button and keep Shift+F6 navigation out of inactive panes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.js | 8 +- .../Components/Layout/TerminalDock.razor | 57 +++-- .../Components/Layout/TerminalDock.razor.cs | 62 ++--- .../Components/Layout/TerminalDock.razor.css | 35 ++- .../Components/Layout/TerminalDock.razor.js | 89 +++++++ .../Resources/Layout.Designer.cs | 10 +- src/Aspire.Dashboard/Resources/Layout.resx | 9 +- .../Resources/xlf/Layout.cs.xlf | 20 +- .../Resources/xlf/Layout.de.xlf | 20 +- .../Resources/xlf/Layout.es.xlf | 20 +- .../Resources/xlf/Layout.fr.xlf | 20 +- .../Resources/xlf/Layout.it.xlf | 20 +- .../Resources/xlf/Layout.ja.xlf | 20 +- .../Resources/xlf/Layout.ko.xlf | 20 +- .../Resources/xlf/Layout.pl.xlf | 20 +- .../Resources/xlf/Layout.pt-BR.xlf | 20 +- .../Resources/xlf/Layout.ru.xlf | 20 +- .../Resources/xlf/Layout.tr.xlf | 20 +- .../Resources/xlf/Layout.zh-Hans.xlf | 20 +- .../Resources/xlf/Layout.zh-Hant.xlf | 20 +- .../Layout/TerminalDockTests.cs | 124 +++++++++- .../Shared/TerminalSetupHelpers.cs | 2 + .../Playwright/TerminalDockTests.cs | 222 ++++++++++++++++++ 23 files changed, 660 insertions(+), 218 deletions(-) create mode 100644 tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 46d5db320c3..612a78ea1f3 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -704,8 +704,14 @@ function moveFocusFromTerminal(state, reverse) { return true; } + // Inactive dock panes retain their dimensions for xterm, so a nonempty rectangle does not imply that an + // element can take focus. Skip inert/hidden panes and nonselected tabs in a roving-tabindex strip. const focusableElements = Array.from(document.querySelectorAll(FOCUSABLE_ELEMENT_SELECTOR)) - .filter((element) => element.getClientRects().length > 0); + .filter((element) => element.tabIndex >= 0 + && !element.disabled + && !element.closest('[inert]') + && element.getClientRects().length > 0 + && getComputedStyle(element).visibility === 'visible'); const activeIndex = focusableElements.indexOf(document.activeElement); for (let index = activeIndex - 1; index >= 0; index--) { const candidate = focusableElements[index]; diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 0f3ef8e9eae..f73ba35cd75 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -13,31 +13,37 @@ @ref="_dockElement">
- @foreach (var terminal in _terminals) + @* FluentTabs resets selection when closing an inactive tab (https://github.com/microsoft/fluentui-blazor/issues/3392). + Keep selection and removal driven by AppHost updates, with separate tab/close buttons and mounted panes. *@ + @if (_terminals.Count > 0) { -
- @terminal.Title - - - - - +
+ @foreach (var terminal in _terminals) + { +
+ + + + +
+ }
} - - -
@if (_popupBlocked) { @@ -65,6 +71,11 @@ @* Inactive panes use visibility rather than display so they keep real dimensions — xterm measures its grid from the element box, and a display:none pane would refit to zero columns. *@
@if (_detachedTerminalIds.Contains(terminal.TerminalId)) { @@ -94,8 +105,6 @@ }
} - @* Rendered last so it stacks over the panes. The panes stay mounted underneath, which is what lets a - terminal keep its xterm buffer and its socket while the panel is on screen. *@ @if (IsPanelVisible) {
diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 28c8ff70740..4d45b3fba55 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -31,22 +31,13 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener private readonly List _terminals = []; private readonly CancellationTokenSource _cts = new(); + private readonly string _elementIdPrefix = $"terminal-dock-{Guid.NewGuid():N}"; private bool _hasBeenOpened; private bool _isVisible; private bool _disposed; private string? _activeTerminalId; - /// - /// Whether the user asked for the panel with the + button while terminals exist. - /// - /// - /// Sticky on purpose. A terminal arriving on the watch stream selects itself when nothing is selected, so - /// without this flag any AppHost activity would yank the panel away from under the user. Only an explicit - /// tab click, or the AppHost revealing a terminal through IAspireTerminal.Show(), dismisses it. - /// - private bool _panelRequested; - private int _heightPx = DefaultHeightPx; private Task? _watchTask; private IJSObjectReference? _jsModule; @@ -153,6 +144,10 @@ protected override async Task OnAfterRenderAsync(bool firstRender) return; } await _jsModule.InvokeVoidAsync("registerResizeHandle", _dockElement, _selfRef).ConfigureAwait(true); + if (!_disposed) + { + await _jsModule.InvokeVoidAsync("registerTabNavigation", _dockElement).ConfigureAwait(true); + } } } @@ -179,22 +174,24 @@ private void Hide() private void Activate(string terminalId) { + if (!_terminals.Any(t => t.TerminalId == terminalId)) + { + // A queued click can arrive after the watch stream removes its tab. + Logger.LogDebug("Ignored selection of removed dock terminal {TerminalId}.", terminalId); + return; + } + _activeTerminalId = terminalId; - _panelRequested = false; StateHasChanged(); } - /// - /// Whether the panel is covering the terminal panes, either because the user asked for it or because there is - /// no terminal to show. - /// - private bool IsPanelVisible => _panelRequested || _terminals.Count == 0; + private bool IsPanelVisible => _terminals.Count == 0; - /// - /// Whether a terminal is the one currently on screen. False for every terminal while the panel is up, which is - /// what keeps the tab strip from showing a selected tab whose pane is hidden. - /// - private bool IsPaneActive(string terminalId) => !IsPanelVisible && terminalId == _activeTerminalId; + private bool IsPaneActive(string terminalId) => terminalId == _activeTerminalId; + + private string GetTabId(string terminalId) => $"{_elementIdPrefix}-tab-{terminalId}"; + + private string GetPaneId(string terminalId) => $"{_elementIdPrefix}-pane-{terminalId}"; private TerminalWindowLauncher WindowLauncher => _windowLauncher ??= new TerminalWindowLauncher(JS, OnDetachedWindowClosedAsync); @@ -280,20 +277,6 @@ private Task OnDetachedWindowClosedAsync(string terminalId) => InvokeAsync(() => } }); - /// - /// Shows the panel that stands in for a terminal when there is nothing to show, or nothing selected. - /// - /// - /// The + button deliberately does not create anything. Terminals are owned by the AppHost process, not - /// by the browser, so there is no meaningful workload the dashboard could pick on the user's behalf; the panel - /// is where launch actions will go once there is something to launch. - /// - private void ShowPanel() - { - _panelRequested = true; - StateHasChanged(); - } - private async Task CloseTerminalAsync(string terminalId, string terminalTitle) { try @@ -344,11 +327,15 @@ await InvokeAsync(async () => List endedTerminalIds = []; if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Snapshot) { + var previousActiveIndex = _terminals.FindIndex(t => t.TerminalId == _activeTerminalId); _terminals.Clear(); _terminals.AddRange(update.Snapshot.Terminals); if (!_terminals.Any(t => t.TerminalId == _activeTerminalId)) { - _activeTerminalId = _terminals.FirstOrDefault()?.TerminalId; + // Snapshots can also remove the active tab; use the same adjacent fallback as removal. + _activeTerminalId = _terminals.Count > 0 + ? _terminals[Math.Clamp(previousActiveIndex, 0, _terminals.Count - 1)].TerminalId + : null; } // Recovery snapshots replace all prior state, including terminals removed while offline. @@ -425,8 +412,6 @@ await InvokeAsync(async () => _terminals.Add(descriptor); } _activeTerminalId = descriptor.TerminalId; - // The AppHost is asking for this terminal specifically, which outranks a panel the user opened. - _panelRequested = false; _hasBeenOpened = true; _isVisible = true; break; @@ -479,6 +464,7 @@ public async ValueTask DisposeAsync() { try { + await module.InvokeVoidAsync("unregisterTabNavigation", _dockElement).ConfigureAwait(true); await module.DisposeAsync().ConfigureAwait(true); } catch (JSDisconnectedException) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css index ac19d1e1dbc..9796bbdcaba 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css @@ -54,16 +54,22 @@ flex: 0 0 auto; } +.terminal-dock-tablist { + display: flex; + min-width: 0; + overflow-x: auto; +} + .terminal-dock-tab { display: flex; align-items: center; gap: 4px; - padding: 2px 4px 2px 10px; + padding: 2px 4px; border-radius: 4px 4px 0 0; - cursor: pointer; color: #c9d1d9; font-size: 12px; max-width: 220px; + flex: 0 0 auto; } .terminal-dock-tab.active { @@ -71,13 +77,22 @@ color: #58a6ff; } -/* The + selects the panel rather than performing an action, so it needs the same pressed affordance a tab gets. - ::deep because FluentButton renders the element the class lands on, and ::part(control) because the web - component paints an opaque background on its internal control element, which would otherwise hide anything - set on the host. */ -::deep .terminal-dock-new.active::part(control) { - background-color: #0d1117; - border-radius: 4px 4px 0 0; +.terminal-dock-tab-select { + display: flex; + align-items: center; + align-self: stretch; + min-width: 0; + padding: 2px 4px 2px 6px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.terminal-dock-tab-select:focus-visible { + outline: 2px solid var(--focus-stroke-outer); + outline-offset: -2px; } .terminal-dock-tab-title { @@ -106,8 +121,6 @@ visibility: hidden; } -/* Stacked over the panes rather than replacing them, so a terminal underneath keeps its xterm buffer and socket. - Opaque for the same reason: the panes are only hidden with visibility, so anything translucent would show them. */ .terminal-dock-panel { position: absolute; inset: 0; diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js index 20ace4c687d..8892724caa3 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js @@ -45,3 +45,92 @@ export function registerResizeHandle(dockElement, dotNetRef) { grabber.addEventListener('pointerup', end); grabber.addEventListener('pointercancel', end); } + +const tabNavigationRegistrations = new WeakMap(); + +export function registerTabNavigation(dockElement) { + unregisterTabNavigation(dockElement); + let focusedTabGroup = null; + + const onFocusIn = (event) => { + const group = event.target.closest?.('.terminal-dock-tab'); + focusedTabGroup = group && dockElement.contains(group) ? group : null; + }; + + // Automatic activation follows https://www.w3.org/WAI/ARIA/apg/patterns/tabs/. + // Only tab headers handle these keys. Native buttons provide Enter/Space, while xterm, close buttons and + // browser shortcuts keep their own input handling. Moving focus locally avoids waiting for a circuit round-trip. + const onKeyDown = (event) => { + const tab = event.target.closest?.('.terminal-dock-tab-select'); + if (!tab || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey || event.isComposing) { + return; + } + + const tabs = Array.from(dockElement.querySelectorAll('.terminal-dock-tab-select')); + const index = tabs.indexOf(tab); + let nextIndex; + switch (event.key) { + case 'ArrowLeft': + nextIndex = (index + tabs.length - 1) % tabs.length; + break; + case 'ArrowRight': + nextIndex = (index + 1) % tabs.length; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = tabs.length - 1; + break; + case 'Delete': + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) { + tab.closest('.terminal-dock-tab').querySelector('.terminal-dock-tab-close').click(); + } + return; + default: + return; + } + + event.preventDefault(); + event.stopPropagation(); + tabs[nextIndex].focus({ preventScroll: true }); + tabs[nextIndex].scrollIntoView({ block: 'nearest', inline: 'nearest' }); + tabs[nextIndex].click(); + }; + + // Removal is confirmed by the watch stream, not by the close RPC finishing. A focused node's removal leaves + // focus on the document body, so remember the group until the DOM update arrives. Moving elsewhere while a + // close is pending clears it; unrelated metadata updates must not steal focus from a terminal or the page. + const observer = new MutationObserver(() => { + if (!focusedTabGroup || focusedTabGroup.isConnected) { + return; + } + + focusedTabGroup = null; + if (!dockElement.isConnected || dockElement.inert) { + return; + } + + const target = dockElement.querySelector('.terminal-dock-tab-select[aria-selected="true"]') + || dockElement.querySelector('.terminal-dock-collapse'); + target.focus({ preventScroll: true }); + target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); + + document.addEventListener('focusin', onFocusIn); + dockElement.addEventListener('keydown', onKeyDown); + onFocusIn({ target: document.activeElement }); + observer.observe(dockElement, { childList: true, subtree: true }); + tabNavigationRegistrations.set(dockElement, () => { + document.removeEventListener('focusin', onFocusIn); + dockElement.removeEventListener('keydown', onKeyDown); + observer.disconnect(); + }); +} + +export function unregisterTabNavigation(dockElement) { + tabNavigationRegistrations.get(dockElement)?.(); + tabNavigationRegistrations.delete(dockElement); +} diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 47df7e851f2..8fa7b16e58c 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -106,7 +106,7 @@ public static string DashboardRunSelectUnpin { } /// - /// Looks up a localized string similar to Close terminal. + /// Looks up a localized string similar to Close terminal '{0}'. /// public static string TerminalDockCloseTab { get { @@ -142,7 +142,7 @@ public static string TerminalDockPanelBody { } /// - /// Looks up a localized string similar to No terminal selected. + /// Looks up a localized string similar to No terminals. /// public static string TerminalDockPanelHeading { get { @@ -205,11 +205,11 @@ public static string TerminalDockHide { } /// - /// Looks up a localized string similar to New terminal. + /// Looks up a localized string similar to Terminals. /// - public static string TerminalDockNewTerminal { + public static string TerminalDockTabs { get { - return ResourceManager.GetString("TerminalDockNewTerminal", resourceCulture); + return ResourceManager.GetString("TerminalDockTabs", resourceCulture); } } diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index f5d4815b883..f8ea6011400 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -172,7 +172,8 @@ Aspire - Close terminal + Close terminal '{0}' + {0} is the terminal's display title. Terminal close timed out @@ -196,14 +197,14 @@ Hide terminal panel (Shift+`) - - New terminal + + Terminals Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected + No terminals Press Shift+` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index 49e16fcb44d..319474d615e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index fa8f6acce41..6b23d5e26d7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 93718369b50..5a1dc424bd1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 0e6ba341813..4dd2eb18614 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index b2effa7225c..aa4b5383723 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index a74e6af3ecc..7c838aad7a4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index bb7125590fb..ff1dfe4fe9d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index c97f3c8c7c5..2ae7f9345c2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index dff27a9abe6..92cfcb14c4d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index c6b355aee84..d61aafc4a40 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 500c1d5ee91..94efaa1823b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 3cb8a5ee8d9..1cc6ba7d014 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 89317c9e61a..2af338e6b0d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -148,9 +148,9 @@ - Close terminal - Close terminal - + Close terminal '{0}' + Close terminal '{0}' + {0} is the terminal's display title. Timed out waiting for terminal '{0}' to shut down. Cleanup is continuing in the background. @@ -187,19 +187,14 @@ Hide terminal panel (Shift+`) - - New terminal - New terminal - - Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. - No terminal selected - No terminal selected + No terminals + No terminals @@ -212,6 +207,11 @@ Return to panel + + Terminals + Terminals + + This terminal has ended. This terminal has ended. diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 7d44ed45e64..a5b92e4bd4a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -23,7 +23,7 @@ namespace Aspire.Dashboard.Components.Tests.Layout; public class TerminalDockTests : DashboardTestContext { [Fact] - public async Task WatchUpdates_ReplaceSnapshotAndPreservePanelUntilActivated() + public async Task WatchUpdates_ReplaceSnapshotAndSelectAppHostTerminals() { var updates = Channel.CreateUnbounded(); var client = new TestDashboardClient(terminalChannelProvider: () => updates); @@ -31,17 +31,18 @@ public async Task WatchUpdates_ReplaceSnapshotAndPreservePanelUntilActivated() var cut = RenderComponent(); await cut.InvokeAsync(cut.Instance.ToggleAsync); + Assert.Equal("No terminals", cut.Find(".terminal-dock-panel-heading").TextContent); + Assert.Equal(["Open terminal in a new window", "Hide terminal panel (Shift+`)"], + cut.FindAll(".terminal-dock-tabstrip fluent-button").Select(button => button.GetAttribute("aria-label"))); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); cut.WaitForAssertion(() => Assert.Equal("first", cut.Find(".terminal-dock-tab.active").TextContent.Trim())); - await cut.Find(".terminal-dock-new").ClickAsync(new()); - cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock-panel"))); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Added, "third")); cut.WaitForAssertion(() => { Assert.Equal(3, cut.FindAll(".terminal-dock-tab").Count); - Assert.Single(cut.FindAll(".terminal-dock-panel")); - Assert.Empty(cut.FindAll(".terminal-dock-tab.active")); + Assert.Equal("first", cut.Find(".terminal-dock-tab.active").TextContent.Trim()); + Assert.Empty(cut.FindAll(".terminal-dock-panel")); }); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "second")); @@ -57,9 +58,122 @@ public async Task WatchUpdates_ReplaceSnapshotAndPreservePanelUntilActivated() await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); Assert.Equal(0, client.ActiveTerminalSubscriptionCount); + Assert.Equal(["registerTabNavigation", "unregisterTabNavigation"], JSInterop.Invocations + .Where(invocation => invocation.Identifier is "registerTabNavigation" or "unregisterTabNavigation") + .Select(invocation => invocation.Identifier)); await Services.GetRequiredService().OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock); } + [Fact] + public async Task SelectTab_UpdatesAccessibleSelectionWithoutRemountingPanes() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second", "third")); + cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("[role=tab]").Count)); + var terminals = cut.FindComponents().Select(view => view.Instance).ToArray(); + Assert.Equal("Terminals", cut.Find("[role=tablist]").GetAttribute("aria-label")); + + foreach (var selected in new[] { 2, 0, 1 }) + { + await cut.FindAll("[role=tab]")[selected].ClickAsync(new()); + var tabs = cut.FindAll("[role=tab]"); + var panes = cut.FindAll("[role=tabpanel]"); + var closeButtons = cut.FindAll(".terminal-dock-tab-close"); + for (var i = 0; i < tabs.Count; i++) + { + Assert.Equal(i == selected ? "0" : "-1", tabs[i].GetAttribute("tabindex")); + Assert.Equal(i == selected ? "true" : "false", tabs[i].GetAttribute("aria-selected")); + Assert.Equal(panes[i].Id, tabs[i].GetAttribute("aria-controls")); + Assert.Equal(tabs[i].Id, panes[i].GetAttribute("aria-labelledby")); + Assert.Equal(i != selected, panes[i].HasAttribute("inert")); + Assert.Equal(i != selected ? "true" : "false", panes[i].GetAttribute("aria-hidden")); + Assert.Equal(i == selected ? "0" : "-1", closeButtons[i].GetAttribute("tabindex")); + Assert.Equal($"Close terminal '{tabs[i].TextContent.Trim()}'", closeButtons[i].GetAttribute("aria-label")); + Assert.Equal("button", tabs[i].GetAttribute("type")); + } + + Assert.Equal(terminals, cut.FindComponents().Select(view => view.Instance).ToArray()); + } + + Assert.Equal(["initTerminal", "initTerminal", "initTerminal"], JSInterop.Invocations + .Where(invocation => invocation.Identifier is "initTerminal" or "disposeTerminal" or "reconnectTerminal") + .Select(invocation => invocation.Identifier)); + Assert.Empty(client.ClosedTerminals); + } + + [Theory] + [InlineData(0, "second", false)] + [InlineData(1, "third", false)] + [InlineData(2, "second", false)] + [InlineData(0, "second", true)] + [InlineData(1, "third", true)] + [InlineData(2, "second", true)] + public async Task CloseActiveTab_WaitsForWatchRemovalAndSelectsAdjacentTab(int selected, string next, bool useSnapshot) + { + var updates = Channel.CreateUnbounded(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var client = new TestDashboardClient( + terminalChannelProvider: () => updates, + closeTerminal: (_, _) => completion.Task); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + string[] ids = ["first", "second", "third"]; + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot(ids)); + cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("[role=tab]").Count)); + await cut.FindAll("[role=tab]")[selected].ClickAsync(new()); + + var close = cut.FindAll(".terminal-dock-tab-close")[selected].ClickAsync(new()); + cut.WaitForAssertion(() => Assert.Equal([ids[selected]], client.ClosedTerminals.ToArray())); + Assert.Equal(3, cut.FindAll("[role=tab]").Count); + Assert.Equal(ids[selected], cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); + + await updates.Writer.WriteAsync(useSnapshot + ? TerminalSetupHelpers.Snapshot(ids.Where(id => id != ids[selected]).ToArray()) + : TerminalSetupHelpers.Change(TerminalChangeType.Removed, ids[selected])); + cut.WaitForAssertion(() => + { + Assert.Equal(2, cut.FindAll("[role=tab]").Count); + Assert.Equal(next, cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); + Assert.Equal("0", cut.Find("[role=tab][aria-selected=true]").GetAttribute("tabindex")); + }); + completion.SetResult(); + await close.DefaultTimeout(); + } + + [Fact] + public async Task LastTabRemoved_EmptyDockCanReceiveAnotherAppHostTerminal() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "first")); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll("[role=tab]"))); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "first")); + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll("[role=tablist]")); + Assert.Empty(cut.FindAll("[role=tabpanel]")); + Assert.Equal("No terminals", cut.Find(".terminal-dock-panel-heading").TextContent); + Assert.Equal(["Open terminal in a new window", "Hide terminal panel (Shift+`)"], + cut.FindAll(".terminal-dock-tabstrip fluent-button").Select(button => button.GetAttribute("aria-label"))); + }); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Added, "second")); + cut.WaitForAssertion(() => + { + Assert.Equal("second", cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); + Assert.Empty(cut.FindAll(".terminal-dock-panel")); + }); + Assert.Empty(client.ClosedTerminals); + } + [Fact] public async Task CloseInactiveTab_DoesNotChangeSelection() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 4e1570cb3d9..724e743b5ff 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -35,6 +35,8 @@ public static void SetupTerminalDock(TestContext context) { var dock = context.JSInterop.SetupModule("./Components/Layout/TerminalDock.razor.js"); dock.SetupVoid("registerResizeHandle", _ => true).SetVoidResult(); + dock.SetupVoid("registerTabNavigation", _ => true).SetVoidResult(); + dock.SetupVoid("unregisterTabNavigation", _ => true).SetVoidResult(); var windows = context.JSInterop.SetupModule("/js/app-terminalwindow.js"); windows.Setup("openTerminalWindow", _ => true).SetResult("opened"); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs new file mode 100644 index 00000000000..07891a89d05 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs @@ -0,0 +1,222 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; +using Aspire.Dashboard.Tests.Shared; +using Aspire.DashboardService.Proto.V1; +using Aspire.TestUtilities; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Playwright; +using Xunit; + +namespace Aspire.Dashboard.Tests.Integration.Playwright; + +[RequiresFeature(TestFeature.Playwright)] +public sealed class TerminalDockTests(TerminalDockTests.TerminalDockDashboardServerFixture fixture) + : PlaywrightTestsBase(fixture) +{ + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task KeyboardNavigation_SelectsTabsWithoutInterceptingTerminalInput() + { + await RunTestAsync(async page => + { + await OpenDockAsync(page); + var terminals = await page.Locator(".terminal-dock .xterm").ElementHandlesAsync(); + await Tab(page, "first").FocusAsync(); + + foreach (var (key, expected) in new[] + { + ("ArrowLeft", "third"), + ("ArrowRight", "first"), + ("End", "third"), + ("Home", "first"), + ("ArrowRight", "second"), + ("Enter", "second"), + ("Space", "second") + }) + { + await page.Keyboard.PressAsync(key); + await Assertions.Expect(Tab(page, expected)).ToBeFocusedAsync(); + await Assertions.Expect(Tab(page, expected)).ToHaveAttributeAsync("aria-selected", "true"); + } + + await page.Keyboard.PressAsync("Tab"); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = "Close terminal 'second'", Exact = true })) + .ToBeFocusedAsync(); + await page.Keyboard.PressAsync("Shift+Tab"); + await Assertions.Expect(Tab(page, "second")).ToBeFocusedAsync(); + await page.Keyboard.PressAsync("Shift+Tab"); + Assert.False(await page.EvaluateAsync("!!document.activeElement.closest('.terminal-dock-tabstrip')")); + await page.Keyboard.PressAsync("Tab"); + await Assertions.Expect(Tab(page, "second")).ToBeFocusedAsync(); + + var input = page.Locator(".terminal-dock-pane.active .xterm-helper-textarea"); + await input.FocusAsync(); + foreach (var key in new[] { "ArrowLeft", "ArrowRight", "Home", "End", "Delete" }) + { + await page.Keyboard.PressAsync(key); + await Assertions.Expect(input).ToBeFocusedAsync(); + await Assertions.Expect(Tab(page, "second")).ToHaveAttributeAsync("aria-selected", "true"); + } + + await page.Keyboard.PressAsync("F6"); + Assert.True(await input.EvaluateAsync( + "input => input !== document.activeElement && input.closest('.terminal-dock-pane').contains(document.activeElement)")); + await input.FocusAsync(); + await page.Keyboard.PressAsync("Shift+F6"); + Assert.True(await page.EvaluateAsync("!!document.activeElement.closest('.terminal-dock-tabstrip')")); + + Assert.Empty(fixture.Client.ClosedTerminals); + foreach (var terminal in terminals) + { + Assert.True(await terminal.EvaluateAsync("element => element.isConnected && element.getBoundingClientRect().width > 0")); + } + }); + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task CloseTab_RestoresFocusOnlyAfterRemovalIncludingLastTab() + { + await RunTestAsync(async page => + { + var (updates, closes) = await OpenDockAsync(page); + await Tab(page, "second").ClickAsync(); + await Assertions.Expect(Tab(page, "second")).ToHaveAttributeAsync("aria-selected", "true"); + + foreach (var (closed, next, key) in new[] + { + ("second", "third", "Delete"), + ("third", "first", "Enter"), + ("first", (string?)null, "Space") + }) + { + if (key != "Delete") + { + await page.Keyboard.PressAsync("Tab"); + } + await page.Keyboard.PressAsync(key); + Assert.Equal(closed, await closes.Reader.ReadAsync().AsTask().DefaultTimeout()); + await Assertions.Expect(Tab(page, closed)).ToHaveAttributeAsync("aria-selected", "true"); + await Assertions.Expect(key == "Delete" + ? Tab(page, closed) + : page.GetByRole(AriaRole.Button, new() { Name = $"Close terminal '{closed}'", Exact = true })) + .ToBeFocusedAsync(); + await updates.Writer.WriteAsync(Change(TerminalChangeType.Removed, closed)); + + if (next is not null) + { + await Assertions.Expect(Tab(page, next)).ToBeFocusedAsync(); + await Assertions.Expect(Tab(page, next)).ToHaveAttributeAsync("aria-selected", "true"); + } + else + { + await Assertions.Expect(page.Locator(".terminal-dock-collapse")).ToBeFocusedAsync(); + await Assertions.Expect(page.Locator(".terminal-dock-panel-heading")).ToHaveTextAsync("No terminals"); + } + } + + // The tablist is recreated after the empty state, but its dock-scoped listener remains usable. + await updates.Writer.WriteAsync(Change(TerminalChangeType.Added, "replacement")); + await Tab(page, "replacement").FocusAsync(); + await page.Keyboard.PressAsync("Delete"); + Assert.Equal("replacement", await closes.Reader.ReadAsync().AsTask().DefaultTimeout()); + await updates.Writer.WriteAsync(Change(TerminalChangeType.Removed, "replacement")); + await Assertions.Expect(page.Locator(".terminal-dock-collapse")).ToBeFocusedAsync(); + Assert.Equal(["second", "third", "first", "replacement"], fixture.Client.ClosedTerminals.ToArray()); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task Removal_DoesNotStealFocusAfterMovingElsewhere(bool hideDock) + { + await RunTestAsync(async page => + { + var (updates, closes) = await OpenDockAsync(page); + await Tab(page, "first").FocusAsync(); + await page.Keyboard.PressAsync("Delete"); + Assert.Equal("first", await closes.Reader.ReadAsync().AsTask().DefaultTimeout()); + + ILocator focusTarget; + if (hideDock) + { + await page.Locator(".terminal-dock-collapse").ClickAsync(); + focusTarget = page.GetByRole(AriaRole.Button, new() { Name = "Toggle terminal (Shift+`)", Exact = true }); + } + else + { + await Tab(page, "second").ClickAsync(); + focusTarget = page.Locator(".terminal-dock-pane.active .xterm-helper-textarea"); + } + + await focusTarget.FocusAsync(); + await updates.Writer.WriteAsync(Change(TerminalChangeType.Removed, "first")); + await Assertions.Expect(page.Locator(".terminal-dock-tab-select")).ToHaveCountAsync(2); + await Assertions.Expect(focusTarget).ToBeFocusedAsync(); + }); + } + + private async Task<(Channel Updates, Channel Closes)> OpenDockAsync(IPage page) + { + var channels = fixture.StartSession(); + // Keep the real xterm views connected without needing a PTY; this fixture exercises dock input and focus. + await page.RouteWebSocketAsync("**/api/apphost-terminal?*", route => route.OnMessage(_ => { })); + await page.GotoAsync("/").DefaultTimeout(); + foreach (var id in new[] { "first", "second", "third" }) + { + await channels.Updates.Writer.WriteAsync(Change(TerminalChangeType.Added, id)); + } + await channels.Updates.Writer.WriteAsync(Change(TerminalChangeType.Activated, "first")); + await Assertions.Expect(Tab(page, "first")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator(".terminal-dock .xterm")).ToHaveCountAsync(3); + return channels; + } + + private static ILocator Tab(IPage page, string name) => page.GetByRole(AriaRole.Tab, new() { Name = name, Exact = true }); + + private static WatchTerminalsUpdate Change(TerminalChangeType type, string id) => new() + { + Change = new TerminalChangeNotification + { + ChangeType = type, + Terminal = new TerminalDescriptor { TerminalId = id, Title = id } + } + }; + + public sealed class TerminalDockDashboardServerFixture : DashboardServerFixture + { + private Channel _updates = Channel.CreateUnbounded(); + private Channel _closes = Channel.CreateUnbounded(); + + public TestDashboardClient Client { get; } + + public TerminalDockDashboardServerFixture() + { + Client = new TestDashboardClient( + isEnabled: true, + terminalChannelProvider: () => Volatile.Read(ref _updates), + closeTerminal: (id, token) => Volatile.Read(ref _closes).Writer.WriteAsync(id, token).AsTask()); + } + + public (Channel Updates, Channel Closes) StartSession() + { + var updates = Channel.CreateUnbounded(); + var closes = Channel.CreateUnbounded(); + Volatile.Write(ref _updates, updates); + Volatile.Write(ref _closes, closes); + Client.ClosedTerminals.Clear(); + return (updates, closes); + } + + protected override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(Client); + } + } +} From f954f1624d19c578c6841df0fddbf9883e88a1e9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 15:17:54 +1000 Subject: [PATCH 041/106] Add keyboard resizing to the terminal dock Make the resize handle an accessible separator with focused keyboard controls, viewport-aware bounds, coalesced updates, and listener cleanup. Preserve pointer dragging and terminal input. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor | 16 +- .../Components/Layout/TerminalDock.razor.cs | 18 +- .../Components/Layout/TerminalDock.razor.css | 8 + .../Components/Layout/TerminalDock.razor.js | 155 +++++++++++++++--- .../Resources/Layout.Designer.cs | 18 ++ src/Aspire.Dashboard/Resources/Layout.resx | 7 + .../Resources/xlf/Layout.cs.xlf | 10 ++ .../Resources/xlf/Layout.de.xlf | 10 ++ .../Resources/xlf/Layout.es.xlf | 10 ++ .../Resources/xlf/Layout.fr.xlf | 10 ++ .../Resources/xlf/Layout.it.xlf | 10 ++ .../Resources/xlf/Layout.ja.xlf | 10 ++ .../Resources/xlf/Layout.ko.xlf | 10 ++ .../Resources/xlf/Layout.pl.xlf | 10 ++ .../Resources/xlf/Layout.pt-BR.xlf | 10 ++ .../Resources/xlf/Layout.ru.xlf | 10 ++ .../Resources/xlf/Layout.tr.xlf | 10 ++ .../Resources/xlf/Layout.zh-Hans.xlf | 10 ++ .../Resources/xlf/Layout.zh-Hant.xlf | 10 ++ .../Layout/TerminalDockTests.cs | 56 +++++++ .../Shared/TerminalSetupHelpers.cs | 1 + .../Playwright/TerminalDockTests.cs | 93 +++++++++++ 22 files changed, 478 insertions(+), 24 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index f73ba35cd75..1b22671e1b7 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -7,11 +7,25 @@ @if (_hasBeenOpened) {
-
+ + @Loc[nameof(Resources.Layout.TerminalDockResizeHelp)]
@* FluentTabs resets selection when closing an inactive tab (https://github.com/microsoft/fluentui-blazor/issues/3392). Keep selection and removal driven by AppHost updates, with separate tab/close buttons and mounted panes. *@ diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 4d45b3fba55..1647c7013ef 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -28,6 +28,8 @@ namespace Aspire.Dashboard.Components.Layout; public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener, IAsyncDisposable { private const int DefaultHeightPx = 320; + private const int MinimumHeightPx = 120; + private const int MaximumHeightPx = 1200; private readonly List _terminals = []; private readonly CancellationTokenSource _cts = new(); @@ -39,6 +41,7 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener private string? _activeTerminalId; private int _heightPx = DefaultHeightPx; + private int _maximumHeightPx = MaximumHeightPx; private Task? _watchTask; private IJSObjectReference? _jsModule; private DotNetObjectReference? _selfRef; @@ -143,7 +146,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await _jsModule.DisposeAsync().ConfigureAwait(true); return; } - await _jsModule.InvokeVoidAsync("registerResizeHandle", _dockElement, _selfRef).ConfigureAwait(true); + await _jsModule.InvokeVoidAsync("registerResizeHandle", _dockElement, _selfRef, MinimumHeightPx, MaximumHeightPx).ConfigureAwait(true); if (!_disposed) { await _jsModule.InvokeVoidAsync("registerTabNavigation", _dockElement).ConfigureAwait(true); @@ -152,20 +155,26 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } /// - /// Called from JS while the user drags the dock's top edge. + /// Updates the dock height after pointer or keyboard resizing, or a viewport size change. /// + /// The requested dock height in CSS pixels. + /// The browser viewport height in CSS pixels. + /// A task that completes after the dock state is updated. [JSInvokable] - public Task SetHeightAsync(int heightPx) => InvokeAsync(() => + public Task SetHeightAsync(int heightPx, int viewportHeightPx) => InvokeAsync(() => { if (_disposed) { return; } - _heightPx = Math.Clamp(heightPx, 120, 1200); + _maximumHeightPx = Math.Clamp(viewportHeightPx, 1, MaximumHeightPx); + _heightPx = Math.Clamp(heightPx, EffectiveMinimumHeightPx, _maximumHeightPx); StateHasChanged(); }); + private int EffectiveMinimumHeightPx => Math.Min(MinimumHeightPx, _maximumHeightPx); + private void Hide() { _isVisible = false; @@ -464,6 +473,7 @@ public async ValueTask DisposeAsync() { try { + await module.InvokeVoidAsync("unregisterResizeHandle", _dockElement).ConfigureAwait(true); await module.InvokeVoidAsync("unregisterTabNavigation", _dockElement).ConfigureAwait(true); await module.DisposeAsync().ConfigureAwait(true); } diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css index 9796bbdcaba..5cb7e05b2a0 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.css @@ -9,6 +9,8 @@ z-index: 900; display: flex; flex-direction: column; + box-sizing: border-box; + max-height: 100vh; background-color: #0d1117; border-top: 1px solid var(--neutral-stroke-divider-rest); transition: transform 120ms ease-out; @@ -173,6 +175,12 @@ background-color: var(--accent-fill-rest); } +.terminal-dock-resize-handle:focus-visible { + background-color: var(--focus-stroke-outer); + outline: 2px solid var(--focus-stroke-outer); + outline-offset: -2px; +} + /* Shown in place of the terminal while it is running in a detached window. */ .terminal-dock-detached { display: flex; diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js index 8892724caa3..3796b6fc150 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js @@ -1,4 +1,4 @@ -// Drag-to-resize for the terminal dock's top edge. +// Pointer and keyboard resizing for the terminal dock's top edge. // // The dock is bottom-anchored (position: fixed; bottom: 0), so a taller dock means a *smaller* Y coordinate for its // top edge. Height is therefore derived from the pointer's distance to the bottom of the viewport rather than from a @@ -7,43 +7,160 @@ // Pointer capture is used so the drag survives the pointer leaving the 6px grabber, which is otherwise trivially easy // at normal mouse speeds. -export function registerResizeHandle(dockElement, dotNetRef) { +const resizeRegistrations = new WeakMap(); + +export function registerResizeHandle(dockElement, dotNetRef, minimumHeight, maximumHeight) { + unregisterResizeHandle(dockElement); const grabber = dockElement.querySelector('.terminal-dock-resize-handle'); if (!grabber) { - return; + throw new Error('The terminal dock resize handle was not found.'); } - let dragging = false; + let pointerId = null; + let height = Math.round(dockElement.getBoundingClientRect().height); + let viewportHeight = Math.max(1, window.innerHeight); + let frame = null; + let inFlight = false; + let pending = false; + let disposed = false; + + const bounds = () => { + const max = Math.min(maximumHeight, viewportHeight); + return { min: Math.min(minimumHeight, max), max }; + }; + + // Coalesce a held key or pointer movement into at most one circuit call per frame, with only one call in + // flight. Accumulate the requested height locally so delayed renders cannot lose repeated arrow-key steps. + const scheduleUpdate = () => { + pending = true; + if (disposed || inFlight || frame !== null) { + return; + } + frame = requestAnimationFrame(() => { + frame = null; + pending = false; + inFlight = true; + dotNetRef.invokeMethodAsync('SetHeightAsync', height, viewportHeight) + .catch(error => { + if (!disposed) { + console.error('Failed to resize the terminal dock.', error); + } + }) + .finally(() => { + inFlight = false; + if (pending && !disposed) { + scheduleUpdate(); + } + }); + }); + }; + + const resizeTo = requestedHeight => { + const { min, max } = bounds(); + const nextHeight = Math.max(min, Math.min(max, Math.round(requestedHeight))); + if (height !== nextHeight) { + height = nextHeight; + scheduleUpdate(); + } + }; - grabber.addEventListener('pointerdown', (e) => { - dragging = true; + const onPointerDown = e => { + if (e.button !== 0 || !e.isPrimary || dockElement.inert) { + return; + } + pointerId = e.pointerId; grabber.setPointerCapture(e.pointerId); + grabber.focus({ preventScroll: true }); e.preventDefault(); - }); + }; - grabber.addEventListener('pointermove', (e) => { - if (!dragging) { + const onPointerMove = e => { + if (pointerId !== e.pointerId || dockElement.inert) { return; } + resizeTo(viewportHeight - e.clientY); + }; - const height = Math.round(window.innerHeight - e.clientY); - dotNetRef.invokeMethodAsync('SetHeightAsync', height); - }); - - const end = (e) => { - if (!dragging) { + const end = e => { + if (pointerId !== e.pointerId) { return; } - dragging = false; - try { + pointerId = null; + if (grabber.hasPointerCapture(e.pointerId)) { grabber.releasePointerCapture(e.pointerId); - } catch { - // The pointer may already have been released by the browser (e.g. the tab lost focus mid-drag). } }; + // Follow the focused window-splitter pattern: https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/. + // The dock is the bottom pane, so moving the separator up increases its height. Shift adds coarse adjustment; + // no modifier shortcut is registered on the dock or the terminal input itself. + const onKeyDown = e => { + if (dockElement.inert || e.target !== grabber || e.ctrlKey || e.altKey || e.metaKey || e.isComposing) { + return; + } + const step = e.shiftKey ? 50 : 10; + const { min, max } = bounds(); + let nextHeight; + switch (e.key) { + case 'ArrowUp': + nextHeight = height + step; + break; + case 'ArrowDown': + nextHeight = height - step; + break; + case 'Home': + if (e.shiftKey) return; + nextHeight = min; + break; + case 'End': + if (e.shiftKey) return; + nextHeight = max; + break; + default: + return; + } + e.preventDefault(); + e.stopPropagation(); + resizeTo(nextHeight); + }; + + const onViewportResize = () => { + viewportHeight = Math.max(1, window.innerHeight); + resizeTo(height); + // Bounds can change even if the current height still fits. + scheduleUpdate(); + }; + + grabber.addEventListener('pointerdown', onPointerDown); + grabber.addEventListener('pointermove', onPointerMove); grabber.addEventListener('pointerup', end); grabber.addEventListener('pointercancel', end); + grabber.addEventListener('lostpointercapture', end); + grabber.addEventListener('keydown', onKeyDown); + window.addEventListener('resize', onViewportResize); + onViewportResize(); + + resizeRegistrations.set(dockElement, () => { + disposed = true; + if (frame !== null) { + cancelAnimationFrame(frame); + } + grabber.removeEventListener('pointerdown', onPointerDown); + grabber.removeEventListener('pointermove', onPointerMove); + grabber.removeEventListener('pointerup', end); + grabber.removeEventListener('pointercancel', end); + grabber.removeEventListener('lostpointercapture', end); + grabber.removeEventListener('keydown', onKeyDown); + window.removeEventListener('resize', onViewportResize); + if (pointerId !== null && grabber.hasPointerCapture(pointerId)) { + grabber.releasePointerCapture(pointerId); + } + }); +} + +export function unregisterResizeHandle(dockElement) { + resizeRegistrations.get(dockElement)?.(); + resizeRegistrations.delete(dockElement); } const tabNavigationRegistrations = new WeakMap(); diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 8fa7b16e58c..d743b5089ed 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -203,6 +203,24 @@ public static string TerminalDockHide { return ResourceManager.GetString("TerminalDockHide", resourceCulture); } } + + /// + /// Looks up a localized string similar to {0} pixels high. + /// + public static string TerminalDockHeight { + get { + return ResourceManager.GetString("TerminalDockHeight", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height.. + /// + public static string TerminalDockResizeHelp { + get { + return ResourceManager.GetString("TerminalDockResizeHelp", resourceCulture); + } + } /// /// Looks up a localized string similar to Terminals. diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index f8ea6011400..557cc8b70c3 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -197,6 +197,13 @@ Hide terminal panel (Shift+`) + + {0} pixels high + {0} is the terminal dock height in CSS pixels. + + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Terminals diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index 319474d615e..fb684d28fb2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 6b23d5e26d7..a6142bac497 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 5a1dc424bd1..ade87c503b9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 4dd2eb18614..da73c816a29 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index aa4b5383723..81f705e36ff 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index 7c838aad7a4..4c7327f6720 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index ff1dfe4fe9d..0a521e9d372 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 2ae7f9345c2..29c78a668f6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index 92cfcb14c4d..015cfca6632 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index d61aafc4a40..95e5254ffb9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 94efaa1823b..72af66f436e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 1cc6ba7d014..58d84a34c30 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 2af338e6b0d..6b28c2f60f3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -182,6 +182,11 @@ Focus window + + {0} pixels high + {0} pixels high + {0} is the terminal dock height in CSS pixels. + Hide terminal panel (Shift+`) Hide terminal panel (Shift+`) @@ -202,6 +207,11 @@ Press Shift+` to hide this panel. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. + + Return to panel Return to panel diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index a5b92e4bd4a..73a4beca4ba 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -22,6 +22,62 @@ namespace Aspire.Dashboard.Components.Tests.Layout; [UseCulture("en-US")] public class TerminalDockTests : DashboardTestContext { + [Theory] + [InlineData(400, 900, 120, 900, 400)] + [InlineData(-1, 900, 120, 900, 120)] + [InlineData(1400, 1600, 120, 1200, 1200)] + [InlineData(1200, 600, 120, 600, 600)] + [InlineData(320, 90, 90, 90, 90)] + public async Task ResizeDock_UpdatesAccessibleBoundsWithoutRemountingTerminals( + int requestedHeight, int viewportHeight, int minimum, int maximum, int expectedHeight) + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents().Count)); + var terminals = cut.FindComponents().Select(view => view.Instance).ToArray(); + + await cut.InvokeAsync(() => cut.Instance.SetHeightAsync(requestedHeight, viewportHeight)); + var dock = cut.Find(".terminal-dock"); + var handle = cut.Find("[role=separator]"); + Assert.Equal($"height: {expectedHeight}px;", dock.GetAttribute("style")); + Assert.Equal("0", handle.GetAttribute("tabindex")); + Assert.Equal("horizontal", handle.GetAttribute("aria-orientation")); + Assert.Equal("Terminals", handle.GetAttribute("aria-label")); + Assert.Equal(dock.Id, handle.GetAttribute("aria-controls")); + Assert.Equal(minimum.ToString(), handle.GetAttribute("aria-valuemin")); + Assert.Equal(maximum.ToString(), handle.GetAttribute("aria-valuemax")); + Assert.Equal(expectedHeight.ToString(), handle.GetAttribute("aria-valuenow")); + Assert.Equal($"{expectedHeight} pixels high", handle.GetAttribute("aria-valuetext")); + Assert.Equal("ArrowUp ArrowDown Shift+ArrowUp Shift+ArrowDown Home End", handle.GetAttribute("aria-keyshortcuts")); + Assert.Equal("Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height.", + cut.Find($"#{handle.GetAttribute("aria-describedby")}").TextContent); + Assert.Equal(terminals, cut.FindComponents().Select(view => view.Instance).ToArray()); + Assert.Equal("first", cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); + Assert.Empty(client.ClosedTerminals); + } + + [Fact] + public async Task ResizeDock_AfterDisposal_DoesNotUpdateState() + { + var client = new TestDashboardClient(); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + var height = cut.Find(".terminal-dock").GetAttribute("style"); + + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + await cut.InvokeAsync(() => cut.Instance.SetHeightAsync(500, 800)); + + Assert.Equal(height, cut.Find(".terminal-dock").GetAttribute("style")); + Assert.Equal(["registerResizeHandle", "unregisterResizeHandle"], JSInterop.Invocations + .Where(invocation => invocation.Identifier is "registerResizeHandle" or "unregisterResizeHandle") + .Select(invocation => invocation.Identifier)); + } + [Fact] public async Task WatchUpdates_ReplaceSnapshotAndSelectAppHostTerminals() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 724e743b5ff..4dfca4b9392 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -35,6 +35,7 @@ public static void SetupTerminalDock(TestContext context) { var dock = context.JSInterop.SetupModule("./Components/Layout/TerminalDock.razor.js"); dock.SetupVoid("registerResizeHandle", _ => true).SetVoidResult(); + dock.SetupVoid("unregisterResizeHandle", _ => true).SetVoidResult(); dock.SetupVoid("registerTabNavigation", _ => true).SetVoidResult(); dock.SetupVoid("unregisterTabNavigation", _ => true).SetVoidResult(); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs index 07891a89d05..477c06c7c4f 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs @@ -17,6 +17,99 @@ namespace Aspire.Dashboard.Tests.Integration.Playwright; public sealed class TerminalDockTests(TerminalDockTests.TerminalDockDashboardServerFixture fixture) : PlaywrightTestsBase(fixture) { + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task ResizeHandle_KeyboardAndPointerResizingRespectFocusAndBounds() + { + await RunTestAsync(async page => + { + await OpenDockAsync(page); + var terminals = await page.Locator(".terminal-dock .xterm").ElementHandlesAsync(); + var handle = page.GetByRole(AriaRole.Separator, new() { Name = "Terminals", Exact = true }); + var viewportHeight = await page.EvaluateAsync("window.innerHeight"); + var maximum = Math.Min(1200, viewportHeight); + await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuemax", maximum.ToString()); + await handle.FocusAsync(); + + foreach (var (key, expected) in new[] + { + ("ArrowUp", 330), + ("ArrowDown", 320), + ("Shift+ArrowUp", 370), + ("Shift+ArrowDown", 320), + ("Home", 120), + ("End", maximum), + ("ArrowUp", maximum), + ("Home", 120), + ("ArrowDown", 120) + }) + { + await page.Keyboard.PressAsync(key); + await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", expected.ToString()); + await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuetext", $"{expected} pixels high"); + await Assertions.Expect(handle).ToBeFocusedAsync(); + var dockBox = await page.Locator(".terminal-dock").BoundingBoxAsync(); + Assert.NotNull(dockBox); + Assert.InRange(dockBox.Y, 0, viewportHeight); + Assert.InRange(dockBox.Height, 120, maximum); + } + + var handleBox = await handle.BoundingBoxAsync(); + Assert.NotNull(handleBox); + var x = handleBox.X + 100; + var y = handleBox.Y + handleBox.Height / 2; + await page.Mouse.MoveAsync(x, y); + await page.Mouse.DownAsync(); + await page.Mouse.MoveAsync(x, y - 60); + await page.Mouse.UpAsync(); + var draggedHeight = (int)Math.Round(viewportHeight - (y - 60)); + await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", draggedHeight.ToString()); + await Assertions.Expect(handle).ToBeFocusedAsync(); + await page.Keyboard.PressAsync("ArrowUp"); + await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", (draggedHeight + 10).ToString()); + + var input = page.Locator(".terminal-dock-pane.active .xterm-helper-textarea"); + await input.FocusAsync(); + foreach (var key in new[] { "ArrowUp", "ArrowDown", "Shift+ArrowUp", "Shift+ArrowDown", "Home", "End" }) + { + await page.Keyboard.PressAsync(key); + await Assertions.Expect(input).ToBeFocusedAsync(); + await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", (draggedHeight + 10).ToString()); + } + + Assert.Empty(fixture.Client.ClosedTerminals); + foreach (var terminal in terminals) + { + Assert.True(await terminal.EvaluateAsync("element => element.isConnected")); + } + }); + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task ResizeHandle_ViewportChangesKeepTheHandleReachable() + { + await RunTestAsync(async page => + { + await OpenDockAsync(page); + var handle = page.GetByRole(AriaRole.Separator, new() { Name = "Terminals", Exact = true }); + await handle.FocusAsync(); + await page.Keyboard.PressAsync("End"); + + foreach (var viewportHeight in new[] { 240, 800 }) + { + await page.SetViewportSizeAsync(1280, viewportHeight); + await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuemax", viewportHeight.ToString()); + await page.Keyboard.PressAsync("End"); + await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", viewportHeight.ToString()); + await Assertions.Expect(handle).ToBeFocusedAsync(); + var box = await handle.BoundingBoxAsync(); + Assert.NotNull(box); + Assert.InRange(box.Y, 0, viewportHeight - box.Height); + } + }); + } + [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] public async Task KeyboardNavigation_SelectsTabsWithoutInterceptingTerminalInput() From 1db84792cbc59e024ec022286c45f3a31c288812 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 17:28:38 +1000 Subject: [PATCH 042/106] Bound terminal watch queues with configurable snapshot recovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor.cs | 12 + .../Dashboard/DashboardService.cs | 28 +- .../Dashboard/proto/dashboard_service.proto | 6 +- .../DistributedApplicationBuilder.cs | 2 +- .../Terminals/TerminalChange.cs | 16 +- .../Terminals/TerminalService.cs | 122 ++++++-- src/Shared/KnownConfigNames.cs | 1 + .../Layout/TerminalDockTests.cs | 78 +++++ .../Model/DashboardClientTests.cs | 13 + .../Utils/Grpc/TestServerStreamWriter.cs | 16 +- .../Dashboard/DashboardServiceTests.cs | 110 +++++++ .../Terminals/TerminalServiceTests.cs | 273 ++++++++++++++++-- tests/Shared/TestTerminalService.cs | 6 +- 13 files changed, 625 insertions(+), 58 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 1647c7013ef..a043afdf601 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -347,6 +347,18 @@ await InvokeAsync(async () => : null; } + if (!string.IsNullOrEmpty(update.Snapshot.ActivatedTerminalId)) + { + // An overflow snapshot retains the latest Show() request even if its terminal has + // since been removed. Reveal the dock, but never resurrect a removed terminal's tab. + _hasBeenOpened = true; + _isVisible = true; + if (_terminals.Any(t => t.TerminalId == update.Snapshot.ActivatedTerminalId)) + { + _activeTerminalId = update.Snapshot.ActivatedTerminalId; + } + } + // Recovery snapshots replace all prior state, including terminals removed while offline. endedTerminalIds.AddRange(_detachedTerminalIds.Where(id => !_terminals.Any(t => t.TerminalId == id))); _detachedTerminalIds.ExceptWith(endedTerminalIds); diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index b1201cdd594..b12bd1fa0a6 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -18,8 +18,10 @@ // Aspire.Hosting.Terminals cannot be imported wholesale: it declares TerminalDescriptor and TerminalChangeType, // which collide with the identically named proto types this file converts them into. Alias the individual types // instead, so the AppHost-side names read cleanly and the proto names stay unqualified. +using AppHostTerminalChange = Aspire.Hosting.Terminals.TerminalChange; using AppHostTerminalChangeType = Aspire.Hosting.Terminals.TerminalChangeType; using AppHostTerminalDescriptor = Aspire.Hosting.Terminals.TerminalDescriptor; +using AppHostTerminalSnapshot = Aspire.Hosting.Terminals.TerminalSnapshot; using TerminalService = Aspire.Hosting.Terminals.TerminalService; namespace Aspire.Hosting.Dashboard; @@ -696,14 +698,18 @@ public override async Task WatchTerminals( { // The snapshot write belongs inside the try: if the dashboard disconnects in the window between // subscribing and the first write, this throws, and letting it escape would skip the disposal above. - var snapshot = new TerminalDescriptorList(); - snapshot.Terminals.AddRange(subscription.InitialState.Select(ToProtoDescriptor)); + var snapshot = ToProtoTerminalSnapshot(subscription.InitialState, activatedTerminalId: null); await responseStream.WriteAsync(new WatchTerminalsUpdate { Snapshot = snapshot }, cancellationToken).ConfigureAwait(false); - await foreach (var change in subscription.Subscription.WithCancellation(cancellationToken).ConfigureAwait(false)) + await foreach (var update in subscription.Subscription.WithCancellation(cancellationToken).ConfigureAwait(false)) { - await responseStream.WriteAsync( - new WatchTerminalsUpdate + var message = update switch + { + AppHostTerminalSnapshot recovery => new WatchTerminalsUpdate + { + Snapshot = ToProtoTerminalSnapshot(recovery.Terminals, recovery.ActivatedTerminalId) + }, + AppHostTerminalChange change => new WatchTerminalsUpdate { Change = new TerminalChangeNotification { @@ -711,7 +717,9 @@ await responseStream.WriteAsync( Terminal = ToProtoDescriptor(change.Terminal) } }, - cancellationToken).ConfigureAwait(false); + _ => throw new InvalidOperationException("Unknown terminal watch update.") + }; + await responseStream.WriteAsync(message, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -784,6 +792,14 @@ internal async Task CloseTerminalAsync(Aspire.Hosting.Terminals.IAspireTerminal private static TerminalDescriptor ToProtoDescriptor(AppHostTerminalDescriptor descriptor) => new() { TerminalId = descriptor.Id, Title = descriptor.Title }; + private static TerminalDescriptorList ToProtoTerminalSnapshot( + IEnumerable terminals, string? activatedTerminalId) + => new() + { + Terminals = { terminals.Select(ToProtoDescriptor) }, + ActivatedTerminalId = activatedTerminalId ?? string.Empty + }; + private static TerminalChangeType ToProtoChangeType(AppHostTerminalChangeType changeType) => changeType switch { AppHostTerminalChangeType.Added => TerminalChangeType.Added, diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 29538527dd6..8431454ead8 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -535,6 +535,10 @@ message WatchTerminalsRequest { message TerminalDescriptorList { repeated TerminalDescriptor terminals = 1; + // A recovery snapshot can coalesce a pending Show() request. A nonempty id reveals + // the dock and selects that terminal if it is still listed. The id can refer to a + // terminal removed before recovery; reveal the dock without recreating its tab. + string activated_terminal_id = 2; } message TerminalChangeNotification { @@ -544,7 +548,7 @@ message TerminalChangeNotification { message WatchTerminalsUpdate { oneof kind { - // Sent once, first, carrying the terminals that already exist. + // Sent first, and again to replace buffered changes when a watcher falls behind. TerminalDescriptorList snapshot = 1; TerminalChangeNotification change = 2; } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index aba2d597d30..5bfa0e3b98e 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -475,7 +475,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) { var logger = sp.GetRequiredService>(); - return new Terminals.TerminalService(logger) + return new Terminals.TerminalService(logger, sp.GetRequiredService()) { // Terminals belonging to resources are discovered from the model rather than registered, so the // service is given a catalog to consult instead of owning their lifetime. diff --git a/src/Aspire.Hosting/Terminals/TerminalChange.cs b/src/Aspire.Hosting/Terminals/TerminalChange.cs index b7884559618..0489c265a6e 100644 --- a/src/Aspire.Hosting/Terminals/TerminalChange.cs +++ b/src/Aspire.Hosting/Terminals/TerminalChange.cs @@ -1,8 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; + namespace Aspire.Hosting.Terminals; +/// +/// An incremental change or a replacement snapshot for a terminal watcher. +/// +internal abstract record TerminalUpdate; + /// /// The dashboard-visible description of a terminal. /// @@ -32,4 +39,11 @@ internal enum TerminalChangeType /// /// A change to the set of dock terminals, broadcast to every connected dashboard. /// -internal sealed record TerminalChange(TerminalChangeType ChangeType, TerminalDescriptor Terminal); +internal sealed record TerminalChange(TerminalChangeType ChangeType, TerminalDescriptor Terminal) : TerminalUpdate; + +/// +/// Replaces buffered changes while retaining the most recent undelivered request to show the dock. +/// +internal sealed record TerminalSnapshot( + ImmutableArray Terminals, + string? ActivatedTerminalId) : TerminalUpdate; diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 71dc4049363..25cedd91d57 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; using Hex1b; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; #pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. @@ -32,19 +33,33 @@ namespace Aspire.Hosting.Terminals; /// the members the dashboard uses to attach transports and watch the dock's tab list are internal, because /// they are transport plumbing rather than something an AppHost author calls. /// +/// +/// Each dashboard metadata watcher buffers up to 64 updates by default. Set +/// ASPIRE_TERMINAL_WATCH_BUFFER_CAPACITY to a positive integer in the AppHost's configuration +/// before starting the application to tune this limit. Overflow replaces queued changes with a current +/// snapshot while preserving the latest pending request to show the dock. +/// /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] public sealed class TerminalService : IAsyncDisposable { + internal const int DefaultDockUpdateBufferCapacity = 64; + private readonly ConcurrentDictionary _terminals = new(StringComparer.Ordinal); private readonly ILogger _logger; + private readonly int _dockUpdateBufferCapacity; private readonly object _syncLock = new(); - private ImmutableHashSet> _outgoingChannels = []; + private ImmutableHashSet> _outgoingChannels = []; private int _disposed; - internal TerminalService(ILogger logger) + internal TerminalService(ILogger logger, IConfiguration configuration) { _logger = logger; + _dockUpdateBufferCapacity = configuration.GetValue(KnownConfigNames.TerminalWatchBufferCapacity, DefaultDockUpdateBufferCapacity); + if (_dockUpdateBufferCapacity <= 0) + { + throw new InvalidOperationException($"Configuration '{KnownConfigNames.TerminalWatchBufferCapacity}' must be greater than zero."); + } } /// @@ -243,7 +258,7 @@ internal IReadOnlyList ListAll() } /// - /// Subscribes to the dock's terminal list, returning the current set followed by a stream of changes. + /// Subscribes to the dock's terminal list, returning the current set followed by changes or recovery snapshots. /// /// /// The snapshot and the subscription are produced under the same lock so a terminal created concurrently @@ -253,8 +268,14 @@ internal TerminalSubscription SubscribeDockTerminals() { lock (_syncLock) { - var channel = Channel.CreateUnbounded( - new UnboundedChannelOptions { AllowSynchronousContinuations = false, SingleReader = true, SingleWriter = false }); + var channel = Channel.CreateBounded(new BoundedChannelOptions(_dockUpdateBufferCapacity) + { + AllowSynchronousContinuations = false, + // Publish also drains a full queue before replacing it with a snapshot. + SingleReader = false, + SingleWriter = true, + FullMode = BoundedChannelFullMode.Wait + }); if (_disposed != 0) { @@ -265,38 +286,59 @@ internal TerminalSubscription SubscribeDockTerminals() ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Add(c), channel); } - var initial = _terminals.Values - .Where(t => t.Placement == TerminalPlacement.Dock) - .Select(t => t.Descriptor) - .ToImmutableArray(); + var initial = GetDockSnapshot(); return new TerminalSubscription(initial, StreamChanges()) { // The channel is registered above, before the caller has a chance to enumerate. StreamChanges is an - // async iterator, so its finally only runs once someone calls MoveNextAsync -- a caller that faults - // before it starts enumerating would otherwise leave the channel registered forever, and because it - // is unbounded every later change would accumulate in it. Unsubscribe gives callers a deterministic - // way to release the registration on that path. Removing twice is harmless. - Unsubscribe = () => ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Remove(c), channel) + // async iterator, so its finally only runs once someone calls MoveNextAsync. A caller that faults + // during the initial snapshot write must still be able to release the channel and its buffer. + Unsubscribe = () => Unsubscribe(channel) }; - async IAsyncEnumerable StreamChanges([EnumeratorCancellation] CancellationToken cancellationToken = default) + async IAsyncEnumerable StreamChanges([EnumeratorCancellation] CancellationToken cancellationToken = default) { try { - await foreach (var change in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) { - yield return change; + TerminalUpdate? change; + lock (_syncLock) + { + // Do not let the reader take a newer activation between entries drained by Publish, + // then receive a recovery snapshot that replays an older activation. + channel.Reader.TryRead(out change); + } + + if (change is not null) + { + yield return change; + } } } finally { - ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Remove(c), channel); + Unsubscribe(channel); } } } } + private ImmutableArray GetDockSnapshot() + => _terminals.Values + .Where(t => t.Placement == TerminalPlacement.Dock) + .Select(t => t.Descriptor) + .ToImmutableArray(); + + private void Unsubscribe(Channel channel) + { + lock (_syncLock) + { + ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Remove(c), channel); + channel.Writer.TryComplete(); + } + } + internal void NotifyActivated(Hex1bAspireTerminal terminal) => Notify(terminal, TerminalChangeType.Activated); @@ -338,7 +380,7 @@ private void Notify(Hex1bAspireTerminal terminal, TerminalChangeType changeType) { lock (_syncLock) { - if (_terminals.ContainsKey(terminal.Id)) + if (terminal.Placement == TerminalPlacement.Dock && _terminals.ContainsKey(terminal.Id)) { Publish(new TerminalChange(changeType, terminal.Descriptor)); } @@ -365,9 +407,40 @@ internal void Remove(Hex1bAspireTerminal terminal) private void Publish(TerminalChange change) { + ImmutableArray snapshot = default; foreach (var channel in _outgoingChannels) { - channel.Writer.TryWrite(change); + if (channel.Writer.TryWrite(change)) + { + continue; + } + + // All publishers, registration and completion share _syncLock. A slow gRPC writer must neither block + // AppHost operations nor retain unlimited history. Replace its backlog with state captured under that + // same lock, so later deltas always follow the snapshot they extend. Readers may still finish sending + // an older dequeued update first; the replacement snapshot supersedes it. + string? activatedTerminalId = null; + while (channel.Reader.TryRead(out var pending)) + { + activatedTerminalId = pending switch + { + TerminalChange { ChangeType: TerminalChangeType.Activated } activation => activation.Terminal.Id, + TerminalSnapshot recovery => recovery.ActivatedTerminalId, + _ => activatedTerminalId + }; + } + + if (change.ChangeType == TerminalChangeType.Activated) + { + activatedTerminalId = change.Terminal.Id; + } + + // Share the immutable inventory when several subscribers overflow on the same publication. + if (snapshot.IsDefault) + { + snapshot = GetDockSnapshot(); + } + channel.Writer.TryWrite(new TerminalSnapshot(snapshot, activatedTerminalId)); } } @@ -419,16 +492,15 @@ public async ValueTask DisposeAsync() } /// -/// The current set of dock terminals plus a stream of subsequent changes. +/// The current set of dock terminals plus a stream of subsequent changes or recovery snapshots. /// /// -/// Dispose when the subscription is no longer needed. Enumerating to completion also -/// releases the registration, so disposing only matters on paths that abandon the subscription without ever -/// starting to enumerate it. +/// Dispose when the subscription is no longer needed, including paths that abandon it before enumeration starts. +/// Disposal completes the stream, and enumerating to completion also releases its registration. /// internal sealed record TerminalSubscription( ImmutableArray InitialState, - IAsyncEnumerable Subscription) : IDisposable + IAsyncEnumerable Subscription) : IDisposable { /// /// Releases the change-stream registration held by this subscription. Safe to call more than once. diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 8aa855a3b3c..78163260787 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -46,6 +46,7 @@ internal static class KnownConfigNames // this identity so it can shut down and unlink its sockets if the AppHost disappears. public const string TerminalHostParentProcessId = "ASPIRE_TERMINAL_HOST_PARENT_PID"; public const string TerminalHostParentProcessStartedStable = "ASPIRE_TERMINAL_HOST_PARENT_STARTED_STABLE"; + public const string TerminalWatchBufferCapacity = "ASPIRE_TERMINAL_WATCH_BUFFER_CAPACITY"; // Identity (PID + start time) of the foreground CLI that spawned a detached `aspire start` / // `aspire run --detach` child. The detached child watches this during startup and tears the diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 73a4beca4ba..cf74889ef52 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -120,6 +120,84 @@ public async Task WatchUpdates_ReplaceSnapshotAndSelectAppHostTerminals() await Services.GetRequiredService().OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock); } + [Theory] + [InlineData(false, "second", new[] { "first", "second" }, "second")] + [InlineData(true, "second", new[] { "first", "second" }, "second")] + [InlineData(false, "removed", new[] { "first" }, "first")] + [InlineData(true, "removed", new[] { "first" }, "first")] + [InlineData(false, "removed", new string[0], null)] + [InlineData(true, "removed", new string[0], null)] + public async Task RecoverySnapshot_RevealsDockWithoutResurrectingRemovedTerminals( + bool previouslyOpened, string activatedId, string[] ids, string? selectedId) + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + if (previouslyOpened) + { + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second", "removed")); + cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("[role=tab]").Count)); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + } + + var recovery = TerminalSetupHelpers.Snapshot(ids); + recovery.Snapshot.ActivatedTerminalId = activatedId; + await updates.Writer.WriteAsync(recovery); + cut.WaitForAssertion(() => + { + Assert.False(cut.Find(".terminal-dock").HasAttribute("inert")); + Assert.Empty(cut.FindAll(".terminal-dock.collapsed")); + Assert.Equal(ids, cut.FindAll("[role=tab]").Select(tab => tab.TextContent.Trim())); + Assert.Equal(ids.Length, cut.FindComponents().Count); + if (selectedId is null) + { + Assert.Equal("No terminals", cut.Find(".terminal-dock-panel-heading").TextContent); + } + else + { + Assert.Equal(selectedId, cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); + } + }); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Added, "later")); + cut.WaitForAssertion(() => Assert.Equal(ids.Append("later"), cut.FindAll("[role=tab]").Select(tab => tab.TextContent.Trim()))); + Assert.Empty(client.ClosedTerminals); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RecoverySnapshot_WithoutActivationDoesNotRevealDock(bool previouslyOpened) + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + if (previouslyOpened) + { + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + } + + var renderCount = cut.RenderCount; + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("replacement")); + cut.WaitForAssertion(() => + { + Assert.True(cut.RenderCount > renderCount); + if (previouslyOpened) + { + Assert.True(cut.Find(".terminal-dock.collapsed").HasAttribute("inert")); + Assert.Equal("replacement", cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); + } + else + { + Assert.Empty(cut.FindAll(".terminal-dock")); + } + }); + } + [Fact] public async Task SelectTab_UpdatesAccessibleSelectionWithoutRemountingPanes() { diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index eecab685409..c21cfd3f73e 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -715,6 +715,19 @@ public async Task SubscribeTerminals_StreamEnds_ResubscribesWithSnapshot(bool fa Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); Assert.Same(initial, updates.Current); + var recovery = new WatchTerminalsUpdate + { + Snapshot = new TerminalDescriptorList + { + Terminals = { new TerminalDescriptor { TerminalId = "recovered", Title = "Recovered" } }, + ActivatedTerminalId = "recovered" + } + }; + await first.Writer.WriteAsync(recovery); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Same(recovery, updates.Current); + Assert.Equal(1, Volatile.Read(ref subscriptions)); + first.Writer.Complete(failStream ? new RpcException(new Status(StatusCode.Unavailable, "Disconnected")) : null); var replacement = new WatchTerminalsUpdate { diff --git a/tests/Aspire.Hosting.TestUtilities/Utils/Grpc/TestServerStreamWriter.cs b/tests/Aspire.Hosting.TestUtilities/Utils/Grpc/TestServerStreamWriter.cs index b1fa1959875..a239c8c5681 100644 --- a/tests/Aspire.Hosting.TestUtilities/Utils/Grpc/TestServerStreamWriter.cs +++ b/tests/Aspire.Hosting.TestUtilities/Utils/Grpc/TestServerStreamWriter.cs @@ -13,6 +13,8 @@ public class TestServerStreamWriter : IServerStreamWriter where T : class public WriteOptions? WriteOptions { get; set; } + public Func? BeforeWriteAsync { get; set; } + public TestServerStreamWriter(ServerCallContext serverCallContext) { _channel = Channel.CreateUnbounded(); @@ -41,20 +43,22 @@ public async Task ReadNextAsync() throw new InvalidOperationException("Unable to read message."); } - public Task WriteAsync(T message, CancellationToken cancellationToken) + public async Task WriteAsync(T message, CancellationToken cancellationToken) { - if (_serverCallContext.CancellationToken.IsCancellationRequested || - _serverCallContext.CancellationToken.IsCancellationRequested) + _serverCallContext.CancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); + + if (BeforeWriteAsync is { } beforeWrite) { - return Task.FromCanceled(_serverCallContext.CancellationToken); + await beforeWrite(message, cancellationToken).ConfigureAwait(false); + _serverCallContext.CancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); } if (!_channel.Writer.TryWrite(message)) { throw new InvalidOperationException("Unable to write message."); } - - return Task.CompletedTask; } public Task WriteAsync(T message) diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index a87949e9e1b..62211c77065 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -1270,6 +1270,116 @@ public void ResolveFiles_UnknownInput_ReturnsNull() Assert.Empty(result); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WatchTerminals_StalledWriteRecoversInventoryAndPendingActivation(bool removeActivatedTerminal) + { + using var serviceData = CreateDashboardServiceData(); + await using var terminalService = TestTerminalService.Create(); + var terminal = Assert.IsType(terminalService.CreateTerminal(new TerminalLaunchOptions + { + Title = "Before", + Placement = TerminalPlacement.Dock, + Command = new TerminalCommand("bash") + })); + terminal.Show(); + var service = CreateDashboardService(serviceData, terminalService: terminalService); + using var cts = new CancellationTokenSource(); + var context = TestServerCallContext.Create(cancellationToken: cts.Token); + var writing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var responses = new TestServerStreamWriter(context) + { + BeforeWriteAsync = (_, cancellationToken) => + { + writing.TrySetResult(); + return resume.Task.WaitAsync(cancellationToken); + } + }; + var watch = service.WatchTerminals(new(), responses, context); + try + { + await writing.Task.DefaultTimeout(); + terminal.Show(); + for (var i = 1; i < TerminalService.DefaultDockUpdateBufferCapacity; i++) + { + terminal.Retitle($"Revision {i}"); + } + if (removeActivatedTerminal) + { + await terminal.DisposeAsync(); + } + else + { + terminal.Retitle("Recovered"); + } + resume.SetResult(); + + var initial = await responses.ReadNextAsync().DefaultTimeout(); + Assert.Equal("Before", Assert.Single(initial.Snapshot.Terminals).Title); + Assert.Equal(string.Empty, initial.Snapshot.ActivatedTerminalId); + + var recovery = WatchTerminalsUpdate.Parser.ParseFrom((await responses.ReadNextAsync().DefaultTimeout()).ToByteArray()); + Assert.Equal(terminal.Id, recovery.Snapshot.ActivatedTerminalId); + if (removeActivatedTerminal) + { + Assert.Empty(recovery.Snapshot.Terminals); + } + else + { + var descriptor = Assert.Single(recovery.Snapshot.Terminals); + Assert.Equal(terminal.Id, descriptor.TerminalId); + Assert.Equal("Recovered", descriptor.Title); + } + + var added = terminalService.CreateTerminal(new TerminalLaunchOptions + { + Title = "After recovery", + Placement = TerminalPlacement.Dock, + Command = new TerminalCommand("bash") + }); + var change = await responses.ReadNextAsync().DefaultTimeout(); + Assert.Equal(Aspire.DashboardService.Proto.V1.TerminalChangeType.Added, change.Change.ChangeType); + Assert.Equal(added.Id, change.Change.Terminal.TerminalId); + Assert.Equal("After recovery", change.Change.Terminal.Title); + } + finally + { + await cts.CancelAsync(); + await watch.DefaultTimeout(); + } + } + + [Fact] + public async Task WatchTerminals_CancellationDuringStalledWriteCompletesWatch() + { + using var serviceData = CreateDashboardServiceData(); + await using var terminalService = TestTerminalService.Create(); + var service = CreateDashboardService(serviceData, terminalService: terminalService); + using var cts = new CancellationTokenSource(); + var context = TestServerCallContext.Create(cancellationToken: cts.Token); + var writing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var responses = new TestServerStreamWriter(context) + { + BeforeWriteAsync = (_, cancellationToken) => + { + writing.TrySetResult(); + return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + }; + var watch = service.WatchTerminals(new(), responses, context); + try + { + await writing.Task.DefaultTimeout(); + } + finally + { + await cts.CancelAsync(); + await watch.DefaultTimeout(); + } + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 85ed22d61e6..db9f6c2de19 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Globalization; using System.Reflection; using System.Threading.Channels; using Aspire.Hosting.Terminals; @@ -9,6 +10,8 @@ using Aspire.Hosting.Utils; using Hex1b; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. @@ -21,6 +24,49 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class TerminalServiceTests { + [Theory] + [InlineData(1)] + [InlineData(8)] + [InlineData(128)] + public async Task SubscribeDockTerminals_UsesConfiguredCapacityFromAppHost(int capacity) + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration[KnownConfigNames.TerminalWatchBufferCapacity] = capacity.ToString(CultureInfo.InvariantCulture); + await using var app = builder.Build(); + var service = app.Services.GetRequiredService(); + var terminal = CreateDockTerminal(service, "Terminal"); + using var subscription = service.SubscribeDockTerminals(); + var channel = Assert.Single(GetOutgoingChannels(service)); + terminal.Show(); + for (var i = 1; i < capacity; i++) + { + terminal.Retitle($"Revision {i}"); + } + Assert.Equal(capacity, channel.Reader.Count); + + terminal.Retitle("Recovered"); + Assert.Equal(1, channel.Reader.Count); + await using var updates = subscription.Subscription.GetAsyncEnumerator(); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + var snapshot = Assert.IsType(updates.Current); + Assert.Equal(terminal.Id, snapshot.ActivatedTerminalId); + Assert.Equal(new TerminalDescriptor(terminal.Id, "Recovered"), Assert.Single(snapshot.Terminals)); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("invalid")] + [InlineData("2147483648")] + [InlineData("")] + public void Constructor_InvalidWatchBufferCapacity_Throws(string capacity) + { + using var configuration = new ConfigurationManager(); + configuration[KnownConfigNames.TerminalWatchBufferCapacity] = capacity; + + Assert.Throws(() => TestTerminalService.Create(configuration)); + } + [Fact] public void CreateTerminal_NullOptions_Throws() { @@ -191,8 +237,7 @@ public async Task SubscribeDockTerminals_PublishesAddedDockTerminal() await using var changes = subscription.Subscription.GetAsyncEnumerator(CancellationToken.None); Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout()); - Assert.Equal(TerminalChangeType.Added, changes.Current.ChangeType); - Assert.Equal(dock.Id, changes.Current.Terminal.Id); + Assert.Equal(new TerminalChange(TerminalChangeType.Added, new(dock.Id, "Dock")), changes.Current); } [Fact] @@ -201,14 +246,208 @@ public async Task SubscribeDockTerminals_DoesNotPublishInteractionTerminal() var service = TestTerminalService.Create(); using var subscription = service.SubscribeDockTerminals(); - CreateInteractionTerminal(service, "Dialog"); + var dialog = Assert.IsType(CreateInteractionTerminal(service, "Dialog")); + dialog.Retitle("Updated dialog"); + dialog.Show(); var dock = CreateDockTerminal(service, "Dock"); // The interaction terminal was created first, so if it were published at all it would arrive first. await using var changes = subscription.Subscription.GetAsyncEnumerator(CancellationToken.None); Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout()); - Assert.Equal(dock.Id, changes.Current.Terminal.Id); + Assert.Equal(new TerminalChange(TerminalChangeType.Added, new(dock.Id, "Dock")), changes.Current); + } + + [Fact] + public async Task SubscribeDockTerminals_OverflowReplacesBacklogWithCurrentSnapshot() + { + await using var service = TestTerminalService.Create(); + var first = CreateDockTerminal(service, "First"); + var removed = CreateDockTerminal(service, "Removed"); + CreateInteractionTerminal(service, "Dialog"); + using var subscription = service.SubscribeDockTerminals(); + var channel = Assert.Single(GetOutgoingChannels(service)); + + first.Retitle("Updated"); + await removed.DisposeAsync(); + var added = CreateDockTerminal(service, "Added"); + for (var i = 3; i < TerminalService.DefaultDockUpdateBufferCapacity; i++) + { + first.Retitle($"Revision {i}"); + } + Assert.Equal(TerminalService.DefaultDockUpdateBufferCapacity, channel.Reader.Count); + + first.Retitle("Latest"); + Assert.Equal(1, channel.Reader.Count); + await using var updates = subscription.Subscription.GetAsyncEnumerator(); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + var snapshot = Assert.IsType(updates.Current); + Assert.Null(snapshot.ActivatedTerminalId); + Assert.Equal( + new[] { new TerminalDescriptor(first.Id, "Latest"), new TerminalDescriptor(added.Id, "Added") }.OrderBy(t => t.Id), + snapshot.Terminals.OrderBy(t => t.Id)); + + added.Retitle("After recovery"); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Equal(new TerminalChange(TerminalChangeType.Retitled, new(added.Id, "After recovery")), updates.Current); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubscribeDockTerminals_RepeatedOverflowPreservesLatestPendingActivation(bool activateAgain) + { + await using var service = TestTerminalService.Create(); + var first = CreateDockTerminal(service, "First"); + var second = CreateDockTerminal(service, "Second"); + using var subscription = service.SubscribeDockTerminals(); + var channel = Assert.Single(GetOutgoingChannels(service)); + first.Show(); + second.Show(); + + for (var i = 0; i < TerminalService.DefaultDockUpdateBufferCapacity * 4; i++) + { + first.Retitle($"Revision {i}"); + if (activateAgain && i == TerminalService.DefaultDockUpdateBufferCapacity + 3) + { + first.Show(); + } + Assert.InRange(channel.Reader.Count, 1, TerminalService.DefaultDockUpdateBufferCapacity); + } + + await using var updates = subscription.Subscription.GetAsyncEnumerator(); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + var snapshot = Assert.IsType(updates.Current); + Assert.Equal(activateAgain ? first.Id : second.Id, snapshot.ActivatedTerminalId); + } + + [Fact] + public async Task SubscribeDockTerminals_ActivationThatOverflowsIsIncludedInSnapshot() + { + await using var service = TestTerminalService.Create(); + var terminal = CreateDockTerminal(service, "Terminal"); + using var subscription = service.SubscribeDockTerminals(); + for (var i = 0; i < TerminalService.DefaultDockUpdateBufferCapacity; i++) + { + terminal.Retitle($"Revision {i}"); + } + + terminal.Show(); + await using var updates = subscription.Subscription.GetAsyncEnumerator(); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Equal(terminal.Id, Assert.IsType(updates.Current).ActivatedTerminalId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubscribeDockTerminals_OverflowRetainsRevealIntentWhenActivatedTerminalWasRemoved(bool keepAnotherTerminal) + { + await using var service = TestTerminalService.Create(); + var terminal = CreateDockTerminal(service, "Activated"); + var remaining = keepAnotherTerminal ? CreateDockTerminal(service, "Remaining") : null; + using var subscription = service.SubscribeDockTerminals(); + terminal.Show(); + for (var i = 1; i < TerminalService.DefaultDockUpdateBufferCapacity; i++) + { + terminal.Retitle($"Revision {i}"); + } + + await terminal.DisposeAsync(); + await using var updates = subscription.Subscription.GetAsyncEnumerator(); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + var snapshot = Assert.IsType(updates.Current); + Assert.Equal(terminal.Id, snapshot.ActivatedTerminalId); + Assert.Equal(remaining is null ? [] : new[] { new TerminalDescriptor(remaining.Id, "Remaining") }, snapshot.Terminals); + } + + [Fact] + public async Task SubscribeDockTerminals_SlowSubscriberDoesNotDisruptFastSubscriber() + { + await using var service = TestTerminalService.Create(); + var terminal = CreateDockTerminal(service, "Terminal"); + using var slow = service.SubscribeDockTerminals(); + using var fast = service.SubscribeDockTerminals(); + await using var fastUpdates = fast.Subscription.GetAsyncEnumerator(); + + for (var i = 0; i < TerminalService.DefaultDockUpdateBufferCapacity * 4; i++) + { + var title = $"Revision {i}"; + terminal.Retitle(title); + Assert.True(await fastUpdates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Equal(new TerminalChange(TerminalChangeType.Retitled, new(terminal.Id, title)), fastUpdates.Current); + Assert.All(GetOutgoingChannels(service), + channel => Assert.InRange(channel.Reader.Count, 0, TerminalService.DefaultDockUpdateBufferCapacity)); + } + + await using var slowUpdates = slow.Subscription.GetAsyncEnumerator(); + Assert.True(await slowUpdates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.IsType(slowUpdates.Current); + } + + [Fact] + public async Task SubscribeDockTerminals_CancellationAfterOverflowReleasesRegistration() + { + await using var service = TestTerminalService.Create(); + var terminal = CreateDockTerminal(service, "Terminal"); + using var subscription = service.SubscribeDockTerminals(); + for (var i = 0; i <= TerminalService.DefaultDockUpdateBufferCapacity; i++) + { + terminal.Show(); + } + + using var cts = new CancellationTokenSource(); + await using var updates = subscription.Subscription.GetAsyncEnumerator(cts.Token); + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => updates.MoveNextAsync().AsTask()).DefaultTimeout(); + Assert.Empty(GetOutgoingChannels(service)); + } + + [Fact] + public async Task SubscribeDockTerminals_DisposalCompletesPendingRead() + { + await using var service = TestTerminalService.Create(); + using var subscription = service.SubscribeDockTerminals(); + await using var updates = subscription.Subscription.GetAsyncEnumerator(); + var next = updates.MoveNextAsync().AsTask(); + subscription.Dispose(); + + Assert.False(await next.DefaultTimeout()); + Assert.Empty(GetOutgoingChannels(service)); + } + + [Fact] + public async Task SubscribeDockTerminals_ShutdownWithBacklogStaysBoundedAndClearsInventory() + { + await using var service = TestTerminalService.Create(); + for (var i = 0; i < TerminalService.DefaultDockUpdateBufferCapacity + 5; i++) + { + CreateDockTerminal(service, $"Terminal {i}"); + } + using var subscription = service.SubscribeDockTerminals(); + var channel = Assert.Single(GetOutgoingChannels(service)); + var inventory = subscription.InitialState.ToDictionary(t => t.Id); + + await service.DisposeAsync(); + Assert.InRange(channel.Reader.Count, 1, TerminalService.DefaultDockUpdateBufferCapacity); + var recovered = false; + await foreach (var update in subscription.Subscription) + { + if (update is TerminalSnapshot snapshot) + { + recovered = true; + inventory = snapshot.Terminals.ToDictionary(t => t.Id); + } + else + { + var change = Assert.IsType(update); + Assert.Equal(TerminalChangeType.Removed, change.ChangeType); + inventory.Remove(change.Terminal.Id); + } + } + Assert.True(recovered); + Assert.Empty(inventory); + Assert.Empty(GetOutgoingChannels(service)); } [Fact] @@ -216,10 +455,10 @@ public void SubscribeDockTerminals_DisposedWithoutEnumerating_ReleasesItsChannel { var service = TestTerminalService.Create(); - // The subscription registers an unbounded channel eagerly, but StreamChanges is an async iterator whose + // The subscription registers its channel eagerly, but StreamChanges is an async iterator whose // finally only runs once someone calls MoveNextAsync. A caller that faults before it starts enumerating -- // a viewer that disconnects while the snapshot is being written, for example -- would otherwise leave a - // channel registered that every subsequent change accumulates into for the lifetime of the AppHost. + // channel and its buffer registered for the lifetime of the AppHost. var subscription = service.SubscribeDockTerminals(); Assert.Single(GetOutgoingChannels(service)); @@ -256,7 +495,7 @@ public async Task SubscribeDockTerminals_DisposedWithoutEnumerating_StopsReceivi await using var changes = live.Subscription.GetAsyncEnumerator(CancellationToken.None); Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout()); - Assert.Equal(afterwards.Id, changes.Current.Terminal.Id); + Assert.Equal(new TerminalChange(TerminalChangeType.Added, new(afterwards.Id, "Later")), changes.Current); } [Fact] @@ -283,7 +522,7 @@ public async Task CreateTerminal_AfterDispose_Throws() public async Task SubscribeDockTerminals_DuringCreation_DoesNotReplaySnapshotAsAdded() { var logger = new GatedLogger("Created Dock terminal"); - await using var service = new TerminalService(logger); + await using var service = new TerminalService(logger, new ConfigurationBuilder().Build()); var create = Task.Run(() => CreateDockTerminal(service, "Dock")); try { @@ -296,13 +535,13 @@ public async Task SubscribeDockTerminals_DuringCreation_DoesNotReplaySnapshotAsA Assert.Equal(descriptor.Id, (await create.DefaultTimeout()).Id); await service.DisposeAsync(); - var changes = new List(); + var changes = new List(); await foreach (var change in subscription.Subscription) { changes.Add(change); } - var removed = Assert.Single(changes); + var removed = Assert.IsType(Assert.Single(changes)); Assert.Equal(TerminalChangeType.Removed, removed.ChangeType); Assert.Equal(descriptor.Id, removed.Terminal.Id); } @@ -317,7 +556,7 @@ public async Task SubscribeDockTerminals_DuringCreation_DoesNotReplaySnapshotAsA public async Task SubscribeDockTerminals_DuringRemoval_DoesNotReceiveRemovalForAnAbsentSnapshotEntry() { var logger = new GatedLogger("Removed terminal"); - await using var service = new TerminalService(logger); + await using var service = new TerminalService(logger, new ConfigurationBuilder().Build()); var terminal = CreateDockTerminal(service, "Dock"); var remove = Task.Run(async () => await terminal.DisposeAsync()); try @@ -472,28 +711,28 @@ private static IAspireTerminal CreateInteractionTerminal(TerminalService service Placement = TerminalPlacement.Dialog }); - private static IAspireTerminal CreateDockTerminal(TerminalService service, string title) - => service.CreateTerminal(new TerminalLaunchOptions + private static Hex1bAspireTerminal CreateDockTerminal(TerminalService service, string title) + => Assert.IsType(service.CreateTerminal(new TerminalLaunchOptions { Title = title, Command = new TerminalCommand("bash"), Placement = TerminalPlacement.Dock - }); + })); /// /// Reads the private channel set the dock fan-out writes to. /// /// /// Registration is deliberately invisible from the public surface: a leaked channel is silent, and the only - /// observable symptom is unbounded memory growth over the AppHost's lifetime. Asserting on the set directly is + /// observable symptom is retained buffers as abandoned subscriptions accumulate. Asserting on the set directly is /// what makes the leak regression detectable at all -- a test that only checks a later subscription still /// receives changes passes whether or not the abandoned channel was released. /// - private static ImmutableHashSet> GetOutgoingChannels(TerminalService service) + private static ImmutableHashSet> GetOutgoingChannels(TerminalService service) { var field = typeof(TerminalService).GetField("_outgoingChannels", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(field); - return (ImmutableHashSet>)field.GetValue(service)!; + return (ImmutableHashSet>)field.GetValue(service)!; } } diff --git a/tests/Shared/TestTerminalService.cs b/tests/Shared/TestTerminalService.cs index 29886b6d2ad..0bc163f5c4d 100644 --- a/tests/Shared/TestTerminalService.cs +++ b/tests/Shared/TestTerminalService.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Terminals; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. @@ -22,5 +23,8 @@ namespace Aspire.Hosting.Utils; internal static class TestTerminalService { public static TerminalService Create() - => new(NullLogger.Instance); + => Create(new ConfigurationBuilder().Build()); + + public static TerminalService Create(IConfiguration configuration) + => new(NullLogger.Instance, configuration); } From 521f0db9b0a5a062ef30823c0c10e87c72a8e221 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 19:31:15 +1000 Subject: [PATCH 043/106] Expose sealed AspireTerminal handles with internal backends Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../TerminalInteractionCommands.cs | 10 +-- .../Components/Layout/TerminalDock.razor.cs | 4 +- src/Aspire.Hosting/Aspire.Hosting.csproj | 6 +- .../Dashboard/DashboardService.cs | 2 +- src/Aspire.Hosting/IInteractionService.cs | 10 +-- src/Aspire.Hosting/InteractionService.cs | 2 +- .../{IAspireTerminal.cs => AspireTerminal.cs} | 63 ++++++++++++------- .../Terminals/AspireTerminalKey.cs | 4 +- .../Terminals/Hex1bAspireTerminal.cs | 8 ++- .../Terminals/ITerminalBackend.cs | 23 +++++++ .../Terminals/ResourceAspireTerminal.cs | 8 ++- .../Terminals/ResourceTerminalCatalog.cs | 6 +- .../Terminals/TerminalAutomation.cs | 2 +- .../Terminals/TerminalChange.cs | 2 +- .../Terminals/TerminalDiagnostics.cs | 2 +- src/Aspire.Hosting/Terminals/TerminalOwner.cs | 2 +- .../Terminals/TerminalPlacement.cs | 2 +- .../Terminals/TerminalService.cs | 12 ++-- .../Dashboard/DashboardServiceTests.cs | 12 ++-- .../Terminals/AspireTerminalTests.cs | 50 +++++++++++++++ .../Terminals/Hex1bAspireTerminalTests.cs | 7 ++- .../InteractionServiceTerminalTests.cs | 10 +-- .../Terminals/ResourceAspireTerminalTests.cs | 17 ++--- .../Terminals/ResourceTerminalCatalogTests.cs | 2 +- .../Terminals/TerminalServiceTests.cs | 8 +-- ...pireTerminal.cs => TestTerminalBackend.cs} | 7 +-- 26 files changed, 194 insertions(+), 87 deletions(-) rename src/Aspire.Hosting/Terminals/{IAspireTerminal.cs => AspireTerminal.cs} (62%) create mode 100644 src/Aspire.Hosting/Terminals/ITerminalBackend.cs create mode 100644 tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs rename tests/Aspire.Hosting.Tests/Utils/{TestAspireTerminal.cs => TestTerminalBackend.cs} (77%) diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index c0fa7e3efee..ebda5542e9e 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -9,7 +9,7 @@ // InputType.Terminal is an experimental spike. PromptInputsAsync is also experimental. #pragma warning disable ASPIREINTERACTION001 -// AppHost-owned terminals - TerminalService, IAspireTerminal, TerminalCommand - are experimental. +// AppHost-owned terminals - TerminalService, AspireTerminal, TerminalCommand - are experimental. #pragma warning disable ASPIRETERMINAL002 namespace Terminals.AppHost; @@ -181,7 +181,7 @@ private static async Task ExecIntoContainerAsync( /// /// This is the counterpart to the interaction-input commands above. Instead of a modal dialog bound to a single /// dialog lifetime, the terminal becomes a tab in the dashboard's terminal dock (Shift+`) that outlives the command - /// that created it. It also exercises IAspireTerminal's automation surface — send input, wait for output, + /// that created it. It also exercises AspireTerminal's automation surface — send input, wait for output, /// read the screen — which is how AppHost code can script a terminal it owns. /// [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")] @@ -234,7 +234,7 @@ public static IResourceBuilder WithDockShellCommand(this IRes /// This is the "automate an interactive prompt" scenario. Plenty of tools an AppHost needs to invoke are only /// available as interactive console programs — they log in, prompt for confirmation, ask which subscription to /// use — and there is no API to call instead. An input plus - /// 's automation members lets AppHost code answer those prompts itself while the + /// 's automation members lets AppHost code answer those prompts itself while the /// human watches it happen, and step in whenever it cannot. /// /// @@ -429,7 +429,7 @@ await interactionService.PromptMessageBoxAsync( /// Bisection needs at most ceil(log2(limit)) guesses, so the loop is bounded by construction. The guard on an /// exhausted range only fires if the game stops answering consistently, which would otherwise spin forever. /// - private static async Task<(int Number, int Attempts)> PlayNumberGuessAsync(IAspireTerminal terminal, int limit, CancellationToken cancellationToken) + private static async Task<(int Number, int Attempts)> PlayNumberGuessAsync(AspireTerminal terminal, int limit, CancellationToken cancellationToken) { // Generous: this is the first automation call, so it is what starts the workload, and a cold // `dotnet run --file` has to compile the script before the game prints anything. @@ -473,7 +473,7 @@ await interactionService.PromptMessageBoxAsync( /// would race the rest of the line being written. Polling for one of the three complete replies has no such race, /// and the attempt number keeps an earlier reply still on screen from being misread as this one. /// - private static async Task ReadReplyAsync(IAspireTerminal terminal, int attempt, int guess, CancellationToken cancellationToken) + private static async Task ReadReplyAsync(AspireTerminal terminal, int attempt, int guess, CancellationToken cancellationToken) { var prefix = $">> #{attempt.ToString(CultureInfo.InvariantCulture)}: {guess.ToString(CultureInfo.InvariantCulture)} is "; var deadline = DateTime.UtcNow + s_promptTimeout; diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index a043afdf601..cca7a9343d5 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -92,7 +92,7 @@ protected override void OnInitialized() ShortcutManager.AddGlobalKeydownListener(this); // Watched eagerly rather than on first open: an `activated` notification is how AppHost code reveals a - // terminal it created (IAspireTerminal.Show()), and that has to work in a browser that has never opened the + // terminal it created (AspireTerminal.Show()), and that has to work in a browser that has never opened the // dock. One idle server stream per circuit is the price of that. _watchTask = Task.Run(() => WatchTerminalsAsync(_cts.Token), _cts.Token); } @@ -427,7 +427,7 @@ await InvokeAsync(async () => return _detachedTerminalIds.Remove(descriptor.TerminalId) ? descriptor.TerminalId : null; case TerminalChangeType.Activated: - // Raised by IAspireTerminal.Show() in the AppHost, so AppHost code can reveal its own terminal. + // Raised by AspireTerminal.Show() in the AppHost, so AppHost code can reveal its own terminal. if (index < 0) { _terminals.Add(descriptor); diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index e02db465bf1..364b63b340a 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -73,10 +73,8 @@ - + diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index b12bd1fa0a6..0d2d810fb48 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -754,7 +754,7 @@ public override async Task CloseTerminal( /// /// Requests disposal while bounding only the dashboard's wait for cleanup. /// - internal async Task CloseTerminalAsync(Aspire.Hosting.Terminals.IAspireTerminal terminal, CancellationToken cancellationToken) + internal async Task CloseTerminalAsync(Aspire.Hosting.Terminals.AspireTerminal terminal, CancellationToken cancellationToken) { using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var timeout = Task.Delay(TimeSpan.FromSeconds(CloseTerminalTimeoutSeconds), waitCts.Token); diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index b1a51142f90..a0e3fe6fb26 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -480,12 +480,12 @@ public long? MaxFileSize /// /// /// The supplied instance must still be registered with the resolved from the - /// current AppHost's service provider. Disposed terminals, terminals from another AppHost, and unregistered - /// implementations are rejected before the dialog is shown; matching a registered terminal's ID is not enough. + /// current AppHost's service provider. Disposed handles and handles from another AppHost are rejected + /// before the dialog is shown. /// /// /// Owning the terminal outside the interaction is what lets the AppHost script it through - /// 's automation members — before the dialog is raised, while it is open, and after + /// 's automation members — before the dialog is raised, while it is open, and after /// it closes — and lets the same terminal be shown by more than one dialog over its life. /// /// @@ -511,7 +511,7 @@ public long? MaxFileSize /// /// /// - /// The terminal's must be . A dock + /// The terminal's must be . A dock /// terminal is presented as a dock tab that outlives the code which created it, so showing one in a dialog would /// render the same terminal through two competing presentations. /// @@ -522,7 +522,7 @@ public long? MaxFileSize /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] [AspireExportIgnore(Reason = "A terminal is a live local process attached to the AppHost; it cannot be serialized to polyglot app hosts.")] - public IAspireTerminal? Terminal { get; init; } + public AspireTerminal? Terminal { get; init; } /// /// Identifies the AppHost-owned terminal created for this input. Stamped by the interaction service when the diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index 9cba8b69b91..d32c053e42c 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -188,7 +188,7 @@ public async Task> PromptInputsAsy // dialog as well would render the same terminal through two competing presentations. if (input.Terminal.Placement != TerminalPlacement.Dialog) { - throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.Terminal)} to a terminal whose {nameof(IAspireTerminal.Placement)} is {input.Terminal.Placement}. Terminals shown by an interaction must be created with {nameof(TerminalPlacement)}.{nameof(TerminalPlacement.Dialog)}."); + throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.Terminal)} to a terminal whose {nameof(AspireTerminal.Placement)} is {input.Terminal.Placement}. Terminals shown by an interaction must be created with {nameof(TerminalPlacement)}.{nameof(TerminalPlacement.Dialog)}."); } // The dashboard resolves IDs in this AppHost's registry rather than using the supplied object. diff --git a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs b/src/Aspire.Hosting/Terminals/AspireTerminal.cs similarity index 62% rename from src/Aspire.Hosting/Terminals/IAspireTerminal.cs rename to src/Aspire.Hosting/Terminals/AspireTerminal.cs index cf4588994aa..85f7725dcb4 100644 --- a/src/Aspire.Hosting/Terminals/IAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/AspireTerminal.cs @@ -6,19 +6,13 @@ namespace Aspire.Hosting.Terminals; /// -/// A terminal surfaced in the dashboard and driveable from AppHost code. +/// An Aspire-owned terminal handle for dashboard interaction and automation from AppHost code. /// /// /// -/// This is deliberately a thin, Aspire-shaped abstraction over the underlying terminal implementation -/// (currently Hex1b). Keeping the implementation type out of this interface is what lets terminals be -/// used from Aspire APIs without dragging Hex1b's very large surface area into Aspire's own. -/// -/// -/// The automation members are an intentionally small subset. Hex1b exposes a rich cell-pattern matching -/// DSL (CellPatternSearcher and around sixty supporting types); none of it is projected here. -/// "Send some input, wait for some text, read the screen" covers the scenarios a spike needs, and the -/// surface can grow later if real usage demands it. +/// Obtain a handle from or +/// . Handles cannot be constructed or extended by callers; +/// Aspire manages their registration and connection to the underlying terminal implementation. /// /// /// What disposal means depends on . For the workload @@ -34,27 +28,34 @@ namespace Aspire.Hosting.Terminals; /// /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] -public interface IAspireTerminal : IAsyncDisposable +public sealed class AspireTerminal : IAsyncDisposable { + internal AspireTerminal(ITerminalBackend backend) + { + Backend = backend; + } + + internal ITerminalBackend Backend { get; } + /// /// Gets the opaque identifier used to address this terminal over the dashboard connection. /// - string Id { get; } + public string Id => Backend.Id; /// /// Gets the title shown on the terminal's dock tab. /// - string Title { get; } + public string Title => Backend.Title; /// /// Gets the process that owns this terminal's workload. /// - TerminalOwner Owner { get; } + public TerminalOwner Owner => Backend.Owner; /// /// Gets where this terminal is displayed in the dashboard. /// - TerminalPlacement Placement { get; } + public TerminalPlacement Placement => Backend.Placement; /// /// Starts the terminal's workload if it is not already running. @@ -73,7 +74,7 @@ public interface IAspireTerminal : IAsyncDisposable /// /// /// The terminal has already stopped. - void Start(); + public void Start() => Backend.Start(); /// /// Reveals the terminal dock in every connected dashboard and switches to this terminal's tab. @@ -82,19 +83,29 @@ public interface IAspireTerminal : IAsyncDisposable /// Only meaningful for terminals. Terminals in a dialog are revealed /// by that dialog, so this is a no-op for them. /// - void Show(); + public void Show() => Backend.Show(); /// /// Sends text to the terminal's workload as though it had been typed. /// + /// The text to send. + /// Cancellation token. + /// A task representing the input operation. + /// is . /// The AppHost-owned terminal has already stopped. - Task SendTextAsync(string text, CancellationToken cancellationToken = default); + public Task SendTextAsync(string text, CancellationToken cancellationToken = default) + => Backend.SendTextAsync(text, cancellationToken); /// /// Sends a single non-printable key to the terminal's workload. /// + /// The key to send. + /// Cancellation token. + /// A task representing the input operation. + /// is not a supported key. /// The AppHost-owned terminal has already stopped. - Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default); + public Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) + => Backend.SendKeyAsync(key, cancellationToken); /// /// Waits until appears on the terminal screen. @@ -102,13 +113,23 @@ public interface IAspireTerminal : IAsyncDisposable /// The text to wait for. /// How long to wait before giving up. Defaults to 30 seconds. /// Cancellation token. + /// A task that completes when the text appears on the terminal screen. + /// is . /// The text did not appear before elapsed. /// The AppHost-owned terminal has already stopped. - Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default); + public Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + => Backend.WaitForTextAsync(text, timeout, cancellationToken); /// /// Gets the current contents of the terminal screen, with lines separated by newlines. /// + /// The current terminal screen text. /// The AppHost-owned terminal has already stopped. - string GetScreenText(); + public string GetScreenText() => Backend.GetScreenText(); + + /// + /// Releases the handle, stopping the workload only when it is owned by the AppHost. + /// + /// A task representing the terminal cleanup operation. + public ValueTask DisposeAsync() => Backend.DisposeAsync(); } diff --git a/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs b/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs index 58653d539c0..15ce1bb86a5 100644 --- a/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs +++ b/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs @@ -8,13 +8,13 @@ namespace Aspire.Hosting.Terminals; /// -/// The non-printable keys that can be sent to a terminal through . +/// The non-printable keys that can be sent to a terminal through . /// /// /// This is deliberately a small, Aspire-owned enum rather than a projection of the underlying terminal /// library's key enum. Each value maps to a raw byte sequence in , /// which keeps the mapping under Aspire's control and avoids leaking a third-party enum through -/// . +/// . /// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] public enum AspireTerminalKey diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index d3494e4b009..ff89bf83c7e 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -11,7 +11,7 @@ namespace Aspire.Hosting.Terminals; /// -/// The Hex1b-backed implementation of . +/// The Hex1b-backed implementation of . /// /// /// Clients are handed to Hex1b's HMP1 server through a channel, which lets a single terminal serve several @@ -19,7 +19,7 @@ namespace Aspire.Hosting.Terminals; /// using HMP1's multi-head support. The workload lives in the AppHost, so terminal state survives a viewer /// disconnecting entirely. /// -internal sealed class Hex1bAspireTerminal : IAspireTerminal +internal sealed class Hex1bAspireTerminal : ITerminalBackend { // Unbounded because the producer is a viewer attaching; the queue depth is realistically 0 or 1 and // dropping or blocking an attach would strand the RPC that is waiting to be served. @@ -52,8 +52,12 @@ public Hex1bAspireTerminal(TerminalService owner, string id, string title, Termi Id = id; Title = title; Placement = placement; + Handle = new(this); } + // Creation and lookup must return the same handle because interaction validation checks instance identity. + public AspireTerminal Handle { get; } + public string Id { get; } public string Title { get; private set; } diff --git a/src/Aspire.Hosting/Terminals/ITerminalBackend.cs b/src/Aspire.Hosting/Terminals/ITerminalBackend.cs new file mode 100644 index 00000000000..e3b6e19442a --- /dev/null +++ b/src/Aspire.Hosting/Terminals/ITerminalBackend.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Terminals; + +/// +/// The internal operations behind an handle. +/// +internal interface ITerminalBackend : IAsyncDisposable +{ + string Id { get; } + string Title { get; } + TerminalOwner Owner { get; } + TerminalPlacement Placement { get; } + void Start(); + void Show(); + Task SendTextAsync(string text, CancellationToken cancellationToken); + Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken); + Task WaitForTextAsync(string text, TimeSpan? timeout, CancellationToken cancellationToken); + string GetScreenText(); +} diff --git a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs index c2a20b746be..517decd2725 100644 --- a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting.Terminals; /// -/// An over a terminal that belongs to a resource replica. +/// The resource-backed implementation of . /// /// /// @@ -31,7 +31,7 @@ namespace Aspire.Hosting.Terminals; /// resizing the grid out from under a human who is watching the same terminal. /// /// -internal sealed class ResourceAspireTerminal : IAspireTerminal +internal sealed class ResourceAspireTerminal : ITerminalBackend { /// /// How long to wait for the HMP1 handshake before treating the terminal host as unreachable. @@ -58,8 +58,12 @@ public ResourceAspireTerminal(string id, string title, string consumerUdsPath, I Title = title; _consumerUdsPath = consumerUdsPath; _logger = logger; + Handle = new(this); } + // Repeated catalog lookups share this handle for as long as its cached automation peer is live. + public AspireTerminal Handle { get; } + public string Id { get; } public string Title { get; } diff --git a/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs b/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs index 01bb1ba1960..06702339b07 100644 --- a/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs +++ b/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs @@ -11,7 +11,7 @@ namespace Aspire.Hosting.Terminals; /// /// Discovers the terminals that belong to resources in the application model, and hands out -/// handles for them. +/// handles for them. /// /// /// @@ -104,7 +104,7 @@ public IReadOnlyList List() /// /// Gets a handle for a resource terminal by its stable id. /// - public bool TryGetTerminal(string terminalId, out IAspireTerminal? terminal) + public bool TryGetTerminal(string terminalId, out AspireTerminal? terminal) { terminal = null; @@ -142,7 +142,7 @@ public bool TryGetTerminal(string terminalId, out IAspireTerminal? terminal) _handles[entry.Id] = handle; } - terminal = handle; + terminal = handle.Handle; } return true; diff --git a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs index 61c2f82ba93..e819527ba38 100644 --- a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs +++ b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting.Terminals; /// -/// The shared implementation of 's automation members. +/// The shared implementation of 's automation members. /// /// /// Every terminal Aspire exposes is ultimately a , whether its workload runs in the diff --git a/src/Aspire.Hosting/Terminals/TerminalChange.cs b/src/Aspire.Hosting/Terminals/TerminalChange.cs index 0489c265a6e..e15d13f5aef 100644 --- a/src/Aspire.Hosting/Terminals/TerminalChange.cs +++ b/src/Aspire.Hosting/Terminals/TerminalChange.cs @@ -30,7 +30,7 @@ internal enum TerminalChangeType Retitled, /// - /// was called. Dashboards should reveal the dock and switch to + /// was called. Dashboards should reveal the dock and switch to /// this terminal's tab. /// Activated diff --git a/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs b/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs index 66fc2561bb7..8a2c1a87bcd 100644 --- a/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs +++ b/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.Terminals; internal static class TerminalDiagnostics { /// - /// Terminals owned by the AppHost process — , + /// Terminals owned by the AppHost process — , /// and the types they take. /// /// diff --git a/src/Aspire.Hosting/Terminals/TerminalOwner.cs b/src/Aspire.Hosting/Terminals/TerminalOwner.cs index 2c37380abf6..0317a068b87 100644 --- a/src/Aspire.Hosting/Terminals/TerminalOwner.cs +++ b/src/Aspire.Hosting/Terminals/TerminalOwner.cs @@ -26,7 +26,7 @@ public enum TerminalOwner /// /// /// These terminals run out-of-process in a per-replica terminal host rather than in the AppHost, so - /// disposing the releases Aspire's handle on the terminal without stopping + /// disposing the releases Aspire's handle on the terminal without stopping /// the underlying workload. /// Resource diff --git a/src/Aspire.Hosting/Terminals/TerminalPlacement.cs b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs index e20316a42ab..9dc8ec6822c 100644 --- a/src/Aspire.Hosting/Terminals/TerminalPlacement.cs +++ b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs @@ -37,7 +37,7 @@ public enum TerminalPlacement /// The terminal is not displayed anywhere. /// /// - /// Terminals driven purely through the automation members of never need a + /// Terminals driven purely through the automation members of never need a /// viewer. Giving that case its own value keeps it out of the dock's tab list without having to pretend it /// belongs to a dialog or a resource. /// diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 25cedd91d57..5990533c71a 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -86,7 +86,7 @@ internal TerminalService(ILogger logger, IConfiguration configu /// The placement in is not , /// , or . /// - public IAspireTerminal CreateTerminal(TerminalLaunchOptions options) + public AspireTerminal CreateTerminal(TerminalLaunchOptions options) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options.Command); @@ -130,7 +130,7 @@ private static Hex1bTerminalBuilder CreateBuilder(TerminalCommand command) /// cannot describe — notably the dock's built-in terminal, which runs an /// in-process Hex1b app rather than a child process. /// - internal IAspireTerminal CreateTerminal(string title, TerminalPlacement placement, Hex1bTerminalBuilder builder) + internal AspireTerminal CreateTerminal(string title, TerminalPlacement placement, Hex1bTerminalBuilder builder) { ArgumentNullException.ThrowIfNull(title); ArgumentNullException.ThrowIfNull(builder); @@ -164,7 +164,7 @@ internal IAspireTerminal CreateTerminal(string title, TerminalPlacement placemen _logger.LogDebug("Created {Placement} terminal {TerminalId} ({Title}).", placement, id, title); - return terminal; + return terminal.Handle; } /// @@ -187,20 +187,20 @@ internal Task AttachAsync(string terminalId, Stream clientStream, Func /// Gets a terminal by id. /// - /// The of the terminal to find. + /// The of the terminal to find. /// The terminal, if one with that id exists. /// if the terminal was found. /// /// Resolves terminals the AppHost owns as well as those belonging to resources, so automation code can /// drive either kind through the same handle without knowing which it has. /// - public bool TryGetTerminal(string terminalId, [NotNullWhen(true)] out IAspireTerminal? terminal) + public bool TryGetTerminal(string terminalId, [NotNullWhen(true)] out AspireTerminal? terminal) { ArgumentNullException.ThrowIfNull(terminalId); if (_terminals.TryGetValue(terminalId, out var found)) { - terminal = found; + terminal = found.Handle; return true; } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 62211c77065..8de8983bda1 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -1282,7 +1282,7 @@ public async Task WatchTerminals_StalledWriteRecoversInventoryAndPendingActivati Title = "Before", Placement = TerminalPlacement.Dock, Command = new TerminalCommand("bash") - })); + }).Backend); terminal.Show(); var service = CreateDashboardService(serviceData, terminalService: terminalService); using var cts = new CancellationTokenSource(); @@ -1401,7 +1401,7 @@ public async Task AttachTerminal_WorkloadEndedReportsStatusWithoutHmpHandshake(b if (endedBeforeAttach) { workload.SignalDisconnected(); - await Assert.IsType(terminal).WorkloadEnded.DefaultTimeout(); + await Assert.IsType(terminal.Backend).WorkloadEnded.DefaultTimeout(); } using var cts = new CancellationTokenSource(); @@ -1559,7 +1559,7 @@ public async Task CloseTerminal_BlockingDisposal_TimeoutOrCancellationObservesLa var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var terminal = new TestAspireTerminal("blocking") + var terminal = new AspireTerminal(new TestTerminalBackend("blocking") { OnDispose = () => { @@ -1575,7 +1575,7 @@ public async Task CloseTerminal_BlockingDisposal_TimeoutOrCancellationObservesLa completed.TrySetResult(); } } - }; + }); try { @@ -1637,10 +1637,10 @@ public async Task CloseTerminal_DisposalFailure_IsLoggedAndPropagated(bool timeo var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); var service = CreateDashboardService(serviceData, logger: logger, terminalService: terminalService); Exception failure = timeoutException ? new TimeoutException("Workload timeout.") : new InvalidOperationException("Disposal failed."); - var terminal = new TestAspireTerminal("failing") + var terminal = new AspireTerminal(new TestTerminalBackend("failing") { OnDispose = () => synchronous ? throw failure : ValueTask.FromException(failure) - }; + }); var exception = await Record.ExceptionAsync(() => service.CloseTerminalAsync(terminal, CancellationToken.None)).DefaultTimeout(); diff --git a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs new file mode 100644 index 00000000000..691602b6340 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Aspire.Hosting.Terminals; +using Aspire.Hosting.Utils; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +[Trait("Partition", "2")] +public class AspireTerminalTests +{ + [Fact] + public void PublicHandleIsSealedWithOnlyAnInternalConstructor() + { + Assert.True(typeof(AspireTerminal).IsPublic); + Assert.True(typeof(AspireTerminal).IsSealed); + Assert.Empty(typeof(AspireTerminal).GetConstructors()); + var constructor = Assert.Single(typeof(AspireTerminal).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic)); + Assert.True(constructor.IsAssembly); + Assert.False(typeof(ITerminalBackend).IsVisible); + Assert.Equal([typeof(IAsyncDisposable)], typeof(AspireTerminal).GetInterfaces()); + } + + [Fact] + public async Task HandlePreservesIdentityAndLiveMetadata() + { + await using var service = TestTerminalService.Create(); + await using var terminal = service.CreateTerminal(new TerminalLaunchOptions + { + Title = "Before", + Placement = TerminalPlacement.Dialog, + Command = new TerminalCommand("bash") + }); + var backend = Assert.IsType(terminal.Backend); + backend.Retitle("After"); + + Assert.Same(terminal, backend.Handle); + Assert.True(service.TryGetTerminal(terminal.Id, out var found)); + Assert.Same(terminal, found); + Assert.Equal("After", terminal.Title); + Assert.Equal(TerminalOwner.AppHost, terminal.Owner); + Assert.Equal(TerminalPlacement.Dialog, terminal.Placement); + + await terminal.DisposeAsync(); + Assert.False(service.TryGetTerminal(terminal.Id, out _)); + } +} diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs index b420cface56..7d71ea24d6d 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -35,7 +35,7 @@ public async Task WorkloadExit_EndsAutomationAndKeepsTabUntilDisposed(bool attac // Raw stream workloads report disconnection explicitly. Observe output first; Hex1b's completion is // not an output-drain barrier, and this test makes no claim about preserving the final screen. workload.SignalDisconnected(); - await Assert.IsType(terminal).WorkloadEnded.DefaultTimeout(); + await Assert.IsType(terminal.Backend).WorkloadEnded.DefaultTimeout(); Assert.True(service.TryGetTerminal(terminal.Id, out var registered)); Assert.Same(terminal, registered); @@ -102,6 +102,11 @@ await Task.WhenAll( await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout(); Assert.Equal("automation-input"u8.ToArray(), bytes); + await terminal.SendKeyAsync(AspireTerminalKey.Enter).DefaultTimeout(); + bytes = new byte[1]; + await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout(); + Assert.Equal("\r"u8.ToArray(), bytes); + await outputWriter.WriteAsync("after-disconnect\r\n"u8.ToArray()); await Task.WhenAll( second.WaitForTextAsync("after-disconnect"), diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index a29a5d1691b..c5cfe6022fa 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -133,14 +133,16 @@ public async Task PromptInputsAsync_UnregisteredTerminal_ThrowsBeforePublishing( var (interactionService, terminalService) = CreateInteractionService(); await using var serviceOwner = terminalService; await using var registeredTerminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - await using var unregisteredTerminal = new TestAspireTerminal(useRegisteredId ? registeredTerminal.Id : "unregistered"); + var backend = new TestTerminalBackend(useRegisteredId ? registeredTerminal.Id : "unregistered"); + await using var unregisteredTerminal = new AspireTerminal(backend); var validInput = new InteractionInput { Name = "valid", InputType = InputType.Terminal, Terminal = registeredTerminal }; var invalidInput = new InteractionInput { Name = "invalid", InputType = InputType.Terminal, Terminal = unregisteredTerminal }; - Assert.Equal(useRegisteredId, unregisteredTerminal.Equals(registeredTerminal)); + Assert.Equal(useRegisteredId, backend.Equals(registeredTerminal.Backend)); + Assert.NotEqual(registeredTerminal, unregisteredTerminal); await AssertTerminalRejectedAsync(interactionService, [validInput, invalidInput], invalidInput.Name); - Assert.False(unregisteredTerminal.IsDisposed); + Assert.False(backend.IsDisposed); Assert.True(terminalService.TryGetTerminal(registeredTerminal.Id, out var registered)); Assert.Same(registeredTerminal, registered); } @@ -354,7 +356,7 @@ private static Task CancelInteractionAsync(InteractionService interactionService (_, _, _) => new InteractionCompletionState { Complete = true }, CancellationToken.None); - private static IAspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement) + private static AspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement) => service.CreateTerminal(new TerminalLaunchOptions { Title = "Terminal", diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs index e99052b36b3..d2b5fc95816 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs @@ -35,12 +35,13 @@ public async Task AutomationTypesIntoAndReadsBackFromATerminalHost() // have the workload run it, read the result off the replicated screen. await using var host = await TestResourceTerminalHost.StartAsync(CreateSocketPath()); - await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance).Handle; // The shell echoes the command line before its output, so a fixed marker would match the echo of the // input rather than the result. Splitting the literal across a quote means the typed line and the // output line differ, and only the output line contains the marker. - await terminal.SendTextAsync("echo apphost-was\"\"-here\r").DefaultTimeout(); + await terminal.SendTextAsync("echo apphost-was\"\"-here").DefaultTimeout(); + await terminal.SendKeyAsync(AspireTerminalKey.Enter).DefaultTimeout(); await terminal.WaitForTextAsync("apphost-was-here", TimeSpan.FromSeconds(30)).DefaultTimeout(); @@ -54,7 +55,7 @@ public async Task WaitForTextThrowsTimeoutWhenTheTextNeverAppears() await using var host = await TestResourceTerminalHost.StartAsync(CreateSocketPath()); - await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance).Handle; // Establish the connection first so the timeout under test is the wait, not the handshake. await terminal.SendTextAsync("\r").DefaultTimeout(); @@ -68,7 +69,7 @@ public async Task AutomationFailsWhenNoTerminalHostIsListening() { var missingSocket = Path.Combine(_socketDirectory, "not-listening.sock"); - await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", missingSocket, NullLogger.Instance); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", missingSocket, NullLogger.Instance).Handle; // A replica whose terminal host is gone must surface as a failed automation call rather than hanging // until the connect timeout expires on every subsequent call. @@ -78,7 +79,7 @@ public async Task AutomationFailsWhenNoTerminalHostIsListening() [Fact] public async Task DisposeIsSafeWhenNothingEverConnected() { - var terminal = new ResourceAspireTerminal("resource:test:0", "test", Path.Combine(_socketDirectory, "unused.sock"), NullLogger.Instance); + var terminal = new ResourceAspireTerminal("resource:test:0", "test", Path.Combine(_socketDirectory, "unused.sock"), NullLogger.Instance).Handle; // Listing terminals hands out handles that are never automated, so disposing an unconnected handle is // the common case rather than an edge case. @@ -91,7 +92,7 @@ public async Task AutomationRetriesAfterTheTerminalHostStarts() Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); var socketPath = CreateSocketPath(); - await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance).Handle; await Assert.ThrowsAnyAsync(() => terminal.SendTextAsync("not-delivered")).DefaultTimeout(); @@ -108,7 +109,7 @@ public async Task AutomationReconnectsAfterTheTerminalHostRestarts() var socketPath = CreateSocketPath(); await using var firstHost = await TestResourceTerminalHost.StartAsync(socketPath); - await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance).Handle; await terminal.SendTextAsync("echo first\"\"-host\r").DefaultTimeout(); await terminal.WaitForTextAsync("first-host").DefaultTimeout(); @@ -128,7 +129,7 @@ public async Task CancelingOneCallerDoesNotCancelTheSharedConnectionAttempt() using var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); listener.Bind(new UnixDomainSocketEndPoint(socketPath)); listener.Listen(); - await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance); + await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance).Handle; using var cts = new CancellationTokenSource(); // Accept the transport but withhold the HMP1 handshake so cancellation occurs during connection setup. diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs index c255e185344..41125e1f479 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs @@ -164,7 +164,7 @@ public async Task TryGetTerminalReplacesADisposedHandle() Assert.True(catalog.TryGetTerminal(id, out var replacement)); Assert.NotSame(first, replacement); Assert.Equal(id, replacement!.Id); - Assert.False(Assert.IsType(replacement).IsDisposed); + Assert.False(Assert.IsType(replacement.Backend).IsDisposed); Assert.True(catalog.TryGetTerminal(id, out var repeated)); Assert.Same(replacement, repeated); } diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index db9f6c2de19..61958299439 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -246,7 +246,7 @@ public async Task SubscribeDockTerminals_DoesNotPublishInteractionTerminal() var service = TestTerminalService.Create(); using var subscription = service.SubscribeDockTerminals(); - var dialog = Assert.IsType(CreateInteractionTerminal(service, "Dialog")); + var dialog = Assert.IsType(CreateInteractionTerminal(service, "Dialog").Backend); dialog.Retitle("Updated dialog"); dialog.Show(); var dock = CreateDockTerminal(service, "Dock"); @@ -693,7 +693,7 @@ public void ListAll_WithoutAResourceCatalogReturnsOnlyAppHostTerminals() Assert.Single(service.ListAll()); } - private static IAspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement, bool useBuilder) + private static AspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement, bool useBuilder) => useBuilder ? service.CreateTerminal("Shell", placement, Hex1bTerminal.CreateBuilder().WithPtyProcess("bash")) : service.CreateTerminal(new TerminalLaunchOptions @@ -703,7 +703,7 @@ private static IAspireTerminal CreateTerminal(TerminalService service, TerminalP Placement = placement }); - private static IAspireTerminal CreateInteractionTerminal(TerminalService service, string title) + private static AspireTerminal CreateInteractionTerminal(TerminalService service, string title) => service.CreateTerminal(new TerminalLaunchOptions { Title = title, @@ -717,7 +717,7 @@ private static Hex1bAspireTerminal CreateDockTerminal(TerminalService service, s Title = title, Command = new TerminalCommand("bash"), Placement = TerminalPlacement.Dock - })); + }).Backend); /// /// Reads the private channel set the dock fan-out writes to. diff --git a/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs b/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs similarity index 77% rename from tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs rename to tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs index 2fc97bef7b5..1251e2e9204 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestAspireTerminal.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Utils; -internal sealed class TestAspireTerminal(string id) : IAspireTerminal +internal sealed class TestTerminalBackend(string id) : ITerminalBackend { public string Id { get; } = id; public string Title => "Test terminal"; @@ -23,9 +23,8 @@ internal sealed class TestAspireTerminal(string id) : IAspireTerminal public Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public string GetScreenText() => throw new NotSupportedException(); - // An implementation may compare terminals by ID, but that must not make it interchangeable with the - // registered instance: the dashboard attaches to the registered object, not the supplied implementation. - public override bool Equals(object? obj) => obj is IAspireTerminal other && string.Equals(Id, other.Id, StringComparison.Ordinal); + // Backend equality must not make distinct public handles interchangeable. + public override bool Equals(object? obj) => obj is ITerminalBackend other && string.Equals(Id, other.Id, StringComparison.Ordinal); public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Id); public ValueTask DisposeAsync() From f7cf6d26bdae705a2ce67084ec586c9b6976895b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 19:35:36 +1000 Subject: [PATCH 044/106] Await number-guess automation after dialog cancellation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../TerminalInteractionCommands.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index ebda5542e9e..a5be1ed0987 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -315,11 +315,12 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde var playTask = PlayNumberGuessAsync(terminal, limit, gameCts.Token); - // If the human closes the dialog first the terminal is torn down underneath us, so stop playing. - if (await Task.WhenAny(dialogTask, playTask).ConfigureAwait(false) == dialogTask) + // The dialog only borrows the terminal. Cancel and join automation before leaving this scope, + // where the caller-owned terminal is disposed, and observe failures even after the dialog closes. + var dialogClosed = await Task.WhenAny(dialogTask, playTask).ConfigureAwait(false) == dialogTask; + if (dialogClosed) { await gameCts.CancelAsync(); - return CommandResults.Failure("Canceled"); } int number; @@ -328,7 +329,7 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde { (number, attempts) = await playTask; } - catch (OperationCanceledException) + catch (OperationCanceledException) when (gameCts.IsCancellationRequested) { return CommandResults.Failure("Canceled"); } @@ -345,6 +346,11 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde return CommandResults.Failure(ex.Message); } + if (dialogClosed) + { + return CommandResults.Failure("Canceled"); + } + // Leave the winning line on screen long enough to read before the dialog disappears. await Task.Delay(TimeSpan.FromSeconds(2), commandContext.CancellationToken); From 288d1847f824b368331021fc9b3a4b4187ece06c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 20:08:30 +1000 Subject: [PATCH 045/106] Fix terminal key validation and dashboard shortcut handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Dialogs/InteractionsInputDialog.razor.cs | 6 +- src/Aspire.Dashboard/wwwroot/js/app.js | 16 ++-- .../Terminals/AspireTerminal.cs | 6 +- .../Playwright/DashboardInteractionsTests.cs | 74 ++++++++++++++++++- .../Terminals/AspireTerminalTests.cs | 58 +++++++++++++++ .../Utils/TestTerminalBackend.cs | 4 +- 6 files changed, 149 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs index 1c733d3f257..c979464de09 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs @@ -273,9 +273,9 @@ private async Task ToggleSecretTextVisibilityAsync(InputViewModel inputModel) } /// - /// Builds the WebSocket endpoint that a terminal-typed input's TerminalView connects to. The AppHost keys - /// terminal sessions by interaction id and input name, so both travel in the query string; the dashboard resolves - /// them into an AttachTerminal gRPC call server-side. + /// Builds the WebSocket endpoint that a terminal-typed input's TerminalView connects to. The query string + /// carries the terminal's opaque ID, which the dashboard forwards in an AttachTerminal gRPC call to + /// resolve the existing terminal in the AppHost's registry. /// private static string BuildInteractionTerminalEndpoint(InputViewModel inputModel) { diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index b96c0b254c7..e7741aedde3 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -248,6 +248,14 @@ window.registerGlobalKeydownListener = function (shortcutManager) { function calculateShortcut(e) { if (modifierKeysExceptShiftNotPressed(e)) { + // Match the physical Shift+Backquote gesture across keyboard layouts, not the produced character. + // The focused-input guard runs before this, so terminal and text inputs still receive their keys. + // To toggle from terminal input, press F6 first to focus its controls. + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code + if (e.shiftKey && e.code === "Backquote") { + return 400; + } + /* general shortcuts */ switch (e.key) { case "?": // help @@ -267,14 +275,6 @@ window.registerGlobalKeydownListener = function (shortcutManager) { case "_": // decrease panel size case "-": return 340; - - // Shift+` toggles the terminal dock. Deliberately handled here, below the isActiveElementInput guard, - // rather than as a special case above it: Shift+` is `~`, which users legitimately type in a terminal - // (~/ for home) and in any text field, so it must reach the focused element instead of being claimed - // as a shortcut. To toggle the dock from a focused terminal, press F6 first to move focus to the - // terminal controls. Ctrl+` would not need that, but window managers and desktop apps intercept it. - case "~": - return 400; } } diff --git a/src/Aspire.Hosting/Terminals/AspireTerminal.cs b/src/Aspire.Hosting/Terminals/AspireTerminal.cs index 85f7725dcb4..59dab042de3 100644 --- a/src/Aspire.Hosting/Terminals/AspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/AspireTerminal.cs @@ -105,7 +105,11 @@ public Task SendTextAsync(string text, CancellationToken cancellationToken = def /// is not a supported key. /// The AppHost-owned terminal has already stopped. public Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) - => Backend.SendKeyAsync(key, cancellationToken); + { + // Reject invalid keys before the backend can start a workload or connect to a resource terminal. + _ = AspireTerminalKeySequences.Get(key); + return Backend.SendKeyAsync(key, cancellationToken); + } /// /// Waits until appears on the terminal screen. diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs index 58e91b928b6..0c8915f866d 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs @@ -12,8 +12,8 @@ namespace Aspire.Dashboard.Tests.Integration.Playwright; -// Functional coverage for the net-new interactive behaviors implemented purely in app.js: grid -// column auto-fit (double-click a resize handle) and the floating scroll-to-bottom button for +// Functional coverage for interactive behaviors implemented purely in app.js: global keyboard shortcuts, +// grid column auto-fit (double-click a resize handle), and the floating scroll-to-bottom button for // large scroll regions. These carry real runtime logic (column/track alignment, overflow/edge // thresholds) and are coupled to specific markup (".resize-handle", ".continuous-scroll-overflow"). // Scanning resting page state can't catch a regression here, so we drive the interactions and assert @@ -26,6 +26,76 @@ public DashboardInteractionsTests(InteractionsDashboardServerFixture dashboardSe { } + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task TerminalDockShortcut_UsesPhysicalKeyAndPreservesInputGuard() + { + await RunTestAsync(async page => + { + await page.SetContentAsync(""" + + + + + + """); + await page.AddScriptTagAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, "wwwroot", "js", "app.js") }); + await page.EvaluateAsync(""" + () => { + const host = document.getElementById('fluent'); + host.attachShadow({ mode: 'open' }).appendChild(document.createElement('input')); + } + """); + + var cases = new (string Key, string Code, bool Shift, bool Alt, bool Ctrl, bool Meta, string Target, int? Expected)[] + { + ("~", "Backquote", true, false, false, false, "control", 400), + ("\u00b0", "Backquote", true, false, false, false, "control", 400), + ("Dead", "Backquote", true, false, false, false, "control", 400), + ("~", "BracketRight", true, false, false, false, "control", null), + ("`", "Backquote", false, false, false, false, "control", null), + ("~", "Backquote", false, false, false, false, "control", null), + ("~", "Backquote", true, true, false, false, "control", null), + ("~", "Backquote", true, false, true, false, "control", null), + ("~", "Backquote", true, false, false, true, "control", null), + ("~", "Backquote", true, false, false, false, "input", null), + ("\u00b0", "Backquote", true, false, false, false, "textarea", null), + ("~", "Backquote", true, false, false, false, "terminal", null), + ("\u00b0", "Backquote", true, false, false, false, "fluent", null), + ("S", "KeyS", true, false, false, false, "control", 110), + ("r", "KeyR", false, false, false, false, "control", 200) + }; + + foreach (var (key, code, shiftKey, altKey, ctrlKey, metaKey, target, expected) in cases) + { + var shortcuts = await page.EvaluateAsync(""" + ({ key, code, shiftKey, altKey, ctrlKey, metaKey, target }) => { + const calls = []; + const registration = window.registerGlobalKeydownListener({ + invokeMethodAsync: (_, shortcut) => { + calls.push(shortcut); + return Promise.resolve(); + } + }); + try { + const host = document.getElementById(target); + const input = host.shadowRoot?.querySelector('input') ?? host; + input.focus(); + input.dispatchEvent(new KeyboardEvent('keydown', { + key, code, shiftKey, altKey, ctrlKey, metaKey, + bubbles: true, composed: true + })); + return calls; + } finally { + window.unregisterGlobalKeydownListener(registration); + } + } + """, new { key, code, shiftKey, altKey, ctrlKey, metaKey, target }); + Assert.Equal(expected is { } shortcut ? [shortcut] : Array.Empty(), shortcuts); + } + }); + } + [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] public async Task GridColumn_DoubleClickResizeHandle_AutoFitsColumnWidth() diff --git a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs index 691602b6340..1da8a5910b0 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs @@ -12,6 +12,64 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class AspireTerminalTests { + [Theory] + [InlineData(-1, false)] + [InlineData(-1, true)] + [InlineData(int.MaxValue, false)] + [InlineData(int.MaxValue, true)] + public async Task SendKeyAsync_InvalidKey_DoesNotInvokeBackend(int value, bool canceled) + { + var invoked = false; + var backend = new TestTerminalBackend("invalid-key") + { + OnSendKey = (_, _) => + { + invoked = true; + return Task.CompletedTask; + } + }; + await using var terminal = new AspireTerminal(backend); + using var cts = new CancellationTokenSource(); + if (canceled) + { + cts.Cancel(); + } + + var key = (AspireTerminalKey)value; + var exception = Assert.Throws(() => + { + _ = terminal.SendKeyAsync(key, cts.Token); + }); + + Assert.Equal("key", exception.ParamName); + Assert.Equal(key, exception.ActualValue); + Assert.False(invoked); + } + + [Fact] + public async Task SendKeyAsync_DeclaredKeys_ForwardKeyAndCancellationToken() + { + List<(AspireTerminalKey Key, CancellationToken Token)> calls = []; + var backend = new TestTerminalBackend("valid-key") + { + OnSendKey = (key, token) => + { + calls.Add((key, token)); + return Task.CompletedTask; + } + }; + await using var terminal = new AspireTerminal(backend); + using var cts = new CancellationTokenSource(); + var keys = Enum.GetValues(); + + foreach (var key in keys) + { + await terminal.SendKeyAsync(key, cts.Token); + } + + Assert.Equal(keys.Select(key => (key, cts.Token)), calls); + } + [Fact] public void PublicHandleIsSealedWithOnlyAnInternalConstructor() { diff --git a/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs b/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs index 1251e2e9204..aab8f6b8389 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs @@ -15,11 +15,13 @@ internal sealed class TestTerminalBackend(string id) : ITerminalBackend public TerminalPlacement Placement => TerminalPlacement.Dialog; public bool IsDisposed { get; private set; } public Func? OnDispose { get; set; } + public Func? OnSendKey { get; set; } public void Start() => throw new NotSupportedException(); public void Show() => throw new NotSupportedException(); public Task SendTextAsync(string text, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) + => OnSendKey?.Invoke(key, cancellationToken) ?? throw new NotSupportedException(); public Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public string GetScreenText() => throw new NotSupportedException(); From 9022836e448855a0eb357d1a6129a2bdf760f924 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 7 Sep 2026 23:00:45 +1000 Subject: [PATCH 046/106] Migrate dashboard terminals to Hex1b web terminal Pair the published HWT1 browser client with Hex1b, preserve remote terminal ownership and graphics state, and isolate terminal shadow-DOM keyboard input from dashboard shortcuts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- Directory.Packages.props | 3 +- docs/specs/with-terminal.md | 62 +- eng/github-ci/test-trigger-map.yml | 9 + src/Aspire.Dashboard/Aspire.Dashboard.csproj | 4 +- .../Components/Controls/TerminalView.razor | 14 +- .../Components/Controls/TerminalView.razor.cs | 175 +- .../Controls/TerminalView.razor.css | 30 + .../Components/Controls/TerminalView.razor.js | 1679 +++-------------- .../Components/Pages/ConsoleLogs.razor | 5 +- .../Components/Pages/ConsoleLogs.razor.cs | 20 +- .../Resources/ConsoleLogs.Designer.cs | 42 + .../Resources/ConsoleLogs.resx | 21 + .../Resources/xlf/ConsoleLogs.cs.xlf | 35 + .../Resources/xlf/ConsoleLogs.de.xlf | 35 + .../Resources/xlf/ConsoleLogs.es.xlf | 35 + .../Resources/xlf/ConsoleLogs.fr.xlf | 35 + .../Resources/xlf/ConsoleLogs.it.xlf | 35 + .../Resources/xlf/ConsoleLogs.ja.xlf | 35 + .../Resources/xlf/ConsoleLogs.ko.xlf | 35 + .../Resources/xlf/ConsoleLogs.pl.xlf | 35 + .../Resources/xlf/ConsoleLogs.pt-BR.xlf | 35 + .../Resources/xlf/ConsoleLogs.ru.xlf | 35 + .../Resources/xlf/ConsoleLogs.tr.xlf | 35 + .../Resources/xlf/ConsoleLogs.zh-Hans.xlf | 35 + .../Resources/xlf/ConsoleLogs.zh-Hant.xlf | 35 + .../DefaultTerminalConnectionResolver.cs | 10 +- .../Terminal/TerminalWebSocketProxy.cs | 508 ++--- src/Aspire.Dashboard/package-lock.json | 22 + src/Aspire.Dashboard/package.json | 13 + .../scripts/update-terminal-assets.mjs | 23 + .../scripts/verify-terminal-assets.mjs | 35 + .../wwwroot/fonts/cascadia-mono-nf/README.md | 45 - src/Aspire.Dashboard/wwwroot/js/README.md | 91 + src/Aspire.Dashboard/wwwroot/js/app.js | 9 +- .../wwwroot/js/hex1b-web-terminal/LICENSE | 21 + .../wwwroot/js/hex1b-web-terminal/README.md | 243 +++ .../cascadia-mono-nf/CascadiaMonoNF.woff2 | Bin .../dist}/fonts/cascadia-mono-nf/LICENSE.txt | 0 .../dist/fonts/cascadia-mono-nf/README.md | 29 + .../dist/history-state.d.ts | 27 + .../dist/history-state.d.ts.map | 1 + .../hex1b-web-terminal/dist/history-state.js | 194 ++ .../dist/history-state.js.map | 1 + .../js/hex1b-web-terminal/dist/index.d.ts | 5 + .../js/hex1b-web-terminal/dist/index.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/index.js | 4 + .../js/hex1b-web-terminal/dist/index.js.map | 1 + .../hex1b-web-terminal/dist/input-policy.d.ts | 42 + .../dist/input-policy.d.ts.map | 1 + .../hex1b-web-terminal/dist/input-policy.js | 143 ++ .../dist/input-policy.js.map | 1 + .../hex1b-web-terminal/dist/mouse-input.d.ts | 26 + .../dist/mouse-input.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/mouse-input.js | 356 ++++ .../dist/mouse-input.js.map | 1 + .../js/hex1b-web-terminal/dist/protocol.d.ts | 17 + .../hex1b-web-terminal/dist/protocol.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/protocol.js | 270 +++ .../hex1b-web-terminal/dist/protocol.js.map | 1 + .../js/hex1b-web-terminal/dist/renderer.d.ts | 113 ++ .../hex1b-web-terminal/dist/renderer.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/renderer.js | 595 ++++++ .../hex1b-web-terminal/dist/renderer.js.map | 1 + .../dist/selection-input.d.ts | 36 + .../dist/selection-input.d.ts.map | 1 + .../dist/selection-input.js | 69 + .../dist/selection-input.js.map | 1 + .../hex1b-web-terminal/dist/selection-ui.d.ts | 24 + .../dist/selection-ui.d.ts.map | 1 + .../hex1b-web-terminal/dist/selection-ui.js | 113 ++ .../dist/selection-ui.js.map | 1 + .../dist/terminal-font.d.ts | 23 + .../dist/terminal-font.d.ts.map | 1 + .../hex1b-web-terminal/dist/terminal-font.js | 85 + .../dist/terminal-font.js.map | 1 + .../dist/terminal-sizing.d.ts | 8 + .../dist/terminal-sizing.d.ts.map | 1 + .../dist/terminal-sizing.js | 38 + .../dist/terminal-sizing.js.map | 1 + .../dist/terminal-theme.d.ts | 2 + .../dist/terminal-theme.d.ts.map | 1 + .../hex1b-web-terminal/dist/terminal-theme.js | 39 + .../dist/terminal-theme.js.map | 1 + .../dist/terminal-worker.d.ts | 2 + .../dist/terminal-worker.d.ts.map | 1 + .../dist/terminal-worker.js | 285 +++ .../dist/terminal-worker.js.map | 1 + .../js/hex1b-web-terminal/dist/types.d.ts | 312 +++ .../js/hex1b-web-terminal/dist/types.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/types.js | 2 + .../js/hex1b-web-terminal/dist/types.js.map | 1 + .../hex1b-web-terminal/dist/validation.d.ts | 3 + .../dist/validation.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/validation.js | 7 + .../hex1b-web-terminal/dist/validation.js.map | 1 + .../hex1b-web-terminal/dist/web-terminal.d.ts | 51 + .../dist/web-terminal.d.ts.map | 1 + .../hex1b-web-terminal/dist/web-terminal.js | 728 +++++++ .../dist/web-terminal.js.map | 1 + .../hex1b-web-terminal/dist/wire-types.d.ts | 218 +++ .../dist/wire-types.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/wire-types.js | 2 + .../hex1b-web-terminal/dist/wire-types.js.map | 1 + .../js/hex1b-web-terminal/package.json | 42 + .../wwwroot/js/hmp1-client.js | 354 ---- .../wwwroot/js/xterm/addon-fit.min.js | 8 - .../wwwroot/js/xterm/xterm.min.css | 8 - .../wwwroot/js/xterm/xterm.min.js | 8 - .../Controls/TerminalViewTests.cs | 143 ++ .../JavaScript/KeyboardShortcuts.test.mjs | 62 + .../JavaScript/TerminalView.test.mjs | 432 +++++ .../Pages/ConsoleLogsTerminalTests.cs | 1 + .../Shared/TestNavigationManager.cs | 20 + .../Shared/TerminalTestHost.cs | 122 ++ .../Terminal/TerminalWebSocketTests.cs | 202 ++ .../DashboardTerminalScriptTests.cs | 35 + 116 files changed, 6614 insertions(+), 2262 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css create mode 100644 src/Aspire.Dashboard/package-lock.json create mode 100644 src/Aspire.Dashboard/package.json create mode 100644 src/Aspire.Dashboard/scripts/update-terminal-assets.mjs create mode 100644 src/Aspire.Dashboard/scripts/verify-terminal-assets.mjs delete mode 100644 src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf/README.md create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/LICENSE create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md rename src/Aspire.Dashboard/wwwroot/{ => js/hex1b-web-terminal/dist}/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2 (100%) rename src/Aspire.Dashboard/wwwroot/{ => js/hex1b-web-terminal/dist}/fonts/cascadia-mono-nf/LICENSE.txt (100%) create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/README.md create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json delete mode 100644 src/Aspire.Dashboard/wwwroot/js/hmp1-client.js delete mode 100644 src/Aspire.Dashboard/wwwroot/js/xterm/addon-fit.min.js delete mode 100644 src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.css delete mode 100644 src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.js create mode 100644 tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/JavaScript/KeyboardShortcuts.test.mjs create mode 100644 tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Shared/TestNavigationManager.cs create mode 100644 tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs create mode 100644 tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs create mode 100644 tests/Infrastructure.Tests/WorkflowScripts/DashboardTerminalScriptTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d441d67cb1e..ebc02b0f3c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -112,7 +112,8 @@ - + + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 8ea33c9db5c..1092f6aa19b 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -15,7 +15,7 @@ builder.AddProject("agent") .WithTerminal(); ``` -The dashboard then renders an xterm.js terminal per replica, and the CLI +The dashboard then renders a Hex1b web terminal per replica, and the CLI exposes the same session as `aspire terminal agent --replica 0`. ## Process topology @@ -63,10 +63,11 @@ version 1), which already handles: - Authenticated stream factory hooks (we only use Unix-socket transport today) -The `Hmp1WorkloadAdapter` is what the AppHost-side terminal host uses to -multiplex DCP's PTY traffic to the consumer-facing listener; the -`Hmp1PresentationAdapter` is what consumers (Dashboard WebSocket proxy and -the CLI) use to attach. +The terminal host uses `DcpUpstreamAdapter` for DCP's minimal single-peer +protocol and `Hmp1PresentationAdapter` for its consumer-facing listener. +The dashboard and CLI attach using `Hmp1WorkloadAdapter`. The dashboard +adds a per-browser `Hex1bTerminal` mirror with `Hwt1PresentationAdapter`; +the browser receives authoritative terminal state rather than parsing ANSI. ## Property contract (gRPC `ResourceService` snapshots) @@ -93,34 +94,47 @@ endpoint at `/api/terminal?resource=&replica=`. `TerminalWebSocketProxy` resolves the connection entirely server-side: -1. `ITerminalConnectionResolver.ConnectAsync(resourceName, replicaIndex, ct)` +1. The same-origin WebSocket gate rejects missing or cross-origin `Origin` + headers before resolving a resource, in addition to frontend authorization. +2. `ITerminalConnectionResolver.ConnectAsync(resourceName, replicaIndex, ct)` walks `IDashboardClient.GetResources()`, matches by `DisplayName` + `TryGetTerminalReplicaInfo`, and connects via `Hmp1Transports.ConnectUnixSocket(consumerUdsPath, ct)`. -2. The proxy wraps the resulting stream in `Hmp1WorkloadAdapter` and runs - two pumps: - - **Inbound (browser → producer):** binary frames are forwarded as HMP v1 - `Input` (keystrokes); text frames are parsed as JSON resize control - messages (`{"type":"resize","cols":N,"rows":N}`). - - **Outbound (producer → browser):** VT bytes from the producer become - binary WebSocket frames; resize hints from the producer become JSON - text frames. -3. Frame type — not content — distinguishes keystroke from control. This - keeps the proxy's parser cheap and avoids ambiguity around binary input - that happens to look like JSON. -4. Multi-fragment WS reads are reassembled in `ReassembledFrame` using - `ArrayPool`. +3. The handler connects a public `Hmp1WorkloadAdapter`, attaches a per-view + terminal and `Hwt1PresentationAdapter`, and runs the two transport pumps. + Incoming UTF-8 JSON messages are reassembled up to 64 KiB and passed to + `HandleMessageAsync`. Each `ReadFrameAsync` result is sent as one complete + binary WebSocket message, without dropping or reordering frames. +4. Hex1b owns input encoding, primary-role negotiation, selection, history, + graphics projection, acknowledgements and state resynchronization. The + dashboard bounds handshake and send times and cancels both pumps when + either transport ends. Disposing a view disconnects only that peer, not + the AppHost-owned producer. The browser never sees `consumerUdsPath` and cannot induce the dashboard to connect to an arbitrary local socket — it can only ask for `(resource, replica)` pairs that are present in the resource snapshot stream. +### Browser requirements and package pairing + +The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at +exactly `0.167.0-alpha.1509.1.1f47fd9`. HWT1 is experimental state transfer +between these paired packages, not a stable wire contract implemented by +Aspire. Upgrade both together. The full npm `dist` tree is vendored, including +module workers, relative imports, fonts and licenses. + +This release requires a secure context (HTTPS or localhost), WebGPU, +OffscreenCanvas and module workers. Unsupported browsers display an error; +there is no xterm.js fallback. Sixel and Kitty Graphics Protocol are rendered +from server-authoritative state. Historical rendering is text-only. The +dashboard's independent console-log view remains available. + ### Console / Terminal view toggle For a terminal-enabled resource the dashboard `ConsoleLogs` page mounts **both** `LogViewer` (the resource's standard log stream) and -`TerminalView` (the interactive xterm.js terminal) at the same time and +`TerminalView` (the interactive Hex1b web terminal) at the same time and flips between them via a pair of **Console logs** / **Terminal** items rendered inside the toolbar's options (⋯) `AspireMenuButton`: @@ -133,10 +147,10 @@ rendered inside the toolbar's options (⋯) `AspireMenuButton`: or a different resource is selected (which resets to Console). - Both views stay mounted across flips (visibility is toggled with `display:none` on a wrapper `
`); the log subscription and the - xterm/HMP1 consumer session are kept alive so neither view loses + Hex1b/HMP1 consumer session are kept alive so neither view loses scrollback or has to re-handshake on toggle. After a `display:none → visible` transition the page calls `refreshLayout` on the JS terminal - to guarantee xterm rebinds to the new available space. + to fit the terminal to the new available space. The console log stream is now subscribed to for terminal-enabled resources too (previously it was suppressed), which is what makes the @@ -145,7 +159,7 @@ Console view non-empty for a `WithTerminal()` resource. ## CLI `aspire terminal [--replica N]` (`Aspire.Cli/Commands/TerminalCommand.cs`) -opens its own `Hmp1PresentationAdapter` against the consumer UDS path +opens its own `Hmp1WorkloadAdapter` against the consumer UDS path returned by `IBackchannel.GetTerminalInfoAsync(resource, replica)` and renders frames into the host terminal via Hex1b's `Hex1bTerminal`. When the resource has more than one replica and the CLI is interactive, it prompts @@ -187,6 +201,6 @@ as a Phase 3 follow-up on the parent issue. | CLI command | `src/Aspire.Cli/Commands/TerminalCommand.cs` | | Dashboard WebSocket proxy | `src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs` | | Dashboard resolver | `src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs`| -| `TerminalView` (xterm.js host) | `src/Aspire.Dashboard/Components/Controls/TerminalView.razor.*` | +| `TerminalView` (Hex1b web host) | `src/Aspire.Dashboard/Components/Controls/TerminalView.razor.*` | | Property keys | `src/Shared/Model/KnownProperties.cs` (`Terminal.*`) | | Playground sample | `playground/Terminals/Terminals.AppHost/AppHost.cs` | diff --git a/eng/github-ci/test-trigger-map.yml b/eng/github-ci/test-trigger-map.yml index 4f6ede749d1..d90610379da 100644 --- a/eng/github-ci/test-trigger-map.yml +++ b/eng/github-ci/test-trigger-map.yml @@ -99,6 +99,15 @@ ignore: # Path rules: a glob set -> a target set. One mechanism; the comment headers below are organization # only. targets may be test: / job: / a GROUP name / ALL. path_rules: + - paths: + - src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js + - src/Aspire.Dashboard/wwwroot/js/app.js + - src/Aspire.Dashboard/package.json + - src/Aspire.Dashboard/package-lock.json + - src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/** + - tests/Aspire.Dashboard.Components.Tests/JavaScript/** + targets: [test:Infrastructure.Tests] + reason: DashboardTerminalScriptTests executes the terminal lifecycle JavaScript and reads the paired package assets outside its MSBuild graph. # --- catch-all: build infrastructure & broadly shared code -> ALL -------------------------- - paths: diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index b9d79ed8edb..a4a837ebc16 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -1,5 +1,4 @@  - win-x86;win-x64;win-arm64;linux-x64;linux-arm64;linux-musl-x64;osx-x64;osx-arm64 @@ -329,10 +328,9 @@ + - - diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index 192288e2039..ab6102b0b03 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -1,3 +1,15 @@ @namespace Aspire.Dashboard.Components.Controls -
+
+
+ @if (_terminalError is not null) + { +
+ @GetErrorMessage() + @if (_terminalError != "unsupported") + { + @Loc[nameof(Resources.ConsoleLogs.TerminalRetry)] + } +
+ } +
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 5bfaa2de180..aa6afbbb0a5 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -3,14 +3,14 @@ using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Controls; /// -/// Renders an interactive terminal using xterm.js, connected to the resource's -/// per-replica terminal session via a WebSocket bridge to the AppHost-owned -/// terminal host (HMP v1 over Unix domain socket). +/// Renders a WebGPU terminal connected to the resource's per-replica session +/// through the dashboard's HWT1 presentation endpoint. /// public sealed partial class TerminalView : ComponentBase, IAsyncDisposable { @@ -21,7 +21,7 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable private string? _connectedResourceName; private int _connectedReplicaIndex = -1; // Highest reconnect generation we've observed from JS via a toolbar - // snapshot. The JS side bumps `state.reconnect.generation` on every + // snapshot. The JS side bumps `state.generation` on every // initTerminal / reconnectTerminal / auto-reconnect. `reconnectTerminal` // keeps the same terminal id, so terminal id alone can't tell us whether // a late-arriving `onExit` or `OnTerminalStateChanged` callback belongs @@ -35,12 +35,16 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable // below would see _connectedResourceName == null, mistake that for "rebind // needed", call ReconnectAsync, and — because _terminalId is also still 0 // — fall through to InitializeTerminalAsync a second time. Each - // initTerminal call appends a brand-new xterm host element to the same + // initTerminal call appends a brand-new terminal host element to the same // Blazor container, leaving multiple stacked terminals in the DOM that // mirror the same input/output stream. This pattern is easy to trigger // on a resource stop+restart where the dashboard fires a burst of // resource-snapshot-driven re-renders right after the page mounts. private bool _initStarted; + private bool _initializationFailed; + private bool _disposed; + private string? _terminalError; + private Task? _initializationTask; /// /// Gets or sets the user-facing display name of the resource that owns the @@ -72,9 +76,12 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Inject] public required NavigationManager NavigationManager { get; init; } + [Inject] + public required IStringLocalizer Loc { get; init; } + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (string.IsNullOrEmpty(ResourceName)) + if (_disposed || _initializationFailed || string.IsNullOrEmpty(ResourceName)) { return; } @@ -92,16 +99,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender) var initResource = ResourceName; var initReplica = ReplicaIndex; await InitializeTerminalAsync(initResource!, initReplica); - // Only record the connected resource/replica when JS init actually - // produced a terminal. If _terminalId is still 0, InitializeTerminalAsync - // caught an exception; leaving _connectedResourceName null lets the - // rebind branch below (and future renders) notice and retry rather - // than silently masking the failure. - if (_terminalId != 0) + if (_disposed || _terminalId == 0) { - _connectedResourceName = initResource; - _connectedReplicaIndex = initReplica; + return; } + _connectedResourceName = initResource; + _connectedReplicaIndex = initReplica; if (!string.Equals(ResourceName, _connectedResourceName, StringComparison.Ordinal) || ReplicaIndex != _connectedReplicaIndex) @@ -118,6 +121,9 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } catch (Exception) { + _terminalError = "mount-failed"; + _initializationFailed = true; + StateHasChanged(); return; } @@ -132,7 +138,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // path will set _connectedResourceName / _connectedReplicaIndex and // any future rebind needed will be caught on the next render after // that. Without this guard the rebind branch below would re-enter - // initialization and stack a second xterm onto the same container — + // initialization and stack a second terminal onto the same container — // see the comment on _initStarted. if (_initStarted && _terminalId == 0) { @@ -142,8 +148,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // The same TerminalView instance is reused across resource/replica // switches in the parent (e.g. ConsoleLogs page selects a different // terminal-enabled resource). Detect that here and rebind the - // underlying WebSocket; xterm.js is preserved and just gets cleared - // and refilled by the new connection's StateSync replay. + // underlying WebSocket and mounted client. The client owns a single + // connection; reconnect must create a fresh view and worker. // // ALL exceptions are swallowed at this layer because OnAfterRenderAsync // is a Blazor lifecycle method: an unhandled exception here can fail @@ -166,9 +172,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } catch (Exception) { - // Defensive: any other JS-side error must not bubble out of - // a Blazor lifecycle method. The reconnect loop on the JS - // side keeps retrying so a transient hiccup heals itself. + // Keep the failure local to this view instead of tearing down + // the SignalR circuit, but do not hide it from the user. + _terminalError = "mount-failed"; + _initializationFailed = true; + StateHasChanged(); return; } _connectedResourceName = newResource; @@ -176,18 +184,37 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } - private async Task InitializeTerminalAsync(string resourceName, int replicaIndex) + private Task InitializeTerminalAsync(string resourceName, int replicaIndex) + { + if (_initializationTask is { IsCompleted: false }) + { + return _initializationTask; + } + + _initStarted = true; + return _initializationTask = InitializeTerminalCoreAsync(resourceName, replicaIndex); + } + + private async Task InitializeTerminalCoreAsync(string resourceName, int replicaIndex) { try { - _jsModule = await JS.InvokeAsync( - "import", "/Components/Controls/TerminalView.razor.js"); + var moduleUri = new Uri(new Uri(NavigationManager.BaseUri), "Components/Controls/TerminalView.razor.js"); + _jsModule ??= await JS.InvokeAsync("import", moduleUri.PathAndQuery); + + if (_disposed) + { + await JSInteropHelpers.SafeDisposeAsync(_jsModule); + _jsModule = null; + return; + } _selfRef ??= DotNetObjectReference.Create(this); _connectedGeneration = -1; _terminalId = await _jsModule.InvokeAsync( - "initTerminal", _terminalElement, BuildWebSocketUrl(resourceName, replicaIndex), _selfRef); + "initTerminal", _terminalElement, BuildWebSocketUrl(resourceName, replicaIndex), _selfRef, + Loc[nameof(Resources.ConsoleLogs.TerminalInputLabel)].Value); } catch (JSDisconnectedException) { @@ -204,17 +231,21 @@ private async Task InitializeTerminalAsync(string resourceName, int replicaIndex // importing the module or during initTerminal) must not bubble // out of a Blazor lifecycle method — that can tear down the // SignalR circuit and take the whole dashboard tab with it. - // Clear _initStarted so a subsequent render can retry, and leave - // _terminalId == 0 so the firstRender path in OnAfterRenderAsync - // does not record a connected resource for a terminal that was - // never created. + // Offer an explicit retry rather than re-entering initialization + // on every render while the module remains unavailable. _initStarted = false; + _initializationFailed = true; + _terminalError = "mount-failed"; + if (!_disposed) + { + StateHasChanged(); + } } } /// - /// Reconnects the terminal to a different resource/replica. When both - /// arguments match the current values this is a no-op. + /// Reconnects the terminal to a resource/replica. Matching arguments still + /// create a fresh connection so a failed connection can be retried. /// public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) { @@ -225,6 +256,11 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) if (!string.IsNullOrEmpty(newResourceName)) { await InitializeTerminalAsync(newResourceName, newReplicaIndex); + if (_terminalId != 0) + { + _connectedResourceName = newResourceName; + _connectedReplicaIndex = newReplicaIndex; + } } return; } @@ -248,6 +284,8 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) if (generation > 0) { _connectedGeneration = generation; + _connectedResourceName = newResourceName; + _connectedReplicaIndex = newReplicaIndex; } } catch (JSDisconnectedException) @@ -263,22 +301,27 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) /// renders whatever the most recent snapshot says. /// [JSInvokable] - public Task OnTerminalStateChanged(TerminalToolbarState state) + public async Task OnTerminalStateChanged(TerminalToolbarState state) { if (IsStaleTerminalCallback(state.TerminalId, state.Generation)) { - return Task.CompletedTask; + return; } - return OnToolbarStateChanged.InvokeAsync(state); + if (_terminalError != state.Error) + { + _terminalError = state.Error; + StateHasChanged(); + } + await OnToolbarStateChanged.InvokeAsync(state); } private bool IsStaleTerminalCallback(int terminalId, int generation) { // Drop stale callbacks that arrive after this view was rebound. The - // terminal id changes when initTerminal allocates a new xterm host; + // terminal id changes when initTerminal allocates a new terminal host; // explicit reconnect keeps the id but bumps the JS-side generation. - if (_terminalId != 0 && terminalId != _terminalId) + if (_disposed || (_terminalId != 0 && terminalId != _terminalId)) { return true; } @@ -379,12 +422,9 @@ public async Task RefreshToolbarStateAsync() } /// - /// Asks the JS terminal to recompute its layout. Called by the host - /// page when the terminal element transitions from hidden back to - /// visible (e.g. the user flips the page-level View dropdown from - /// Console back to Terminal) — display:none → visible does not always - /// trigger ResizeObserver, so forcing a relayout here guarantees the - /// terminal fills the available space immediately. + /// Notifies the JS terminal when it becomes visible, starting a deferred + /// mount or refreshing selection overlays without remounting the client. + /// The Hex1b client observes container geometry and owns terminal fitting. /// public async Task RefreshLayoutAsync() { @@ -404,12 +444,48 @@ public async Task RefreshLayoutAsync() private string BuildWebSocketUrl(string resource, int replica) { var baseUri = new Uri(NavigationManager.BaseUri); + var endpoint = new Uri(baseUri, "api/terminal"); var wsScheme = baseUri.Scheme == "https" ? "wss" : "ws"; - return $"{wsScheme}://{baseUri.Authority}/api/terminal?resource={Uri.EscapeDataString(resource)}&replica={replica}"; + return $"{wsScheme}://{endpoint.Authority}{endpoint.AbsolutePath}?resource={Uri.EscapeDataString(resource)}&replica={replica}"; + } + + private string GetErrorMessage() => Loc[_terminalError switch + { + "unsupported" => nameof(Resources.ConsoleLogs.TerminalWebGpuUnsupported), + "disconnected" => nameof(Resources.ConsoleLogs.TerminalDisconnected), + "input-failed" => nameof(Resources.ConsoleLogs.TerminalInputFailed), + "sizing-failed" => nameof(Resources.ConsoleLogs.TerminalSizingFailed), + _ => nameof(Resources.ConsoleLogs.TerminalMountFailed) + }]; + + private async Task RetryAsync() + { + _initializationFailed = false; + _terminalError = null; + try + { + await ReconnectAsync(ResourceName, ReplicaIndex); + } + catch (JSException) + { + _terminalError = "mount-failed"; + } } public async ValueTask DisposeAsync() { + if (_disposed) + { + return; + } + _disposed = true; + // JS init returns its id without waiting for the first frame, but the + // interop round trip can still overlap component disposal. Wait for + // that id before disposing the module so its worker cannot be orphaned. + if (_initializationTask is not null) + { + await _initializationTask; + } if (_jsModule is not null && _terminalId != 0) { try @@ -424,7 +500,9 @@ public async ValueTask DisposeAsync() if (_jsModule is not null) { await JSInteropHelpers.SafeDisposeAsync(_jsModule); + _jsModule = null; } + _terminalId = 0; _selfRef?.Dispose(); _selfRef = null; } @@ -447,10 +525,10 @@ public sealed record TerminalToolbarState ///
public string Status { get; init; } = "connecting"; - /// True once the HMP1 client has a peer id assigned. + /// True after the mounted client has presented its first connected frame. public bool Connected { get; init; } - /// True when this client owns primary input on the producer. + /// True when this client owns resize authority; all peers can send input. public bool IsPrimary { get; init; } /// True when "Take control" is meaningful to surface. @@ -465,7 +543,7 @@ public sealed record TerminalToolbarState ///
public string SizeKey { get; init; } = "auto"; - /// Current xterm font size in CSS pixels. + /// Current terminal font size in CSS pixels. public int FontPx { get; init; } /// Whether font ± buttons should be enabled (Auto mode + primary). @@ -474,11 +552,14 @@ public sealed record TerminalToolbarState /// Whether the size dropdown should be enabled (primary). public bool SizeSelectEnabled { get; init; } - /// Current xterm grid width. + /// Current server-authoritative grid width. public int Cols { get; init; } - /// Current xterm grid height. + /// Current server-authoritative grid height. public int Rows { get; init; } + + /// Localized error category, or when healthy. + public string? Error { get; init; } } /// diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css new file mode 100644 index 00000000000..1cd196a69fe --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css @@ -0,0 +1,30 @@ +.terminal-view { + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.terminal-container { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.terminal-error { + position: absolute; + inset-inline: 12px; + top: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border: 1px solid var(--neutral-stroke-rest); + border-radius: var(--border-radius); + background: var(--neutral-layer-1); + color: var(--neutral-foreground-rest); +} diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index df8597bad58..553b77ec99a 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -1,1479 +1,392 @@ -// xterm.js terminal integration for the Aspire Dashboard. The browser -// speaks HMP v1 directly to the dashboard's /api/terminal WebSocket -// endpoint, which is a dumb byte pipe to the upstream Aspire.TerminalHost -// over the resource's per-replica consumer UDS. From the upstream's -// perspective this tab is a regular HMP v1 peer in the multi-head -// roster, so take-control / role-change / state-replay all flow -// through end-to-end without any dashboard-side translation. -// -// xterm.js is loaded via script tags (not ES module import) because -// the minified bundle uses UMD format, not ESM exports. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. -import { Hmp1Client } from "/js/hmp1-client.js"; +import { WebTerminal, MIN_FONT_SIZE, MAX_FONT_SIZE } from "../../js/hex1b-web-terminal/dist/index.js"; const terminals = new Map(); let nextId = 1; -const textEncoder = new TextEncoder(); - -// Diagnostics gate. Set window.__aspireTerminalDebug = true in DevTools -// before loading the page (or before the first terminal is opened) to -// emit a structured trace of every lifecycle event. Default off so the -// console is quiet for end users. -function dbg(state, event, extra) { - if (!window.__aspireTerminalDebug) return; - const id = state ? state.id : '-'; - const t = performance.now().toFixed(1); - const tag = `[term#${id} +${t}ms]`; - if (extra !== undefined) { - console.log(tag, event, extra); - } else { - console.log(tag, event); - } -} - -function ensureXtermLoaded() { - return new Promise((resolve, reject) => { - if (window.Terminal) { - resolve(); - return; - } - - // Load CSS - if (!document.querySelector('link[href*="xterm.min.css"]')) { - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = '/js/xterm/xterm.min.css'; - document.head.appendChild(link); - } - - // Load xterm.js - const xtermScript = document.createElement('script'); - xtermScript.src = '/js/xterm/xterm.min.js'; - xtermScript.onload = () => { - // Load fit addon - const fitScript = document.createElement('script'); - fitScript.src = '/js/xterm/addon-fit.min.js'; - fitScript.onload = () => resolve(); - fitScript.onerror = (e) => reject(new Error('Failed to load xterm fit addon')); - document.head.appendChild(fitScript); - }; - xtermScript.onerror = (e) => reject(new Error('Failed to load xterm.js')); - document.head.appendChild(xtermScript); - }); -} - -// Auto-reconnect configuration. The dashboard WS may close for many -// reasons during normal operation: the underlying process exits and DCP -// relaunches it (the terminal host's TerminalReplica recycle loop rebinds -// its UDS in between), the user restarts the resource from the dashboard, -// or transient network/IPC issues. We treat ALL closes as transient and -// retry with exponential backoff up to MAX_RECONNECT_ATTEMPTS, after which -// we give up and write a one-line "[disconnected]" hint into the terminal -// so a stopped/removed resource doesn't leave the JS hammering the server -// at 1-attempt-every-5-seconds forever and the user understands why the -// terminal is no longer updating. -// -// Each state has a single reconnect "generation" counter. Every time we -// open a new client the generation bumps; client.on* callbacks compare -// against the captured generation and bail if a newer connect has -// superseded them. This prevents two failure modes: -// 1. A late onClose from client N firing AFTER client N+1 has connected -// and scheduling a redundant reconnect. -// 2. An explicit reconnectTerminal() call colliding with a pending -// auto-reconnect timer (the new connect bumps the generation, so -// the timer's callback no-ops when it fires). +const DEFAULT_FONT_SIZE = 13; const RECONNECT_BACKOFF_MS = [500, 1000, 2000, 4000, 5000]; -const MAX_RECONNECT_ATTEMPTS = 30; // ≈ 5*4 + 26*5 ≈ 150s of trying - -function pickReconnectDelay(attempt) { - const idx = Math.min(attempt, RECONNECT_BACKOFF_MS.length - 1); - return RECONNECT_BACKOFF_MS[idx]; -} - -function scheduleReconnect(state) { - if (!state.reconnect.enabled) { - return; - } - if (state.reconnect.timer !== null) { - return; - } - if (state.reconnect.attempts >= MAX_RECONNECT_ATTEMPTS) { - try { - state.term.write('\r\n\x1b[33m[terminal disconnected — reload the page or re-select the resource to retry]\x1b[0m\r\n'); - } catch { /* ignore */ } - dbg(state, 'scheduleReconnect: gave up', { attempts: state.reconnect.attempts }); - return; - } - const delay = pickReconnectDelay(state.reconnect.attempts); - state.reconnect.attempts++; - dbg(state, 'scheduleReconnect: scheduled', { attempt: state.reconnect.attempts, delayMs: delay }); - state.reconnect.timer = setTimeout(() => { - state.reconnect.timer = null; - if (!state.reconnect.enabled) { - return; - } - connectClient(state, state.wsUrl); - }, delay); -} - -function cancelPendingReconnect(state) { - if (state.reconnect.timer !== null) { - clearTimeout(state.reconnect.timer); - state.reconnect.timer = null; - } -} - -// --- Primary-mode sizing controls ---------------------------------------- -// -// Lifted from samples/WebMuxerDemo/wwwroot/js/app.js (Hex1b 0.147.0). See -// docs/muxer-learnings.md sections 3 (the three render modes) and 4 -// (state sync, mode-transition triggers) for the design contract. -// -// In primary mode we drive the producer's PTY dims, so we expose a footer -// with two mutually-exclusive sizing modes: -// -// "font" (Auto) : user controls font size with +/- buttons; FitAddon -// picks cols×rows to fill the available stage at that -// font. Window resize → fit → new cols×rows broadcast. -// -// "fixed" (preset): user picks a grid (e.g. 80×24) from the dropdown; -// we compute the largest font that makes that grid -// fill the stage and lock cols×rows. Window resize → -// recompute font, cols×rows stay fixed (no broadcast). -// -// In secondary mode (someone else is primary), both control groups hide -// (.read-only) and we lock our xterm grid to the producer's cols×rows, -// then pick the largest integer font size whose rendered grid fits the -// viewport (letterboxing on whichever axis has spare room). This mirrors -// primary fixed-mode; we deliberately avoid CSS transform: scale() here -// because xterm.js computes mouse-to-cell coordinates from -// getBoundingClientRect (which returns transformed dims) divided by its -// internally-measured cell width (which is untransformed), so any -// scale ≠ 1 offsets text selection by roughly the scale factor. -const MIN_FONT_PX = 4; -const MAX_FONT_PX = 72; -const DEFAULT_FONT_PX = 13; +const MAX_RECONNECT_ATTEMPTS = 30; const SIZE_PRESETS = [ - // NOTE: The "Auto" label is overridden on the .NET side in - // ConsoleLogs.razor.cs (OnTerminalToolbarStateChangedAsync) using the - // dashboard's localized resource (ConsoleLogs.resx → - // TerminalToolbarGridSizeAuto). The English string here is only a - // fallback for the rare case where someone consumes the SIZE_PRESETS - // list directly from JS without going through GetSizePresetsAsync — - // we never bind it to the UI as-is. - { value: "auto", label: "Auto", cols: 0, rows: 0 }, - { value: "80x24", label: "80×24", cols: 80, rows: 24 }, - { value: "80x30", label: "80×30", cols: 80, rows: 30 }, + // The host replaces Auto with the localized TerminalToolbarGridSizeAuto. + { value: "auto", label: "Auto", cols: 0, rows: 0 }, + { value: "80x24", label: "80×24", cols: 80, rows: 24 }, + { value: "80x30", label: "80×30", cols: 80, rows: 30 }, { value: "100x30", label: "100×30", cols: 100, rows: 30 }, { value: "132x30", label: "132×30", cols: 132, rows: 30 }, { value: "132x50", label: "132×50", cols: 132, rows: 50 }, ]; -// Inject the WebMuxerDemo terminal-frame styles into exactly once -// per page load. Lifted near-verbatim from samples/WebMuxerDemo/wwwroot/ -// css/styles.css with the page-level (header/aside/body) selectors -// dropped — only the .terminal-pane / #terminal-frame / titlebar / body -// / footer / scrollbar rules remain. Selectors are scoped to -// .aspire-terminal-host (the root we add to the Blazor element) so they -// can never bleed into the rest of the dashboard. IDs are kept as the -// WebMuxer source uses them since we instantiate at most one chrome per -// host element. -function ensureTerminalStyles() { - if (document.getElementById('aspire-terminal-styles')) return; - const css = ` -/* - * Bundled Nerd Font for the terminal view. Cascadia Mono NF is - * Microsoft's official patched build of Cascadia Mono (no ligatures — - * preferred for terminal output) with the Nerd Font glyph set, so - * Powerline separators, devicons, weather icons, k9s/lazygit/htop - * glyphs, etc. all render correctly instead of as tofu boxes. The - * font ships as a single variable woff2 (~950 KB) covering all - * weights. License: SIL OFL 1.1 — see - * wwwroot/fonts/cascadia-mono-nf/LICENSE.txt. - * - * font-display: swap so the terminal renders immediately with the - * fallback monospace stack and silently upgrades to Cascadia once - * the woff2 lands. xterm.js measures cell width from - * .xterm-char-measure-element which is re-measured on every theme/ - * options change; if we ever need to force a re-measure after the - * font swap we can listen for document.fonts.ready, but in practice - * the first measurement happens after the font has loaded for - * already-cached fetches and the visual glitch on cold load is a - * one-frame reflow. - */ -@font-face { - font-family: 'Cascadia Mono NF'; - src: url('/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2') format('woff2-variations'), - url('/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2') format('woff2'); - font-weight: 200 700; - font-style: normal; - font-display: swap; -} - -.aspire-terminal-host { - /* - * --aspire-term-bg is the chrome around the framed terminal (the - * "stage"). Track the dashboard theme via FluentUI's neutral layer - * token so dark/light theme switches keep the surround in step with - * the rest of the page. The actual xterm canvas inside #terminal-body - * stays dark on purpose — terminals are conventionally dark and the - * frame is its own card. - */ - --aspire-term-bg: var(--neutral-layer-2); - --aspire-term-fg: #c9d1d9; - --aspire-term-fg-muted: #8b949e; - --aspire-term-accent: #58a6ff; - --aspire-term-accent-2: #56d364; - --aspire-term-warn: #f0883e; - --aspire-term-panel: #161b22; - --aspire-term-border: #30363d; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - background: var(--aspire-term-bg); - color: var(--aspire-term-fg); - font: 14px system-ui, -apple-system, "Segoe UI", sans-serif; - overflow: hidden; - box-sizing: border-box; +function isCurrent(state, generation) { + return !state.disposed && state.generation === generation; } -.aspire-terminal-host * { box-sizing: border-box; } -.aspire-terminal-host .terminal-pane { - flex: 1; - /* - * min-width: 0 overrides the flex default of min-width: auto. Without - * it, the flex item refuses to shrink below the intrinsic width of - * its contents — including #terminal-body's pinned inline width — so - * horizontal window resize can't shrink the pane and applyRoleAwareLayout - * never sees the narrower viewport. - */ - min-width: 0; - /* - * Stage for the terminal — themed backdrop with a small breathing margin - * around the .xterm frame. No drop-shadow on the frame, so we don't need - * extra padding to give shadow blur space to extend. Top padding is 0 so the - * framed terminal sits flush with the top of the pane, matching the console - * logs view (which has no padding above its content). - */ - padding: 0 8px 8px; - overflow: hidden; - display: flex; - background: var(--neutral-layer-2); +function isVisible(state) { + return state.element.clientWidth > 0 && state.element.clientHeight > 0; } -.aspire-terminal-host #terminal { - /* - * Bare host for xterm.js. Fills the inner stage area, centres its - * single .xterm child horizontally, and pins it to the top so the - * terminal prompt starts at the natural reading position rather than - * floating in the middle of the available space. Secondary peers - * (which lock the grid to producer dims and apply a CSS scale - * transform) still get horizontal letterboxing when narrower than - * the stage. - */ - flex: 1; - min-width: 0; - min-height: 0; - display: flex; - align-items: flex-start; - justify-content: center; -} - -/* - * Terminal "card" — non-transformed wrapper around the xterm so the - * border stays at fixed CSS pixel sizes regardless of any CSS scale - * transform applied to the .xterm in secondary mode. - */ -.aspire-terminal-host #terminal-frame { - display: flex; - flex-direction: column; - flex-shrink: 0; - background: #0d1117; - border: 2px solid #3a4250; - border-radius: 6px; - overflow: hidden; -} - -.aspire-terminal-host #terminal-titlebar { - flex: 0 0 auto; - min-width: 0; - height: 30px; - padding: 0 14px; - background: linear-gradient(180deg, #1a2029 0%, #161b22 100%); - border-bottom: 1px solid #30363d; - color: var(--aspire-term-fg-muted); - font: 12px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; - display: flex; - align-items: center; - user-select: none; -} - -.aspire-terminal-host #terminal-title { - min-width: 0; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: 0.2px; -} - -/* - * Live cols × rows readout on the right side of the titlebar. Kept in - * sync from term.onResize so it always shows the grid the PTY sees. - */ -.aspire-terminal-host #terminal-dims { - flex: 0 0 auto; - margin-left: 12px; - padding-left: 12px; - border-left: 1px solid #30363d; - color: var(--aspire-term-fg-muted); - font-variant-numeric: tabular-nums; - letter-spacing: 0.2px; - white-space: nowrap; -} - -.aspire-terminal-host #terminal-body { - flex: 0 0 auto; - position: relative; - overflow: hidden; - background: #0d1117; - /* - * Breathing room between the frame border and xterm's text so the - * output isn't flush against the edge (matches native terminal UX). - * Combined with box-sizing: border-box (inherited from the wildcard - * rule above), the padding shrinks the content area xterm renders - * into — the JS layout math in layoutTerminal / pinBodyToNatural adds - * TERMINAL_BODY_PADDING_PX * 2 back when pinning the body to the - * natural rendered dims so the frame keeps hugging the grid. - */ - padding: 6px; -} - -.aspire-terminal-host .xterm:focus, -.aspire-terminal-host .xterm:focus-visible { - outline: none; -} - -/* - * xterm.js scrollbar: overlay-style, only visible on hover. - */ -.aspire-terminal-host .xterm-viewport { - scrollbar-width: none; - -ms-overflow-style: none; -} -.aspire-terminal-host .xterm-viewport::-webkit-scrollbar { - width: 0; - background: transparent; -} -.aspire-terminal-host #terminal-frame:hover .xterm-viewport, -.aspire-terminal-host .xterm:hover .xterm-viewport, -.aspire-terminal-host .xterm-viewport:hover, -.aspire-terminal-host .xterm-viewport:focus-within { - scrollbar-width: thin; - scrollbar-color: rgba(139, 148, 158, 0.55) transparent; -} -.aspire-terminal-host #terminal-frame:hover .xterm-viewport::-webkit-scrollbar, -.aspire-terminal-host .xterm:hover .xterm-viewport::-webkit-scrollbar, -.aspire-terminal-host .xterm-viewport:hover::-webkit-scrollbar, -.aspire-terminal-host .xterm-viewport:focus-within::-webkit-scrollbar { - width: 10px; -} -.aspire-terminal-host #terminal-frame:hover .xterm-viewport::-webkit-scrollbar-thumb, -.aspire-terminal-host .xterm:hover .xterm-viewport::-webkit-scrollbar-thumb, -.aspire-terminal-host .xterm-viewport:hover::-webkit-scrollbar-thumb, -.aspire-terminal-host .xterm-viewport:focus-within::-webkit-scrollbar-thumb { - background: rgba(139, 148, 158, 0.55); - border-radius: 5px; - border: 2px solid transparent; - background-clip: padding-box; -} -`; - const style = document.createElement('style'); - style.id = 'aspire-terminal-styles'; - style.textContent = css; - document.head.appendChild(style); -} - -// Builds the terminal chrome inside the Blazor host element: -// -// .aspire-terminal-host (root with theme vars + flex column) -// .terminal-pane (the gradient stage; flex 1) -// #terminal (xterm centring host) -// #terminal-frame (the bordered/shadowed card) -// #terminal-titlebar (title text from OSC 0/2) -// #terminal-body (xterm host; sized by layout) -// -// The status badge, "Take control" button, font controls, size dropdown -// and live dims readout that used to sit inside the chrome have been -// hoisted into the page's toolbar — see ConsoleLogs.razor for the host. -// State snapshots flow up to .NET via `state.dotNetRef` (registered at -// init time) and commands flow back in via the exported wrappers -// `takePrimary`, `setFontSize`, `setSizeModeAuto`, `setSizeModeFixed`. -// -// All lookup roots are scoped to state.host so the layout helpers can -// run in pages that might (in the future) host multiple terminals. -function buildChrome(state) { - ensureTerminalStyles(); - - const blazorElement = state.element; - if (!blazorElement) return; - - // Defense in depth: never leave a previous terminal's chrome attached to - // the Blazor container element. The .NET-side OnAfterRenderAsync guard - // is the primary protection against re-entrant initialization, but if - // anything ever calls initTerminal twice against the same element - // (resource stop+restart bursts, lifecycle bugs, future hot-reload, …) - // appending another host on top of an existing one leaves multiple - // stacked xterm instances all wired to the same WebSocket — input - // echoes everywhere and the terminals can render at different sizes. - // Clearing the element first means worst-case we drop the previous - // (now-orphaned) chrome instead of duplicating it. - while (blazorElement.firstChild) { - blazorElement.removeChild(blazorElement.firstChild); +function notifyToolbar(state) { + if (state.disposed || state.toolbarFrame !== null) { + return; } - - // The Blazor element already has inline width/height: 100%. Wrap - // it with our own host so we can apply our flex column layout - // without disturbing whatever else the parent has set on it. - const host = document.createElement('div'); - host.className = 'aspire-terminal-host'; - blazorElement.appendChild(host); - - // Terminal stage. - const pane = document.createElement('div'); - pane.className = 'terminal-pane'; - const terminalContainer = document.createElement('div'); - terminalContainer.id = 'terminal'; - pane.appendChild(terminalContainer); - - // Card. - const frame = document.createElement('div'); - frame.id = 'terminal-frame'; - - const titlebar = document.createElement('div'); - titlebar.id = 'terminal-titlebar'; - const titleText = document.createElement('span'); - titleText.id = 'terminal-title'; - titleText.textContent = 'terminal'; - const dimsText = document.createElement('span'); - dimsText.id = 'terminal-dims'; - dimsText.textContent = ''; - titlebar.append(titleText, dimsText); - - const body = document.createElement('div'); - body.id = 'terminal-body'; - - frame.append(titlebar, body); - terminalContainer.appendChild(frame); - host.append(pane); - - state.host = host; - state.terminalContainer = terminalContainer; - state.terminalFrame = frame; - state.terminalTitlebar = titlebar; - state.titleText = titleText; - state.dimsText = dimsText; - state.terminalBody = body; + // Geometry and role notifications can arrive together on every frame. + // Coalesce them before crossing the Blazor interop boundary. + state.toolbarFrame = requestAnimationFrame(() => { + state.toolbarFrame = null; + flushToolbar(state); + }); } -function safeFit(state) { - const term = state.term; - const before = term ? { cols: term.cols, rows: term.rows, fontSize: term.options?.fontSize } : null; - try { state.fitAddon?.fit(); } catch { /* ignore — happens during teardown */ } - if (window.__aspireTerminalDebug) { - const after = term ? { cols: term.cols, rows: term.rows, fontSize: term.options?.fontSize } : null; - console.log('[TERMDIAG] safeFit', { - before, after, - currentFontPx: state.currentFontPx, - fitFontPx: state.fitFontPx, - sizeMode: state.sizeMode, - avail: getAvailableBodySpace(state), - isPrimary: !!state.client?.isPrimary, - producerDims: state.client ? { w: state.client.width, h: state.client.height } : null, - }); +function flushToolbar(state) { + if (state.disposed || !state.dotNetRef) { + return; } + const snapshot = getToolbarState(state.id); + const json = JSON.stringify(snapshot); + if (json === state.lastToolbarJson) { + return; + } + state.lastToolbarJson = json; + // The circuit may disappear while a worker notification is in flight. + // Handle asynchronous rejection too, not just synchronous interop errors. + Promise.resolve().then(() => + state.dotNetRef?.invokeMethodAsync("OnTerminalStateChanged", snapshot) + ).catch(() => { + if (!state.disposed) { + state.lastToolbarJson = null; + } + }); } -function updateDimsReadout(state) { - if (!state.dimsText || !state.term) return; - const cols = state.term.cols | 0; - const rows = state.term.rows | 0; - // xterm briefly reports 0x0 during teardown; suppress that instead of - // flashing a zero-sized readout at the user. - state.dimsText.textContent = cols > 0 && rows > 0 ? `${cols} × ${rows}` : ''; +function cancelReconnect(state) { + if (state.reconnectTimer !== null) { + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } } -const FRAME_BORDER_PX = 2; -// CSS `padding` on #terminal-body — kept in sync with the value in the -// injected stylesheet. box-sizing is border-box, so the content area -// xterm actually renders into is smaller than the outer body box by -// TERMINAL_BODY_PADDING_PX * 2 on each axis. getAvailableBodySpace -// returns the xterm-content area (padding subtracted) so callers can -// pass it straight to computeOptimalFont / fit(); fit-mode's body-pin -// and pinBodyToNatural add the padding back when they set the outer -// body dimensions. -const TERMINAL_BODY_PADDING_PX = 6; -function getAvailableBodySpace(state) { - const titlebarH = state.terminalTitlebar ? state.terminalTitlebar.offsetHeight : 0; - const stageW = state.terminalContainer ? state.terminalContainer.clientWidth : 0; - const stageH = state.terminalContainer ? state.terminalContainer.clientHeight : 0; - const outerW = Math.max(0, stageW - FRAME_BORDER_PX * 2); - const outerH = Math.max(0, stageH - titlebarH - FRAME_BORDER_PX * 2); - return { - width: Math.max(0, outerW - TERMINAL_BODY_PADDING_PX * 2), - height: Math.max(0, outerH - TERMINAL_BODY_PADDING_PX * 2), - }; +function releaseClient(state) { + state.restoreFocus ||= !!state.client?.element.contains(document.activeElement); + const controller = state.controller; + const client = state.client; + state.controller = null; + state.client = null; + controller?.abort(); + client?.dispose(); } -// Sizes the xterm display based on the current role and (in primary -// mode) the current sizing mode. See docs/muxer-learnings.md §3. -// -// - Secondary: lock the xterm grid to producer's cols×rows and pick -// the largest integer font whose rendered grid fits the available -// stage. Pin #terminal-body to the natural rendered dims so the -// frame card hugs the grid (letterboxing appears in the stage on -// whichever axis has spare room). This is structurally the same as -// primary fixed-mode with fixedDims == producer dims, minus the -// resize broadcast — see the header comment for why we don't use -// CSS transform: scale() here. -// -// - Primary, font-driven: pin #terminal-body to available stage, run -// fitAddon.fit() — grid grows/shrinks to fill at the user's chosen -// font size. term.onResize → client.sendResize broadcasts to producer. -// -// - Primary, fixed: cols×rows locked to user's preset; compute the -// largest font that lets that grid fit, set fontSize, term.resize -// back to the chosen dims, pin #terminal-body to the natural rendered -// dims so the frame card hugs the chosen grid (grey gradient stage -// shows around it as letterboxing). -function applyRoleAwareLayout(state) { - const term = state.term; - const fitAddon = state.fitAddon; - if (!term || !fitAddon) return; - - const root = term.element; - if (!root) return; - const body = root.parentElement; - if (!body) return; - - // Bail when the terminal container has been laid out to zero — most - // commonly because ConsoleLogs flipped this view to display:none while - // Console is active. Running the layout at zero would pin body.style - // width/height to 0px (fixed mode) or resize the xterm grid to 1x1 - // (fit mode), and neither necessarily gets reversed when the browser - // relayouts the container back to a real size. ConsoleLogs re-invokes - // refreshLayout on the way back to Terminal view, so we recover with - // a real size then. - const { width: probeW, height: probeH } = getAvailableBodySpace(state); - if (probeW <= 0 || probeH <= 0) return; - - // Bump generation: any RAF callbacks queued by prior layout calls - // become stale and will bail when they run. - const generation = ++state.layoutGeneration; - - const haveProducerDims = !!state.client && state.client.width > 0 && state.client.height > 0; - const isSecondary = !!state.client && !state.client.isPrimary && haveProducerDims; - const availableW = probeW; - const availableH = probeH; - - if (!isSecondary) { - // Primary, no-primary, or pre-handshake: clear any leftover - // .xterm inline styling so it flows naturally inside body. - if (root.style.transform || root.style.width || root.style.height) { - root.style.transform = ''; - root.style.transformOrigin = ''; - root.style.width = ''; - root.style.height = ''; - } - - if (state.sizeMode === 'fixed' && state.fixedDims) { - const optFont = computeOptimalFont(state, state.fixedDims.cols, state.fixedDims.rows, availableW, availableH); - if (term.options.fontSize !== optFont) { - term.options.fontSize = optFont; - forceFontRemeasure(term); - } - state.currentFontPx = optFont; - if (term.cols !== state.fixedDims.cols || term.rows !== state.fixedDims.rows) { - try { term.resize(state.fixedDims.cols, state.fixedDims.rows); } catch { /* ignore */ } - } - const expectedCols = state.fixedDims.cols; - const expectedRows = state.fixedDims.rows; - requestAnimationFrame(() => { - if (generation !== state.layoutGeneration) return; - if (state.sizeMode !== 'fixed' || !state.fixedDims) return; - if (state.fixedDims.cols !== expectedCols || state.fixedDims.rows !== expectedRows) return; - pinBodyToNatural(state, root, body); - refineFontAfterCalibration(state, generation, expectedCols, expectedRows, - () => state.sizeMode === 'fixed' && state.fixedDims && - state.fixedDims.cols === expectedCols && state.fixedDims.rows === expectedRows); - }); - } else { - // Font-driven: pin body to fill the pane (content + padding on - // each side, since body is border-box); fit() picks cols×rows - // for the padded content area. - const bodyW = `${availableW + TERMINAL_BODY_PADDING_PX * 2}px`; - const bodyH = `${availableH + TERMINAL_BODY_PADDING_PX * 2}px`; - if (body.style.width !== bodyW || body.style.height !== bodyH) { - body.style.width = bodyW; - body.style.height = bodyH; - } - if (term.options.fontSize !== state.currentFontPx) { - term.options.fontSize = state.currentFontPx; - forceFontRemeasure(term); - } - safeFit(state); - } - notifyToolbar(state); +function scheduleReconnect(state, generation) { + if (!isCurrent(state, generation) || state.reconnectTimer !== null) { return; } - - // Secondary: lock grid to producer dims, pick the largest integer - // font whose rendered grid fits, then hug the frame to the natural - // rendered size. No CSS transform — see the header comment for why. - // This is intentionally the same shape as primary fixed-mode above, - // minus the resize broadcast (secondary never drives the PTY). - const producerCols = state.client.width; - const producerRows = state.client.height; - const optFont = computeOptimalFont(state, producerCols, producerRows, availableW, availableH); - if (term.options.fontSize !== optFont) { - term.options.fontSize = optFont; - forceFontRemeasure(term); - } - state.currentFontPx = optFont; - if (term.cols !== producerCols || term.rows !== producerRows) { - try { term.resize(producerCols, producerRows); } catch { /* ignore */ } - } - requestAnimationFrame(() => { - if (generation !== state.layoutGeneration) return; - // Bail if role/producer dims changed while we were queued. - if (!state.client || state.client.isPrimary) return; - if (state.client.width !== producerCols || state.client.height !== producerRows) return; - pinBodyToNatural(state, root, body); - refineFontAfterCalibration(state, generation, producerCols, producerRows, - () => !!state.client && !state.client.isPrimary && - state.client.width === producerCols && state.client.height === producerRows); - }); - notifyToolbar(state); -} - -// On the very first calibrated render, computeOptimalFont bails out with -// state.currentFontPx (the default 13px) because cellWRatio/cellHRatio -// are still zero — those get seeded by calibrateRatios inside -// pinBodyToNatural, which runs one RAF *after* the initial layout pass. -// Result: the terminal opens at default font and only snaps to the -// right size when a ResizeObserver tick (window resize, sidebar collapse) -// re-drives layout. -// -// Once pinBodyToNatural has run, re-measure and recompute. If the -// optimal font moved (typical on first open), adjust fontSize in place -// and re-pin. We don't call applyRoleAwareLayout recursively because -// that would bump generation and could stack under fast triggers; a -// direct in-place adjustment converges in a single extra frame because -// xterm's cell metrics per font-px are stable across small font deltas. -function refineFontAfterCalibration(state, generation, cols, rows, stillApplicable) { - const term = state.term; - if (!term || !term.element) return; - const root = term.element; - const body = root.parentElement; - if (!body) return; - const fresh = getAvailableBodySpace(state); - if (fresh.width <= 0 || fresh.height <= 0) return; - const refined = computeOptimalFont(state, cols, rows, fresh.width, fresh.height); - if (refined === term.options.fontSize) return; - term.options.fontSize = refined; - forceFontRemeasure(term); - state.currentFontPx = refined; - requestAnimationFrame(() => { - if (generation !== state.layoutGeneration) return; - if (!stillApplicable()) return; - pinBodyToNatural(state, root, body); + if (state.attempts >= MAX_RECONNECT_ATTEMPTS) { + state.error = "disconnected"; notifyToolbar(state); - }); -} - -function pinBodyToNatural(state, root, body) { - if (!root || !body) return; - const screenEl = - root.querySelector('.xterm-screen') || - root.querySelector('canvas.xterm-text-layer') || - root; - const w = screenEl.offsetWidth; - const h = screenEl.offsetHeight; - if (w > 0 && h > 0) { - // body is border-box with padding, so pin the outer size to - // (screen dims + padding on each side) — the content area then - // matches the xterm-screen dims exactly. - const bodyW = `${w + TERMINAL_BODY_PADDING_PX * 2}px`; - const bodyH = `${h + TERMINAL_BODY_PADDING_PX * 2}px`; - if (body.style.width !== bodyW || body.style.height !== bodyH) { - body.style.width = bodyW; - body.style.height = bodyH; - } + return; } - calibrateRatios(state); -} - -// Stores cell width/height per CSS px of font size, derived from the -// currently rendered .xterm-screen. Refreshed after every render so -// fixed-mode font calculations stay accurate as xterm rounds cell -// sizes to integer pixels per font px. -function calibrateRatios(state) { - const term = state.term; - if (!term || !term.element) return; - const screenEl = term.element.querySelector('.xterm-screen'); - if (!screenEl) return; - const w = screenEl.offsetWidth; - const h = screenEl.offsetHeight; - const fs = term.options.fontSize || state.currentFontPx; - if (w > 0 && h > 0 && term.cols > 0 && term.rows > 0 && fs > 0) { - const newW = (w / term.cols) / fs; - const newH = (h / term.rows) / fs; - // Guard against transient stale readings. When fontSize was just - // changed (e.g. fit→fixed switch that jumped 13→26), xterm's DOM - // may not have re-rendered yet, so .xterm-screen still reflects - // the *old* fontSize's cell metrics. Dividing that stale pixel - // width by the new fontSize yields a ratio ~half of the true - // value. That corrupt ratio then feeds computeOptimalFont, which - // picks a wildly wrong font for the target grid. See the - // term.onResize handler in initTerminal for the matching - // RAF-deferred calibration guard. - // - // Heuristic: once we have a plausible baseline, reject any new - // sample that swings by more than 40% in either direction. Real - // xterm cell metrics per fontSize are stable across small font - // deltas (that's the whole reason we cache a ratio) so a 40% - // jump is diagnostic of a stale-render sample, not a real change. - const CALIBRATION_JUMP_TOLERANCE = 0.4; - const withinTolerance = (oldV, newV) => { - if (oldV <= 0) return true; - const delta = Math.abs(newV - oldV) / oldV; - return delta <= CALIBRATION_JUMP_TOLERANCE; - }; - if (withinTolerance(state.cellWRatio, newW) && withinTolerance(state.cellHRatio, newH)) { - state.cellWRatio = newW; - state.cellHRatio = newH; + const delay = RECONNECT_BACKOFF_MS[Math.min(state.attempts++, RECONNECT_BACKOFF_MS.length - 1)]; + state.reconnectTimer = setTimeout(() => { + state.reconnectTimer = null; + if (isCurrent(state, generation)) { + connectClient(state); } - } -} - -function computeOptimalFont(state, cols, rows, availW, availH) { - if (state.cellWRatio <= 0 || state.cellHRatio <= 0) return state.currentFontPx; - if (cols <= 0 || rows <= 0 || availW <= 0 || availH <= 0) return state.currentFontPx; - const fsW = availW / (cols * state.cellWRatio); - const fsH = availH / (rows * state.cellHRatio); - const fs = Math.floor(Math.min(fsW, fsH)); - return Math.max(MIN_FONT_PX, Math.min(MAX_FONT_PX, fs)); -} - -// xterm 5.5.0 only reliably re-measures cell metrics on fontFamily -// *change* — setting term.options.fontSize alone can leave stale cell -// dimensions in the renderer, so a subsequent fitAddon.fit() divides -// the available space by the old cell size and picks the wrong grid. -// Bouncing fontFamily forces the renderer to re-measure with the -// current fontSize. See the document.fonts.ready handler in -// initTerminal for the same trick applied to late font loads. -function forceFontRemeasure(term) { - if (!term) return; - try { - const family = term.options.fontFamily; - term.options.fontFamily = 'monospace'; - term.options.fontFamily = family; - } catch { /* ignore — term may be disposed */ } + }, delay); } -function setFontSize(state, newSize) { - newSize = Math.max(MIN_FONT_PX, Math.min(MAX_FONT_PX, newSize)); - if (newSize === state.currentFontPx && state.sizeMode === 'font') return; - state.currentFontPx = newSize; - // Preserve the caller's requested size as the "Fit-mode font" so the - // toolbar can show what Fit would produce even after a later fixed - // preset overwrites currentFontPx with an auto-calculated size. - state.fitFontPx = newSize; - state.sizeMode = 'font'; - state.fixedDims = null; - if (state.term) { - state.term.options.fontSize = state.currentFontPx; - forceFontRemeasure(state.term); +function connectionFailed(state, generation, error) { + if (!isCurrent(state, generation)) { + return; } - applyRoleAwareLayout(state); -} + console.warn("Dashboard terminal connection failed.", error); + state.error = "mount-failed"; + state.connected = false; + state.peer = { id: null, primaryId: null, isPrimary: false }; + state.pendingSizing = null; + releaseClient(state); + notifyToolbar(state); + scheduleReconnect(state, generation); +} + +function connectClient(state) { + cancelReconnect(state); + const generation = ++state.generation; + releaseClient(state); + state.peer = { id: null, primaryId: null, isPrimary: false }; + state.geometry = null; + state.connected = false; + state.pendingSizing = null; + state.waitingForVisibility = false; + notifyToolbar(state); -function setSizeMode(state, mode, dims) { - if (window.__aspireTerminalDebug) { - console.log('[TERMDIAG] setSizeMode', { - requested: { mode, dims }, - currentSizeMode: state.sizeMode, - currentFontPx: state.currentFontPx, - fitFontPx: state.fitFontPx, - termFontSize: state.term?.options?.fontSize, - termCols: state.term?.cols, - termRows: state.term?.rows, - cellWRatio: state.cellWRatio, - cellHRatio: state.cellHRatio, - isPrimary: !!state.client?.isPrimary, - producer: state.client ? { w: state.client.width, h: state.client.height } : null, - }); - } - if (mode === state.sizeMode && - ((mode === 'font') || - (mode === 'fixed' && dims && state.fixedDims && - dims.cols === state.fixedDims.cols && dims.rows === state.fixedDims.rows))) { - if (window.__aspireTerminalDebug) { - console.log('[TERMDIAG] setSizeMode early-return'); - } + // Do not spend the client's 30-second first-frame timeout while the + // Console view has this component hidden. The same mounted view is kept + // alive on subsequent hide/show transitions so its history is preserved. + if (!isVisible(state)) { + state.waitingForVisibility = true; return; } - state.sizeMode = mode; - state.fixedDims = mode === 'fixed' ? dims : null; - if (mode === 'font') { - state.currentFontPx = state.fitFontPx; - } - applyRoleAwareLayout(state); - if (window.__aspireTerminalDebug) { - console.log('[TERMDIAG] setSizeMode after layout', { - currentFontPx: state.currentFontPx, - termFontSize: state.term?.options?.fontSize, - termCols: state.term?.cols, - termRows: state.term?.rows, - }); + if (!window.isSecureContext || !navigator.gpu) { + state.error = "unsupported"; + notifyToolbar(state); + return; } -} -// Computes the current toolbar state snapshot and (when changed) pushes -// it up to the Blazor host so the page-level toolbar can render the -// status badge, "Take control" button, font controls, size dropdown and -// dims readout. RAF-coalesced because callers include term.onResize, -// applyRoleAwareLayout's RAF callbacks and ResizeObserver — they can -// fire in rapid bursts during window/sidebar resize. Change-detected -// so a no-op call (e.g. layout pass that produced identical dims) does -// not round-trip to .NET. -function notifyToolbar(state) { - if (state._toolbarFlushPending) return; - state._toolbarFlushPending = true; - requestAnimationFrame(() => { - state._toolbarFlushPending = false; - flushToolbarState(state); - }); + const controller = new AbortController(); + state.controller = controller; + // Return the terminal id before awaiting mount: Blazor must be able to + // cancel a pending first frame when the resource changes or is disposed. + void mountClient(state, generation, controller); } -function flushToolbarState(state) { - if (!state.dotNetRef) return; - - const snapshot = buildToolbarSnapshot(state); - - // Skip the .NET round trip if nothing meaningful changed. Cheap - // shallow stringify is fine — snapshot is small and flat. - const serialized = JSON.stringify(snapshot); - if (serialized === state._lastToolbarJson) return; - state._lastToolbarJson = serialized; - +async function mountClient(state, generation, controller) { + const current = () => isCurrent(state, generation) && !controller.signal.aborted; try { - state.dotNetRef.invokeMethodAsync('OnTerminalStateChanged', snapshot); - } catch (e) { - dbg(state, 'notifyToolbar: invoke failed', { error: e?.message }); + const client = await WebTerminal.mount(state.element, { + url: state.wsUrl, + signal: controller.signal, + label: state.label, + sizing: state.sizing, + onStatus(message, level) { + if (!current() || level !== "error") { + return; + } + // In this paired client, worker/transport errors disconnect + // before onStatus. Selection-UI errors can also report "error" + // without disconnecting; do not discard history for those. + if (state.client?.connected) { + state.error = "input-failed"; + notifyToolbar(state); + } else { + connectionFailed(state, generation, message); + } + }, + onGeometry(geometry) { + if (current()) { + state.geometry = geometry; + notifyToolbar(state); + } + }, + onRoleChange(peer) { + if (current()) { + state.peer = peer; + // requestPrimary is only a request. Do not apply sizing + // or advertise ownership until the producer confirms it. + applyPendingSizing(state); + notifyToolbar(state); + } + }, + onSizingChange(sizing) { + if (current()) { + state.sizing = sizing; + notifyToolbar(state); + } + }, + onInputError(error) { + if (current()) { + console.warn("Dashboard terminal input failed.", error); + state.error = "input-failed"; + notifyToolbar(state); + } + }, + }); + if (!current()) { + client.dispose(); + return; + } + state.client = client; + state.connected = client.connected; + state.peer = client.peer; + state.geometry = client.geometry; + state.sizing = client.sizing; + state.error = null; + state.attempts = 0; + if (state.restoreFocus && isVisible(state) && + (!document.activeElement || document.activeElement === document.body || state.element.contains(document.activeElement))) { + client.focus(); + } + state.restoreFocus = false; + applyPendingSizing(state); + notifyToolbar(state); + } catch (error) { + if (current()) { + connectionFailed(state, generation, error); + } } } -function buildToolbarSnapshot(state) { - const client = state.client; - const term = state.term; - - let status; - let canTakeControl = false; - let isPrimary = false; - - if (!client || client.peerId === null) { - status = 'connecting'; - } else if (client.isPrimary) { - status = 'primary'; - isPrimary = true; - } else if (client.primaryPeerId === null) { - status = 'no-primary'; - canTakeControl = true; - } else { - status = 'viewer'; - canTakeControl = true; +function applyPendingSizing(state) { + if (!state.client?.connected || !state.peer.isPrimary || !state.pendingSizing) { + return; } - - const sizeKey = state.sizeMode === 'fixed' && state.fixedDims - ? `${state.fixedDims.cols}x${state.fixedDims.rows}` - : 'auto'; - - return { - terminalId: state.id, - // Generation lets the .NET side discard stale snapshots that arrive - // after the JS terminal was disposed / replaced by another resource. - generation: state.reconnect.generation, - status, - connected: !!client && client.peerId !== null, - isPrimary, - canTakeControl, - sizeMode: state.sizeMode, - sizeKey, - fontPx: state.currentFontPx, - // Font/size controls are enabled whenever this tab is primary or - // could become primary on demand. If we're not primary yet, the - // setFontSizeFromHost / setSizeModeFromHost entry points will - // auto-promote before applying the change so the user doesn't have - // to click "Take control" first — this is especially important - // after a WS reconnect, which silently drops primary status. - // Connecting state still gates these off via canTakeControl=false. - fontControlsEnabled: (isPrimary && state.sizeMode === 'font') || canTakeControl, - sizeSelectEnabled: isPrimary || canTakeControl, - cols: term && term.cols ? term.cols : 0, - rows: term && term.rows ? term.rows : 0, - }; + const sizing = state.pendingSizing; + state.pendingSizing = null; + try { + state.client.setSizing(sizing); + state.error = null; + } catch (error) { + console.warn("Dashboard terminal sizing failed.", error); + state.error = "sizing-failed"; + } + notifyToolbar(state); } -// "Take control" handler. RequestPrimary at our current grid dims so -// the producer resizes the PTY to match what we just laid out. -function takePrimary(state) { - const client = state.client; - const term = state.term; - if (!client || !term || !state.fitAddon) return; - - if (term.element) { - term.element.style.transform = ''; - term.element.style.transformOrigin = ''; - term.element.style.width = ''; - term.element.style.height = ''; - const body = term.element.parentElement; - if (body) { - body.style.width = ''; - body.style.height = ''; - } +function changeSizing(state, sizing) { + if (!state.client?.connected || !isVisible(state)) { + return; } - applyRoleAwareLayout(state); - dbg(state, 'takePrimary', { cols: term.cols, rows: term.rows }); - try { - client.requestPrimary(term.cols, term.rows); - } catch (e) { - dbg(state, 'takePrimary: failed', { error: e?.message }); + state.pendingSizing = sizing; + if (state.peer.isPrimary) { + applyPendingSizing(state); + } else { + // A toolbar sizing gesture explicitly asks for resize authority. + // Ordinary keyboard, mouse and paste input never claims primary. + requestPrimaryFromHost(state.id); } } -export async function initTerminal(element, wsUrl, dotNetRef) { - await ensureXtermLoaded(); - +export function initTerminal(element, wsUrl, dotNetRef, label) { const id = nextId++; const state = { - id, + id, element, wsUrl, dotNetRef, label, client: null, - term: null, - fitAddon: null, - element, - wsUrl, - // Blazor host (TerminalView) — the JS side pushes state snapshots - // into [JSInvokable] OnTerminalStateChanged so the page-level - // toolbar can render the status badge / take-control button / - // font ± / size dropdown / dims readout. May be null if the - // host opted not to receive notifications. - dotNetRef: dotNetRef || null, - utf8Decoder: new TextDecoder('utf-8', { fatal: false }), - reconnect: { - enabled: true, - attempts: 0, - timer: null, - generation: 0, - }, - // Layout / sizing state (per-instance — we never use globals). - sizeMode: 'font', - fixedDims: null, - currentFontPx: DEFAULT_FONT_PX, - // Font size that "Fit" mode uses, tracked separately from - // currentFontPx because fixed-preset layout overwrites the latter - // with the auto-calculated optimal font. Preserving the user's last - // font-mode font here lets setSizeMode('font') restore it when the - // user flips back to Fit. - fitFontPx: DEFAULT_FONT_PX, - cellWRatio: 0, - cellHRatio: 0, - layoutGeneration: 0, - // Toolbar push state. _toolbarFlushPending coalesces bursts via RAF; - // _lastToolbarJson lets us short-circuit no-op snapshots so we don't - // round-trip to .NET on every layout/resize tick. - _toolbarFlushPending: false, - _lastToolbarJson: null, - // DOM refs filled in by buildChrome. - host: null, - terminalContainer: null, - terminalFrame: null, - terminalTitlebar: null, - titleText: null, - dimsText: null, - terminalBody: null, + controller: null, + disposed: false, + connected: false, + peer: { id: null, primaryId: null, isPrimary: false }, + geometry: null, + sizing: { mode: "auto", fontSize: DEFAULT_FONT_SIZE }, + pendingSizing: null, + error: null, + generation: 0, + attempts: 0, + reconnectTimer: null, + toolbarFrame: null, + lastToolbarJson: null, + waitingForVisibility: false, + restoreFocus: false, }; - - // Build the chrome BEFORE creating the xterm — term.open(body) - // needs the body element to exist. - buildChrome(state); - - // Preload Cascadia Mono NF BEFORE constructing the Terminal. xterm - // measures cell metrics (width and height in CSS px) exactly once at - // construction time via its hidden .xterm-char-measure-element. Those - // metrics back not just rendering but also mouse → cell hit-testing - // (selection, click reporting). If the woff2 hasn't entered the - // FontFace cache by the time `new Terminal()` runs, xterm calibrates - // against the fallback (Menlo/Consolas) and the entire grid — visuals - // AND mouse mapping — stays anchored to those slightly-different - // metrics. Awaiting document.fonts.load with the actual font-size we - // are about to use forces the woff2 to be ready before construction. - // We still have the post-load bounce below as a defense in depth for - // the case where preload fails (offline, asset 404). - if (document.fonts && typeof document.fonts.load === 'function') { - try { - await document.fonts.load(`${state.currentFontPx}px "Cascadia Mono NF"`); - } catch { /* ignore — fallback stack continues to render */ } - } - - const FitAddon = window.FitAddon.FitAddon; - const fitAddon = new FitAddon(); - const term = new window.Terminal({ - cursorBlink: true, - fontSize: state.currentFontPx, - fontFamily: '"Cascadia Mono NF", "Cascadia Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace', - // HMP1 does not currently synchronize scrollback across consumer - // reconnects — the producer's StateSync only repaints the visible - // viewport. The reconnect path below calls term.reset() on every - // new HMP1 session so the StateSync repaints into a clean buffer - // with default modes; that also resets this scrollback. - scrollback: 10000, - theme: { - background: '#0d1117', - foreground: '#c9d1d9', - cursor: '#58a6ff', - selectionBackground: '#1f6feb55', - }, - allowProposedApi: true, - }); - - term.loadAddon(fitAddon); - term.open(state.terminalBody); - - state.term = term; - state.fitAddon = fitAddon; - - // Defense in depth: if Cascadia hadn't entered the FontFace cache - // by the time we constructed Terminal (preload above failed/timed - // out, or the browser deferred the load), force xterm to re-measure - // when the font finally lands. xterm only re-measures on fontFamily - // *change*, so bounce through 'monospace' and back. Then refit and - // recalibrate so cols/rows AND the mouse hit map agree with the - // new cell metrics — without the fit the renderer repaints with - // the new glyphs but pointer events still map to the old grid. - if (document.fonts && typeof document.fonts.ready?.then === 'function') { - document.fonts.ready - .then(() => { - if (state.term !== term) return; - try { - term.options.fontFamily = 'monospace'; - term.options.fontFamily = '"Cascadia Mono NF", "Cascadia Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace'; - try { fitAddon.fit(); } catch { /* container not laid out yet */ } - calibrateRatios(state); - applyRoleAwareLayout(state); - } catch { /* ignore — xterm disposed mid-flight */ } - }) - .catch(() => { /* font load failed; fallback stack continues to render */ }); - } - - // Defer the initial layout one frame so xterm has rendered the cell - // grid — calibrateRatios needs the rendered .xterm-screen. - requestAnimationFrame(() => { - calibrateRatios(state); - applyRoleAwareLayout(state); - updateDimsReadout(state); - }); - - // OSC 0 / OSC 2 / OSC 1 — terminal apps push window/icon titles via - // these escape sequences. xterm.js parses them and fires - // onTitleChange with the new string. - term.onTitleChange((newTitle) => { - if (state.titleText) { - state.titleText.textContent = newTitle || 'terminal'; + state.observer = new ResizeObserver(() => { + if (state.waitingForVisibility && !state.disposed && isVisible(state)) { + connectClient(state); } }); - - // term.onResize fires whenever fitAddon.fit() OR a manual term.resize() - // changes the xterm grid. Forward to the producer via sendResize, but - // Hmp1Client.sendResize() silently no-ops when we're not primary, so - // viewers' fit() calls don't disturb the producer. Push fresh dims to - // the toolbar and recalibrate ratios so future fixed-mode font calcs - // stay accurate. - // - // Recalibration is deferred one RAF because xterm dispatches onResize - // *before* it re-renders .xterm-screen; measuring offsetWidth here - // would divide the old rendered width by the new cols count and yield - // a cellWRatio ~half of the true value. That in turn made the toolbar's - // Fit preview report roughly double the real cols×rows. - term.onResize(({ cols, rows }) => { - if (state.client) state.client.sendResize(cols, rows); - updateDimsReadout(state); - requestAnimationFrame(() => { - if (state.term !== term) return; - calibrateRatios(state); - notifyToolbar(state); - }); - }); - - // User input auto-promotes to primary. Consolidating the toolbar - // into the ⋯ menu removed the explicit "Take control" button, so we - // rely on the same auto-promote path as font/size changes: if the - // viewer types (or pastes, or hits Enter), they take primary before - // the input goes out. Server drops non-primary input, so promoting - // first ensures the keystroke lands. No-ops when we're already - // primary or the client isn't connected yet. - term.onData((data) => { - if (!state.client) return; - maybeAutoPromote(state); - state.client.sendInput(textEncoder.encode(data)); - }); - - // Re-layout on container size change (window resize, sidebar collapse, - // dashboard layout changes, devtools opening, …). The role-aware - // layout function handles primary fit + secondary scale uniformly. - const resizeObserver = new ResizeObserver(() => applyRoleAwareLayout(state)); - resizeObserver.observe(state.terminalContainer); - - state._resizeObserver = resizeObserver; + state.observer.observe(element); terminals.set(id, state); - - // Connect HMP1 client. - connectClient(state, wsUrl); - - dbg(state, 'initTerminal: created', { wsUrl }); + connectClient(state); return id; } -function connectClient(state, wsUrl) { - // Cancel any pending reconnect timer and bump the generation so that - // late callbacks from any prior client no-op rather than racing with - // this new connection. - cancelPendingReconnect(state); - state.reconnect.generation++; - const myGeneration = state.reconnect.generation; - state.wsUrl = wsUrl; - - dbg(state, 'connectClient', { generation: myGeneration, attempts: state.reconnect.attempts, hadPriorClient: !!state.client }); - - // Tear down any in-flight client without firing its onClose (we don't - // want it to schedule its own reconnect on top of ours). Null the - // hooks first so an in-flight ws.onclose doesn't dispatch. - if (state.client) { - const stale = state.client; - stale.onOpen = null; - stale.onScreenBytes = null; - stale.onHello = null; - stale.onRoleChange = null; - stale.onPeerJoin = null; - stale.onPeerLeave = null; - stale.onResize = null; - stale.onExit = null; - stale.onClose = null; - try { stale.close(); } catch { /* ignore */ } - state.client = null; - } - - // Reset the UTF-8 decoder so any tail bytes from the previous stream - // don't bleed into the next one. - state.utf8Decoder = new TextDecoder('utf-8', { fatal: false }); - - // Hard-reset xterm (RIS) before the new HMP1 handshake. We MUST use - // term.reset() rather than term.clear(): clear() only wipes the - // visible buffer, leaving DEC private mode state intact (alternate - // screen ?1049, mouse tracking ?1000/?1002/?1003/?1006, focus events - // ?1004, bracketed paste ?2004, app cursor keys, scroll region, - // cursor shape, etc). If the prior connection had a TUI running and - // the WS was reset (e.g. a slow-consumer eviction under load), - // xterm.js would carry those modes into the next session — so when - // the producer's StateSync paints a fresh snapshot the viewer ends - // up wedged: cursor in alt-screen while the producer is on the - // primary buffer, mouse events swallowed even after the TUI exited, - // etc. reset() drops everything back to defaults so the StateSync - // suffix can authoritatively re-enable only the modes that are - // actually live on the producer. - try { state.term.reset(); } catch { /* ignore */ } - - // Update toolbar to "connecting…" while the new handshake completes. - notifyToolbar(state); - - const client = new Hmp1Client({ - url: wsUrl, - // Friendly-name shown in upstream's roster. Includes a short - // tab-id suffix so multiple browser tabs of the same resource are - // distinguishable in CLI viewers connected to the same upstream. - displayName: `aspire-dashboard-${state.id}`, - // Don't auto-snatch primary just by opening a tab; the user - // takes explicit action via the "Take control" button. - defaultRole: 'secondary', - }); - - client.onOpen = () => { - if (myGeneration !== state.reconnect.generation) { - dbg(state, 'client.onOpen: stale generation, ignoring', { my: myGeneration, current: state.reconnect.generation }); - return; - } - dbg(state, 'client.onOpen', { generation: myGeneration }); - // Connection is healthy. Reset the backoff so the next disconnect - // gets a snappy first retry rather than picking up where the prior - // attempt left off. - state.reconnect.attempts = 0; - }; - - client.onScreenBytes = (bytes) => { - if (myGeneration !== state.reconnect.generation) { - return; - } - // stream:true buffers partial multi-byte sequences across calls so - // a codepoint split across HMP1 Output frames still decodes - // correctly. - const text = state.utf8Decoder.decode(bytes, { stream: true }); - if (text.length > 0) { - state.term.write(text); - } - }; - - client.onHello = (payload) => { - if (myGeneration !== state.reconnect.generation) return; - dbg(state, 'client.onHello', payload); - notifyToolbar(state); - // Now that we know producer dims + role, apply layout (fits the - // role-aware path: secondary locks-and-scales to producer dims; - // primary fits/computes-font into the available stage). - applyRoleAwareLayout(state); - }; - - client.onRoleChange = (payload) => { - if (myGeneration !== state.reconnect.generation) return; - dbg(state, 'client.onRoleChange', payload); - notifyToolbar(state); - // Run layout FIRST so fixed-mode (if active) can resize the grid - // to fixedDims; the resulting term.onResize will sendResize the - // correct dims to the producer. Then send an explicit fallback - // in case nothing changed (e.g. font-driven mode where local - // dims already happen to match what we want broadcast). - applyRoleAwareLayout(state); - if (state.client && state.client.isPrimary && state.term) { - state.client.sendResize(state.term.cols, state.term.rows); - } - }; - - client.onPeerJoin = (payload) => { - if (myGeneration !== state.reconnect.generation) return; - dbg(state, 'client.onPeerJoin', payload); - }; - - client.onPeerLeave = (payload) => { - if (myGeneration !== state.reconnect.generation) return; - dbg(state, 'client.onPeerLeave', payload); - }; - - client.onResize = (cols, rows) => { - if (myGeneration !== state.reconnect.generation) return; - dbg(state, 'client.onResize', { cols, rows }); - // Producer's grid changed (only happens via primary's Resize). - // For secondaries this is the trigger to re-fit the frame to - // the new producer dims. - applyRoleAwareLayout(state); - }; - - client.onExit = (code) => { - if (myGeneration !== state.reconnect.generation) return; - dbg(state, 'client.onExit', { code }); - try { - state.term?.write(`\r\n[workload exited with code ${code}]\r\n`); - } catch { /* ignore */ } - }; - - client.onClose = (ev) => { - // Always log close events — this is the key forensic signal for - // periodic-reconnect investigations. code/reason/wasClean tell - // us who hung up and why (1000 = normal, 1006 = abnormal/no- - // close-frame, 1011 = server error, etc.). - const closeInfo = { - generation: myGeneration, - currentGeneration: state.reconnect.generation, - stale: myGeneration !== state.reconnect.generation, - code: ev?.code, - reason: ev?.reason, - wasClean: ev?.wasClean, - }; - dbg(state, 'client.onClose', closeInfo); - // Abnormal close (1006 = no close frame, !wasClean) is highly - // suggestive of a transport-level kill. Surface this at warn so - // it shows up in the default browser console without needing the - // aspire-terminal-debug flag. Normal close (1000) under stress - // means the proxy gracefully closed after upstream EOF — also - // worth a one-liner to correlate with server-side pump logs. - if (ev && (ev.code !== 1000 || !ev.wasClean)) { - try { - console.warn('[aspire-terminal] WS closed abnormally', closeInfo); - } catch { /* ignore */ } - } - if (myGeneration !== state.reconnect.generation) { - return; - } - if (!state.reconnect.enabled) { - return; - } - notifyToolbar(state); // back to "connecting" - scheduleReconnect(state); - }; - - state.client = client; - try { - client.connect(); - } catch (e) { - dbg(state, 'connectClient: connect threw', { error: e?.message }); - // Treat a synchronous connect failure (e.g. malformed URL) as a - // close — drive the reconnect loop just like a runtime drop. - if (state.reconnect.enabled && myGeneration === state.reconnect.generation) { - scheduleReconnect(state); - } - } - - return myGeneration; -} - export function reconnectTerminal(id, wsUrl) { const state = terminals.get(id); - if (!state) return 0; - - dbg(state, 'reconnectTerminal (Razor explicit)', { wsUrl }); - - // Explicit reconnect (e.g. user navigated to a different replica). - // Reset the backoff so we connect immediately rather than waiting - // for the next pending auto-reconnect timer slot. - state.reconnect.attempts = 0; - return connectClient(state, wsUrl); + if (!state) { + return 0; + } + state.wsUrl = wsUrl; + state.attempts = 0; + state.error = null; + connectClient(state); + return state.generation; } export function disposeTerminal(id) { const state = terminals.get(id); - if (!state) return; - - dbg(state, 'disposeTerminal (Blazor unmount)'); - - // Make absolutely sure no late callback resurrects the terminal. - state.reconnect.enabled = false; - cancelPendingReconnect(state); - state.reconnect.generation++; - - // Drop the Blazor callback before tearing down so any in-flight RAF - // notifyToolbar callback no-ops instead of invoking a disposed - // DotNetObjectReference. The .NET side owns disposing the ref - // itself; we just clear our pointer to it. - state.dotNetRef = null; - - if (state._resizeObserver) { - state._resizeObserver.disconnect(); - } - if (state.client) { - const stale = state.client; - stale.onOpen = null; - stale.onScreenBytes = null; - stale.onHello = null; - stale.onRoleChange = null; - stale.onPeerJoin = null; - stale.onPeerLeave = null; - stale.onResize = null; - stale.onExit = null; - stale.onClose = null; - try { stale.close(); } catch { /* ignore */ } - state.client = null; - } - if (state.host && state.host.parentNode) { - try { state.host.parentNode.removeChild(state.host); } catch { /* ignore */ } + if (!state) { + return; } - if (state.term) { - try { state.term.dispose(); } catch { /* ignore */ } + state.disposed = true; + ++state.generation; + cancelReconnect(state); + if (state.toolbarFrame !== null) { + cancelAnimationFrame(state.toolbarFrame); } + state.observer.disconnect(); + releaseClient(state); + state.dotNetRef = null; terminals.delete(id); } -// --- Toolbar commands ---------------------------------------------------- -// -// These wrappers let the page-level toolbar (ConsoleLogs.razor) drive the -// same actions that used to live inside the terminal's own chrome. Each -// is idempotent and silently no-ops if the terminal id is unknown or the -// underlying client/term isn't ready — JS remains authoritative, so a -// stale toolbar click can't put us into a bad state. Mode/role guards -// match the disabled-state logic in flushToolbarState; we still re-check -// here in case the .NET disabled flag hasn't reached the user's click yet. - export function getSizePresets() { - // Return a copy so .NET-side callers can't accidentally mutate the - // module-level array. - return SIZE_PRESETS.map((p) => ({ value: p.value, label: p.label, cols: p.cols, rows: p.rows })); + return SIZE_PRESETS; } -export function setFontSizeFromHost(id, newSize) { +export function setFontSizeFromHost(id, fontSize) { const state = terminals.get(id); - if (!state || typeof newSize !== 'number') return; - // Order matters: apply the new font (which in font-driven mode will - // refit and update term.cols/rows) BEFORE auto-promoting. takePrimary - // sends RequestPrimary(cols,rows) using the current term grid, so if - // we promoted first the server would grant primary at the OLD oversize - // grid and the producer's PTY would keep emitting frames that overflow - // the per-peer queue and re-trigger slow-consumer eviction. By - // resizing locally first, the promotion request itself carries the - // smaller dims and the producer shrinks the PTY on grant. - setFontSize(state, newSize); - maybeAutoPromote(state); + if (!state || !Number.isFinite(fontSize)) { + return; + } + changeSizing(state, { mode: "auto", fontSize: Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, Math.round(fontSize))) }); } export function setSizeModeFromHost(id, sizeKey) { const state = terminals.get(id); - if (!state) return; - if (!sizeKey || sizeKey === 'auto') { - setSizeMode(state, 'font', null); - } else { - const preset = SIZE_PRESETS.find((p) => p.value === sizeKey); - if (preset) { - setSizeMode(state, 'fixed', { cols: preset.cols, rows: preset.rows }); - } + const preset = SIZE_PRESETS.find(p => p.value === sizeKey); + if (!state || !preset) { + return; } - // Promote AFTER applying local sizing so RequestPrimary carries the - // new dims (see setFontSizeFromHost above for the rationale). - maybeAutoPromote(state); + changeSizing(state, preset.value === "auto" + ? { mode: "auto", fontSize: state.sizing.fontSize } + : { mode: "fixed", columns: preset.cols, rows: preset.rows, fontSize: state.sizing.fontSize }); } -function maybeAutoPromote(state) { - const client = state.client; - if (!client || client.peerId === null) return; - if (client.isPrimary) return; - takePrimary(state); +export function requestPrimaryFromHost(id) { + const state = terminals.get(id); + if (!state?.client?.connected || !isVisible(state)) { + return; + } + try { + state.client.requestPrimary(); + } catch (error) { + console.warn("Dashboard terminal primary request failed.", error); + state.pendingSizing = null; + state.error = "sizing-failed"; + notifyToolbar(state); + } } -// Lets the .NET host query the current snapshot on demand (e.g. when -// re-attaching after a re-render). Pure: does not push to the host. export function getToolbarState(id) { const state = terminals.get(id); - if (!state) return null; - return buildToolbarSnapshot(state); + if (!state) { + return null; + } + const connected = state.connected && !!state.client?.connected; + const isPrimary = connected && state.peer.isPrimary; + const canTakeControl = connected && !isPrimary && state.peer.id !== null; + return { + terminalId: id, + generation: state.generation, + status: !connected ? "connecting" : isPrimary ? "primary" : state.peer.primaryId === null ? "no-primary" : "viewer", + connected, isPrimary, canTakeControl, + sizeMode: state.sizing.mode === "auto" ? "font" : "fixed", + sizeKey: state.sizing.mode === "auto" ? "auto" : `${state.sizing.columns}x${state.sizing.rows}`, + fontPx: state.sizing.fontSize, + fontControlsEnabled: (isPrimary && state.sizing.mode === "auto") || canTakeControl, + sizeSelectEnabled: isPrimary || canTakeControl, + cols: state.geometry?.columns ?? 0, + rows: state.geometry?.rows ?? 0, + error: state.error, + }; } -// Force-pushes the current toolbar snapshot to the .NET host, bypassing -// the change-detection cache. The host calls this when its own view of -// the toolbar state has been lost (e.g. a Blazor re-render dropped the -// cached snapshot field) but the JS terminal is still live, so the cached -// "last pushed JSON" wouldn't trigger a fresh push otherwise. export function refreshToolbarState(id) { const state = terminals.get(id); - if (!state) return; - state._lastToolbarJson = null; - flushToolbarState(state); + if (state) { + state.lastToolbarJson = null; + flushToolbar(state); + } } -// Triggers a layout recompute on demand. Called by the .NET host after the -// terminal element becomes visible again following a Console/Terminal view -// flip — the wrapper goes from display:none to visible, which may or may -// not trigger ResizeObserver depending on the browser's box-tree timing. -// Forcing applyRoleAwareLayout here guarantees xterm rebinds to the new -// available space immediately rather than waiting for the next external -// resize event. export function refreshLayout(id) { const state = terminals.get(id); - if (!state) return; - applyRoleAwareLayout(state); + if (!state || !isVisible(state)) { + return; + } + if (state.waitingForVisibility) { + connectClient(state); + } else { + // The public client observes this same container and owns fitting for + // both primary and viewer roles. Never resize the producer or remount + // simply to reveal the view; either would disturb geometry/history. + state.client?.refreshSelectionUI(); + } } diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index 6cfd899db51..e5fb715f236 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -136,9 +136,8 @@ @* For terminal-enabled resources we keep BOTH views mounted and flip visibility via CSS. Unmounting TerminalView on every flip - would tear down xterm.js, the WebSocket, and the PTY consumer - session — destroying scrollback and forcing an HMP1 - StateSync round-trip on every toggle. Unmounting LogViewer + would tear down the Hex1b client and WebSocket, forcing a + new producer-backed history sync on every toggle. Unmounting LogViewer would drop the rendered log buffer for the same reason. `display:none` keeps the DOM, JS state, and Blazor component state alive while only one view is visible at a time. *@ diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 21f223eb226..15813578595 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -179,9 +179,8 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in // ⋯ menu picker. private ConsoleLogsView _activeView = ConsoleLogsView.Console; // Tracks the view that was rendered to the DOM on the previous render - // pass. When the active view flips back to Terminal we need to nudge - // xterm.js to relayout because the wrapper's display:none → visible - // transition may not trigger ResizeObserver in every browser. + // pass. Revealing Terminal starts a deferred mount or refreshes selection + // overlays without disposing the client or changing producer dimensions. private ConsoleLogsView? _lastRenderedView; // UI @@ -475,11 +474,8 @@ _terminalViewRef is { } terminalView && await terminalView.RefreshToolbarStateAsync(); } - // Detect a view-flip TO Terminal and prod xterm to relayout. The - // wrapper element transitions from display:none to visible on this - // render and ResizeObserver is not guaranteed to fire for that - // box-tree change. Without this nudge xterm can stay sized to its - // pre-hide dimensions until the next external resize. + // Notify the terminal after its wrapper becomes visible so a deferred + // mount and selection overlays see the new layout. if (_selectedResourceHasTerminal && _activeView == ConsoleLogsView.Terminal && _lastRenderedView != ConsoleLogsView.Terminal && @@ -1369,15 +1365,15 @@ public ConsoleLogsPageState ConvertViewModelToSerializable() // --- Terminal toolbar wiring ----------------------------------------- // // The TerminalView component pushes a TerminalToolbarState snapshot up - // here whenever the underlying xterm/HMP1 state changes (role flips, + // here whenever the underlying Hex1b state changes (role flips, // resize, font change). Those snapshots drive the page-level toolbar // that replaces the in-frame chrome the terminal used to render itself. // JS remains the source of truth for terminal state; this layer just // mirrors the latest snapshot and routes user actions back to JS via // the TerminalView public methods. private const int TerminalFontStep = 1; - private const int TerminalFontMin = 4; - private const int TerminalFontMax = 72; + private const int TerminalFontMin = 8; + private const int TerminalFontMax = 32; private async Task OnTerminalToolbarStateChangedAsync(Controls.TerminalToolbarState state) { @@ -1507,7 +1503,7 @@ public enum ConsoleLogsView { /// The resource's standard log stream (LogViewer). Console, - /// The interactive xterm.js terminal (TerminalView). + /// The interactive Hex1b terminal (TerminalView). Terminal, } } diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index fb2be72e089..44ccf9c0063 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -50,6 +50,48 @@ public static string ConsoleLogsHeader { return ResourceManager.GetString("ConsoleLogsHeader", resourceCulture); } } + + public static string TerminalInputLabel { + get { + return ResourceManager.GetString("TerminalInputLabel", resourceCulture); + } + } + + public static string TerminalWebGpuUnsupported { + get { + return ResourceManager.GetString("TerminalWebGpuUnsupported", resourceCulture); + } + } + + public static string TerminalMountFailed { + get { + return ResourceManager.GetString("TerminalMountFailed", resourceCulture); + } + } + + public static string TerminalDisconnected { + get { + return ResourceManager.GetString("TerminalDisconnected", resourceCulture); + } + } + + public static string TerminalInputFailed { + get { + return ResourceManager.GetString("TerminalInputFailed", resourceCulture); + } + } + + public static string TerminalSizingFailed { + get { + return ResourceManager.GetString("TerminalSizingFailed", resourceCulture); + } + } + + public static string TerminalRetry { + get { + return ResourceManager.GetString("TerminalRetry", resourceCulture); + } + } public static string ConsoleLogsSelectResourceToolbar { get { diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index 7e11a8fee3d..39e2c1a8f89 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -199,6 +199,27 @@ Decrease font size + + Interactive terminal input + + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + + Reconnect terminal + Increase font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf index 3af534814ee..203e8413b88 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf index 17edeb0d221..3f181d17334 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf index 270b893183f..d413048ece6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf index 49ae39032d6..a0237cf07d2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf index d0c6f0b3b3d..d4ee44d7583 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf index df97140c766..c84d5b5b3c5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf index 62250a55555..de9e2717265 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf index ed83fee123c..bdd3de17860 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf index 47a46d3c60c..a7705ebb242 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf index bff41df02fd..5ee24a4242b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf index 909088609cc..6644b71dece 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf index 3a93f458c91..e8eafd9ded8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf index 2f82fca0ea7..fc65be50471 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf @@ -137,6 +137,36 @@ Console logs capture paused at {0} {0} is a time + + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + + + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + The terminal could not complete the input or clipboard action. Check browser clipboard permissions and try again. + + + + Interactive terminal input + Interactive terminal input + + + + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + + + + Reconnect terminal + Reconnect terminal + + + + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + The terminal could not take resize control or change its dimensions. Try selecting the dimensions again. + + Decrease font size Decrease font size @@ -157,6 +187,11 @@ Increase font size + + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs b/src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs index 5f679149493..6ddfa19903a 100644 --- a/src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs +++ b/src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs @@ -20,14 +20,8 @@ namespace Aspire.Dashboard.Terminal; /// anything in its temp directory), but the path never reaches the browser via /// the terminal WebSocket because the proxy takes only /// resource/replica identifiers. -/// The resolver intentionally does not use Hex1b's -/// WithHmp1UdsClient builder. That builder is for in-process Hex1b -/// applications that want to embed the HMP1 stream into a Hex1b -/// terminal (the CLI's aspire terminal attach path does exactly that). -/// The dashboard never instantiates a Hex1b terminal — it is a byte-level -/// proxy between the browser's HMP1 client and the remote terminal host — -/// so the resolver only needs the raw stream and reaches for the lower-level -/// helper instead. +/// The resolver opens only the transport. The WebSocket handler owns +/// the HMP1 consumer and its per-browser HWT1 presentation lifetime. /// internal sealed class DefaultTerminalConnectionResolver : ITerminalConnectionResolver { diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index 7ed4ccdcbd7..255f1daff8f 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -1,50 +1,24 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Buffers; -using System.Diagnostics; using System.Net.WebSockets; +using System.Net.Sockets; +using System.Text.Json; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; +using Hex1b; namespace Aspire.Dashboard.Terminal; /// -/// ASP.NET Core middleware that bridges a single browser WebSocket to the -/// upstream Aspire.TerminalHost consumer UDS for the requested -/// resource and replica. The browser speaks HMP v1 directly via its -/// JavaScript HMP1 client (/js/hmp1-client.js); this handler is a -/// dumb byte pump that shuttles raw HMP1 frames in both directions. +/// Presents an AppHost-owned HMP1 terminal to a browser using Hex1b's HWT1 adapter. /// -/// -/// From the upstream's perspective the browser tab is just another -/// HMP v1 peer in its multi-head roster, so take-control / role-change / -/// state-replay all work end-to-end without any per-connection emulator -/// state in the dashboard process. -/// The browser identifies the target replica via -/// ?resource=<name>&replica=<index>; the actual UDS -/// path is resolved server-side by -/// so the dashboard never trusts a browser-supplied filesystem path. -/// -/// Why a custom proxy and not Hex1b's Hmp1PresentationAdapter? -/// Hmp1PresentationAdapter is the server side of HMP1: it lives -/// in the process that owns the underlying terminal (Aspire.TerminalHost) and -/// multicasts a single Hex1b terminal to many HMP1 peers. The dashboard never -/// owns a terminal — it sits between two HMP1 endpoints (the browser and the -/// remote terminal host) and relays frames at the byte level. Likewise -/// WebSocketPresentationAdapter is for in-process Hex1b apps that -/// render themselves to a browser via WebSocket; it is not a -/// WebSocket↔stream bridge. Until Hex1b ships a generic HMP1 WebSocket -/// proxy primitive there is no built-in adapter that fits the dashboard's -/// role, so this thin pump is the minimum viable implementation. -/// -/// internal static class TerminalWebSocketProxy { - /// - /// Maps the terminal WebSocket endpoint at /api/terminal. The handler - /// requires the same browser authentication as the rest of the Blazor UI. - /// + private static readonly TimeSpan s_handshakeTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_sendTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(2); + public static void MapTerminalWebSocket(this WebApplication app) { app.Map("/api/terminal", async (HttpContext context, @@ -52,40 +26,7 @@ public static void MapTerminalWebSocket(this WebApplication app) ILoggerFactory loggerFactory) => { var logger = loggerFactory.CreateLogger("Aspire.Dashboard.Terminal.TerminalWebSocketProxy"); - - // Per-connection correlation id. Lets us tie pump-end logs and - // any escape-to-Kestrel logs back to a specific browser tab even - // when many terminals are open. Cheap (16 bytes) and isolates a - // particular replica's failure from neighbours under load. - var connectionId = Guid.NewGuid().ToString("n").Substring(0, 8); - - try - { - await HandleAsync(context, resolver, logger, connectionId).ConfigureAwait(false); - } - catch (Exception ex) - { - // Belt-and-braces: if any exception escapes the inner handler - // (e.g. from a code path our nested catches missed), log it - // here at error. Without this the exception would reach - // Kestrel, which can take the entire dashboard down depending - // on the request state — observed when an AppHost Stop killed - // the dashboard via an unhandled terminal-handler exception. - logger.LogError(ex, "Terminal WebSocket handler {ConnectionId} crashed.", connectionId); - - // Best-effort response if we haven't started writing one yet. - if (!context.Response.HasStarted) - { - try - { - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - } - catch - { - // Response could be partially flushed by Kestrel; nothing more to do. - } - } - } + await HandleAsync(context, resolver, logger, context.TraceIdentifier).ConfigureAwait(false); }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName); } @@ -101,27 +42,15 @@ internal static async Task HandleAsync(HttpContext context, return; } - // Cross-Site WebSocket Hijacking defense. Browsers do NOT apply the same-origin - // policy to WebSockets and ASP.NET Core's antiforgery middleware does not gate - // WS upgrades (they're GET ... Connection: Upgrade). Without an explicit Origin - // check, any page loaded in a logged-in developer's browser could - // `new WebSocket("wss://localhost:/api/terminal?resource=...")`, ride the - // dashboard's auth cookie, and gain read+write of any WithTerminal() shell. - // - // The only legitimate caller of this endpoint is the dashboard's own - // TerminalView razor component, which is always served from the dashboard's - // own scheme+host (Request.Scheme + Request.Host). Behind a reverse proxy - // with UseForwardedHeaders, those reflect the public host so PublicUrl is - // handled automatically. Browsers always send Origin on WebSocket upgrades, - // so a missing Origin on a WS request is itself suspicious — reject. - // - // See: https://datatracker.ietf.org/doc/html/rfc6455#section-10.2 - if (!WebSocketOriginValidator.IsSameOrigin(context, out var originLogValue)) + // Browsers send cookies on cross-origin WebSocket upgrades, and antiforgery + // middleware does not protect these GET requests. Validate Origin before + // opening any resource connection, including when frontend auth is disabled. + // See https://datatracker.ietf.org/doc/html/rfc6455#section-10.2. + if (!IsAllowedOrigin(context, out var originLogValue)) { logger.LogWarning( "Rejecting terminal WebSocket upgrade {ConnectionId} with disallowed Origin '{Origin}'.", - connectionId, - originLogValue); + connectionId, originLogValue); context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsync("Origin not allowed.").ConfigureAwait(false); return; @@ -129,7 +58,6 @@ internal static async Task HandleAsync(HttpContext context, var resourceName = context.Request.Query["resource"].ToString(); var replicaText = context.Request.Query["replica"].ToString(); - if (string.IsNullOrWhiteSpace(resourceName)) { context.Response.StatusCode = StatusCodes.Status400BadRequest; @@ -137,7 +65,6 @@ internal static async Task HandleAsync(HttpContext context, return; } - // Default to replica 0 when omitted (single-replica resources). var replicaIndex = 0; if (!string.IsNullOrWhiteSpace(replicaText) && !int.TryParse(replicaText, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out replicaIndex)) @@ -154,16 +81,17 @@ internal static async Task HandleAsync(HttpContext context, return; } - // Resolve the upstream stream entirely server-side. This is the only - // step that knows the consumer UDS path; nothing about the path leaks - // out to the browser. We resolve eagerly (before accepting the WS) - // so we can return a proper 404/503 if the resource isn't ready. + // The browser supplies resource identity, never a filesystem path. Resolve + // before accepting the upgrade so unavailable resources retain HTTP errors. Stream? upstream; try { - upstream = await resolver.ConnectAsync(resourceName, replicaIndex, context.RequestAborted).ConfigureAwait(false); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + timeout.CancelAfter(s_handshakeTimeout); + upstream = await resolver.ConnectAsync(resourceName, replicaIndex, timeout.Token).ConfigureAwait(false); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is IOException or SocketException or TimeoutException || + ex is OperationCanceledException && !context.RequestAborted.IsCancellationRequested) { logger.LogWarning(ex, "Failed to resolve terminal connection for {Resource}/{Replica}.", resourceName, replicaIndex); context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; @@ -178,310 +106,178 @@ internal static async Task HandleAsync(HttpContext context, return; } - // Hex1b's Hmp1PresentationAdapter holds a 1000-frame BoundedChannel - // per peer and uses TryWrite — when the channel fills it disconnects - // the peer outright (no backpressure, no drop-oldest). On high- - // resolution terminals every Output frame is many KB, so a slow - // browser drain easily fills 1000 frames in a couple of seconds. - // Bumping the OS receive buffer on the consumer UDS gives Hex1b's - // write pump much more headroom to drain into the kernel before - // its channel fills, which directly buys time for the WS proxy to - // catch up under stress. 1 MB is well above the default (208 KB on - // Linux, 8 KB on macOS) but still trivial per connection. Best- - // effort: any failure is non-fatal (the connection still works, - // just at the default buffer size). - if (upstream is System.Net.Sockets.NetworkStream ns) + await using var upstreamLifetime = upstream.ConfigureAwait(false); + using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); + var closeStatus = WebSocketCloseStatus.NormalClosure; + var closeReason = "Terminal closed"; + try { - try - { - ns.Socket.ReceiveBufferSize = 1 * 1024 * 1024; - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to bump UDS receive buffer for {ConnectionId}.", connectionId); - } + logger.LogDebug("Terminal view opened for {Resource}/{Replica} ({ConnectionId}).", + resourceName, replicaIndex, connectionId); + await BridgeAsync(socket, upstream, logger, context.RequestAborted).ConfigureAwait(false); } - - WebSocket ws; - try + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { - ws = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); + // The browser or the dashboard has ended this request. } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or WebSocketException) { - logger.LogWarning(ex, "Failed to accept terminal WebSocket for {Resource}/{Replica}.", resourceName, replicaIndex); - try { upstream.Dispose(); } catch { /* swallow */ } - return; + logger.LogDebug(ex, "Terminal transport disconnected ({ConnectionId}).", connectionId); + closeStatus = WebSocketCloseStatus.EndpointUnavailable; + closeReason = "Terminal transport disconnected"; } - - // Log at Information so the in/out trace is visible in the - // default AppHost log without enabling debug logging. Critical - // forensics when the dashboard process dies on Stop — without - // this we can't even see whether the connection got established. - logger.LogInformation("Terminal WS opened for {Resource}/{Replica} ({ConnectionId}).", - resourceName, replicaIndex, connectionId); - - try + catch (TimeoutException ex) + { + logger.LogWarning(ex, "Terminal view timed out ({ConnectionId}).", connectionId); + closeStatus = WebSocketCloseStatus.PolicyViolation; + closeReason = "Terminal connection timed out"; + } + catch (Exception ex) when (ex is InvalidDataException or JsonException or InvalidOperationException or + KeyNotFoundException or FormatException or ArgumentException) { - await BridgeAsync(ws, upstream, logger, connectionId, context.RequestAborted).ConfigureAwait(false); + logger.LogWarning(ex, "Invalid terminal input or state ({ConnectionId}).", connectionId); + closeStatus = WebSocketCloseStatus.PolicyViolation; + closeReason = "Invalid terminal input or state"; } finally { - try { upstream.Dispose(); } catch { /* swallow */ } - logger.LogInformation("Terminal WS closed for {Resource}/{Replica} ({ConnectionId}).", - resourceName, replicaIndex, connectionId); + if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + using var timeout = new CancellationTokenSource(s_closeTimeout); + try + { + await socket.CloseOutputAsync(closeStatus, closeReason, timeout.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is WebSocketException or OperationCanceledException) + { + logger.LogDebug(ex, "Terminal close handshake failed ({ConnectionId}).", connectionId); + socket.Abort(); + } + } } + } - // Best-effort graceful close. Honour CT.None for the close handshake - // so a server-shutdown request abort doesn't skip the courtesy close. - if (ws.State == WebSocketState.Open) + internal static async Task BridgeAsync(WebSocket socket, Stream upstream, ILogger logger, CancellationToken cancellationToken) + { + var workload = new Hmp1WorkloadAdapter(new Hmp1ClientOptions { + StreamFactory = _ => Task.FromResult(upstream), + DisplayName = "Aspire dashboard" + }); + await using var workloadLifetime = workload.ConfigureAwait(false); + using (var handshake = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + handshake.CancelAfter(s_handshakeTimeout); try { - await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, - "terminal closed", - CancellationToken.None).ConfigureAwait(false); + await workload.ConnectAsync(handshake.Token).ConfigureAwait(false); } - catch + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - // best effort + throw new TimeoutException("The terminal host did not complete its handshake."); } } - } - - /// - /// Two-task duplex pump: WS→upstream and upstream→WS. Either side - /// closing/erroring cancels the other. The first task completing is - /// the trigger; both tasks are awaited (with their own per-task try/ - /// catch) so no exception escapes the bridge. - /// - private static async Task BridgeAsync(WebSocket ws, - Stream upstream, - ILogger logger, - string connectionId, - CancellationToken ct) - { - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var token = linkedCts.Token; - // Outbound (upstream → browser) is the heavy direction for terminal - // workloads (fullscreen TUIs emit kilobytes per frame). Use a larger - // pooled buffer and coalesce consecutive available reads into one - // WebSocket send. Fewer, larger WS messages = fewer browser dispatch - // events per second under stress = faster drain = less risk of - // tripping Hex1b's slow-peer eviction. 256 KB is well above any - // realistic single Output frame at sane terminal sizes, but still - // a small per-connection cost. - const int OutboundBufferSize = 256 * 1024; - const int InboundBufferSize = 16 * 1024; - var upstreamNs = upstream as System.Net.Sockets.NetworkStream; - - // Browser → upstream. WS frames carry HMP1 payloads from the JS - // client (Input, Resize, RequestPrimary, ClientHello). Forward - // verbatim; upstream's Hex1b server speaks HMP1. - var inbound = Task.Run(async () => + // A direct HMP1 workload preserves the producer's confirmed primary role, + // geometry and graphics checkpoints. The mirror belongs to this browser; + // disposing it disconnects the peer, not the AppHost-owned terminal. + // https://github.com/mitchdenny/hex1b/blob/1f47fd9a/docs/web-terminal.md + var presentation = new Hwt1PresentationAdapter(); + await using var presentationLifetime = presentation.ConfigureAwait(false); + var terminal = Hex1bTerminal.CreateBuilder() + .WithWorkload(workload) + .WithPresentation(presentation) + .WithScrollback(10000) + .Build(); + await using var terminalLifetime = terminal.ConfigureAwait(false); + using var stopping = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var tasks = new[] + { + SendFramesAsync(socket, presentation, stopping.Token), + ReceiveMessagesAsync(socket, presentation, stopping.Token), + workload.DisconnectedTask.WaitAsync(stopping.Token) + }; + try + { + var completed = await Task.WhenAny(tasks).ConfigureAwait(false); + await completed.ConfigureAwait(false); + } + finally { - var buffer = ArrayPool.Shared.Rent(InboundBufferSize); - var bytesIn = 0L; - string endReason = "unknown"; - Exception? endException = null; + await stopping.CancelAsync().ConfigureAwait(false); try { - while (!token.IsCancellationRequested) - { - var msg = await ws.ReceiveAsync(buffer, token).ConfigureAwait(false); - if (msg.MessageType == WebSocketMessageType.Close) - { - endReason = "browser-close"; - return; - } - - if (msg.Count > 0) - { - bytesIn += msg.Count; - await upstream.WriteAsync(buffer.AsMemory(0, msg.Count), token).ConfigureAwait(false); - await upstream.FlushAsync(token).ConfigureAwait(false); - } - } - endReason = "cancelled"; - } - catch (OperationCanceledException) - { - endReason = "cancelled"; + await Task.WhenAll(tasks).ConfigureAwait(false); } - catch (Exception ex) + catch (OperationCanceledException) when (stopping.IsCancellationRequested) { - // Catch-all on purpose: HMP1-protocol exceptions and - // abrupt-kill races can surface here as IOException, - // WebSocketException, ObjectDisposedException, or unrelated - // types depending on the failure mode. Letting any of them - // escape the pump propagates to Kestrel and risks dropping - // the dashboard request pipeline. - endReason = "exception"; - endException = ex; } - finally + catch (Exception ex) when (ex is IOException or WebSocketException or TimeoutException or + InvalidDataException or JsonException or InvalidOperationException or KeyNotFoundException or + FormatException or ArgumentException) { - ArrayPool.Shared.Return(buffer); - LogPumpEnd(logger, connectionId, "inbound", endReason, endException, bytesIn, sends: 0, slowSends: 0, maxSendMs: 0); + // Preserve the first failure while observing errors from both pumps. + logger.LogDebug(ex, "Terminal pump ended during view teardown."); } - }, token); + } + } - // Upstream → browser. Raw HMP1 frames from the terminal host's - // Hmp1PresentationAdapter; forward as binary WS frames. The JS - // HMP1 client reassembles them across WS message boundaries. - var outbound = Task.Run(async () => + private static async Task SendFramesAsync(WebSocket socket, Hwt1PresentationAdapter presentation, CancellationToken cancellationToken) + { + while (true) { - var buffer = ArrayPool.Shared.Rent(OutboundBufferSize); - var bytesOut = 0L; - var sends = 0L; - var slowSends = 0L; - var maxSendMs = 0L; - string endReason = "unknown"; - Exception? endException = null; + // HWT1 frames are ordered complete binary messages. The adapter handles + // acknowledgements and coalesces state while blocked; never drop frames. + var frame = await presentation.ReadFrameAsync(cancellationToken).ConfigureAwait(false); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(s_sendTimeout); try { - while (!token.IsCancellationRequested) - { - var read = await upstream.ReadAsync(buffer.AsMemory(0, OutboundBufferSize), token).ConfigureAwait(false); - if (read == 0) - { - // Upstream EOF — terminal host process died, the - // replica recycled, or the host evicted this peer - // (e.g. slow-consumer policy). Tear the WS down so - // the JS reconnect loop kicks in. The - // distinguishing signal (vs. ReadAsync throwing) is - // logged via endReason below. - endReason = "upstream-eof"; - return; - } - - // Coalesce: while more data is already available on - // the socket without blocking, keep filling the buffer - // up to OutboundBufferSize. NetworkStream.DataAvailable - // is a synchronous SO_NREAD probe — we use synchronous - // Read for the follow-on chunks so we don't pay the - // async state-machine cost for what's effectively a - // memcpy from the kernel buffer. This collapses bursts - // of small upstream writes into one larger WS message, - // which under stress is the difference between the - // browser keeping up and tripping Hex1b's per-peer - // slow-consumer eviction. - if (upstreamNs is not null) - { - while (read < OutboundBufferSize && upstreamNs.DataAvailable) - { - int more; - try - { - more = upstreamNs.Read(buffer, read, OutboundBufferSize - read); - } - catch - { - // Defer to the next outer ReadAsync loop iteration - // to surface the failure with proper exception type. - break; - } - if (more <= 0) - { - break; - } - read += more; - } - } - - bytesOut += read; - sends++; - - var sw = ValueStopwatch.StartNew(); - await ws.SendAsync(new ArraySegment(buffer, 0, read), - WebSocketMessageType.Binary, - endOfMessage: true, - token).ConfigureAwait(false); - var sendMs = sw.ElapsedMilliseconds; - if (sendMs > maxSendMs) - { - maxSendMs = sendMs; - } - // 100ms is well above the inter-frame budget for a - // 60fps TUI (≈16ms). Sustained slow sends here are - // the primary smoking gun for browser-side - // backpressure that ultimately triggers an upstream - // slow-peer eviction. - if (sendMs >= 100) - { - slowSends++; - } - } - endReason = "cancelled"; - } - catch (OperationCanceledException) - { - endReason = "cancelled"; - } - catch (Exception ex) - { - endReason = "exception"; - endException = ex; + await socket.SendAsync(frame, WebSocketMessageType.Binary, true, timeout.Token).ConfigureAwait(false); } - finally + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - ArrayPool.Shared.Return(buffer); - LogPumpEnd(logger, connectionId, "outbound", endReason, endException, bytesOut, sends, slowSends, maxSendMs); + throw new TimeoutException("The browser did not receive a terminal frame."); } - }, token); - - // Whoever finishes first triggers teardown of the other; both are - // then awaited so we don't leave background tasks running after - // the request scope ends. - var firstCompleted = await Task.WhenAny(inbound, outbound).ConfigureAwait(false); - logger.LogInformation("Terminal bridge first pump ended for {ConnectionId}: {Pump}.", - connectionId, firstCompleted == inbound ? "inbound" : "outbound"); - try { await linkedCts.CancelAsync().ConfigureAwait(false); } catch { /* swallow */ } - try { await Task.WhenAll(inbound, outbound).ConfigureAwait(false); } catch { /* swallow */ } + } } - private static void LogPumpEnd(ILogger logger, string connectionId, string direction, string reason, - Exception? exception, long bytes, long sends, long slowSends, long maxSendMs) + private static async Task ReceiveMessagesAsync(WebSocket socket, Hwt1PresentationAdapter presentation, CancellationToken cancellationToken) { - // Log abnormal terminations at Warning so they show up in default - // AppHost output, normal terminations at Information. The reason - // string is the single most useful signal for diagnosing periodic - // reconnects: "upstream-eof" points at the terminal host / - // slow-peer policy; "exception" + the type points at a transport - // failure; "browser-close" is a clean browser-initiated close. - var slow = direction == "outbound" ? $" sends={sends} slowSends={slowSends} maxSendMs={maxSendMs}" : ""; - var exType = exception?.GetType().FullName ?? "(none)"; - var level = (reason is "exception" or "upstream-eof") ? LogLevel.Warning : LogLevel.Information; - logger.Log(level, exception, - "Terminal WS {Direction} pump ended for {ConnectionId}: reason={Reason} bytes={Bytes}{SlowInfo} exceptionType={ExceptionType}.", - direction, connectionId, reason, bytes, slow, exType); + // HWT1 commands are UTF-8 JSON, e.g. {"type":"ack","revision":1}. WebSocket + // fragmentation can split anywhere, including within a UTF-8 code point. + // Reassemble the whole bounded message before handing it to the public API. + var buffer = new byte[64 * 1024]; + while (true) + { + var length = 0; + ValueWebSocketReceiveResult result; + do + { + if (length == buffer.Length) + { + throw new InvalidDataException("Terminal input exceeds 64 KiB."); + } + + result = await socket.ReceiveAsync(buffer.AsMemory(length), cancellationToken).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + return; + } + if (result.MessageType != WebSocketMessageType.Text) + { + throw new InvalidDataException("Expected a terminal JSON command."); + } + length += result.Count; + } + while (!result.EndOfMessage); + + await presentation.HandleMessageAsync(buffer.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + } } - /// - /// Returns true if the request's Origin header is present and matches the - /// dashboard's own scheme+host (the page that legitimately opens this WebSocket). - /// Internal so tests can exercise it without spinning up a full WS server. - /// - /// - /// Defense against Cross-Site WebSocket Hijacking. We deliberately do not consult - /// a configurable allow-list: the only legitimate caller is the dashboard's own - /// TerminalView component, so the request's scheme+host (which honors forwarded - /// headers when behind a reverse proxy) is always the correct match. A missing - /// Origin on a WS upgrade is treated as disallowed because conforming browsers - /// always include it. - /// internal static bool IsAllowedOrigin(HttpContext context, out string originLogValue) { return WebSocketOriginValidator.IsSameOrigin(context, out originLogValue); } - - private readonly struct ValueStopwatch - { - private static readonly double s_timestampToMs = 1000.0 / Stopwatch.Frequency; - private readonly long _start; - private ValueStopwatch(long start) => _start = start; - public static ValueStopwatch StartNew() => new(Stopwatch.GetTimestamp()); - public long ElapsedMilliseconds => (long)((Stopwatch.GetTimestamp() - _start) * s_timestampToMs); - } } diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json new file mode 100644 index 00000000000..29f5b5aa2d1 --- /dev/null +++ b/src/Aspire.Dashboard/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "aspire-dashboard-assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aspire-dashboard-assets", + "dependencies": { + "@hex1b/web-terminal": "0.167.0-alpha.1509.1.1f47fd9" + } + }, + "node_modules/@hex1b/web-terminal": { + "version": "0.167.0-alpha.1509.1.1f47fd9", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1509.1.1f47fd9.tgz", + "integrity": "sha512-Qe+mYpSRlrOwwJ57lYQk1z1AFkdCYL6L3zkkG5X+fbdo/0hOT74fgdLWLKJE11X/CWuRB8oX/sCJJJP23qNnXw==", + "license": "MIT", + "engines": { + "node": ">=22" + } + } + } +} diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json new file mode 100644 index 00000000000..3f3022be7de --- /dev/null +++ b/src/Aspire.Dashboard/package.json @@ -0,0 +1,13 @@ +{ + "name": "aspire-dashboard-assets", + "private": true, + "type": "module", + "scripts": { + "update-terminal-assets": "node scripts/update-terminal-assets.mjs && node scripts/verify-terminal-assets.mjs", + "verify-terminal-assets": "node scripts/verify-terminal-assets.mjs", + "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" + }, + "dependencies": { + "@hex1b/web-terminal": "0.167.0-alpha.1509.1.1f47fd9" + } +} diff --git a/src/Aspire.Dashboard/scripts/update-terminal-assets.mjs b/src/Aspire.Dashboard/scripts/update-terminal-assets.mjs new file mode 100644 index 00000000000..fab8d30b91e --- /dev/null +++ b/src/Aspire.Dashboard/scripts/update-terminal-assets.mjs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import { cp, mkdir, readFile, rm } from "node:fs/promises"; + +const dashboard = new URL("../", import.meta.url); +const source = new URL("node_modules/@hex1b/web-terminal/", dashboard); +const destination = new URL("wwwroot/js/hex1b-web-terminal/", dashboard); +const manifest = JSON.parse(await readFile(new URL("package.json", dashboard), "utf8")); +const installed = JSON.parse(await readFile(new URL("package.json", source), "utf8")); +if (installed.version !== manifest.dependencies["@hex1b/web-terminal"]) { + throw new Error("Run npm ci before updating terminal assets; the installed package must match the exact manifest version."); +} + +await rm(destination, { recursive: true, force: true }); +await mkdir(destination, { recursive: true }); +// The module worker and font URLs are relative to the emitted modules. Keep +// the complete tree, including maps, declarations, font provenance and licenses. +await cp(new URL("dist/", source), new URL("dist/", destination), { recursive: true }); +for (const name of ["LICENSE", "README.md", "package.json"]) { + await cp(new URL(name, source), new URL(name, destination)); +} +console.log(`Vendored @hex1b/web-terminal ${installed.version}.`); diff --git a/src/Aspire.Dashboard/scripts/verify-terminal-assets.mjs b/src/Aspire.Dashboard/scripts/verify-terminal-assets.mjs new file mode 100644 index 00000000000..828f0a26ead --- /dev/null +++ b/src/Aspire.Dashboard/scripts/verify-terminal-assets.mjs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const dashboard = new URL("../", import.meta.url); +const installed = new URL("node_modules/@hex1b/web-terminal/", dashboard); +const vendored = new URL("wwwroot/js/hex1b-web-terminal/", dashboard); +const manifest = JSON.parse(await readFile(new URL("package.json", dashboard), "utf8")); +const packageInfo = JSON.parse(await readFile(new URL("package.json", installed), "utf8")); +assert.equal(packageInfo.version, manifest.dependencies["@hex1b/web-terminal"], + "Run npm ci before verifying terminal assets; the installed package must match the exact manifest version."); + +async function listFiles(root) { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries.filter(entry => entry.isFile()) + .map(entry => relative(root, join(entry.parentPath, entry.name))) + .sort(); +} + +const installedDist = fileURLToPath(new URL("dist/", installed)); +const vendoredDist = fileURLToPath(new URL("dist/", vendored)); +const files = await listFiles(installedDist); +assert.deepEqual(await listFiles(vendoredDist), files, + "The vendored dist tree must contain every installed file and no stale extras."); +for (const name of files) { + assert.deepEqual(await readFile(join(vendoredDist, name)), await readFile(join(installedDist, name)), name); +} +for (const name of ["LICENSE", "README.md", "package.json"]) { + assert.deepEqual(await readFile(new URL(name, vendored)), await readFile(new URL(name, installed)), name); +} +console.log(`Verified ${files.length} dist files and package metadata/licenses for @hex1b/web-terminal ${packageInfo.version}.`); diff --git a/src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf/README.md b/src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf/README.md deleted file mode 100644 index 823e3612c87..00000000000 --- a/src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Cascadia Mono NF - -Cascadia Mono with the Nerd Font glyph set, used by the dashboard's -embedded terminal view (`TerminalView`) so modern terminal applications -(devbox prompts, lazygit, htop, k9s, etc.) render Powerline separators -and Nerd Font icons correctly rather than as missing-glyph boxes. - -## Source - -- Upstream: -- Release: `v2407.24` (latest as of bundling) -- File: `woff2/CascadiaMonoNF.woff2` (variable font — covers all - weights in one ~950 KB asset). - -Cascadia Mono NF is the **mono** (no ligatures, ligatures are -problematic in terminal output) variant of Cascadia Code with the -official Nerd Font patch. It is built and published by Microsoft. - -## License - -SIL Open Font License, Version 1.1. See `LICENSE.txt` in this -directory for the full text and the reserved-font-name notice. OFL -section 2 permits bundling the font with any software provided the -copyright notice and license are included alongside the font file, -which is what `LICENSE.txt` is for. - -## How it's used - -The font is referenced via a `@font-face` rule injected by -`Components/Controls/TerminalView.razor.js` (`ensureTerminalStyles()`) -under the family name `"Cascadia Mono NF"`, which is then passed to -the xterm.js `Terminal` constructor's `fontFamily` option with system -monospace fallbacks for the brief moment before the woff2 finishes -loading and as a hard fallback if the asset is unavailable. - -## Updating - -1. Download the latest release zip from - . -2. Replace `CascadiaMonoNF.woff2` with the new - `woff2/CascadiaMonoNF.woff2`. -3. If upstream updates the LICENSE text (rare), refresh - `LICENSE.txt`. -4. Bump the release tag at the top of this README so the source is - traceable. diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index d66ac9c00b5..a9dc8a36854 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -11,3 +11,94 @@ From [Plotly JS's docs](https://github.com/plotly/plotly.js/blob/22efc2fb76f4c89 > The `basic` partial bundle contains trace modules `bar`, `pie` and `scatter`. If we ever want to show more chart types than those, we'll need to change the bundle we use. + +## Hex1b web terminal + +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1509.1.1f47fd9**, +paired with the Hex1b NuGet build from commit +`1f47fd9a9f8a4b0c79f3ec6e3f6f9ca8e86fc235`. The client and server use the evolving +HWT1 presentation transport and must be updated together. Do not substitute a +different client based only on a similar version number. + +From `src/Aspire.Dashboard`, use Node.js 22 or later to acquire and update assets: + +```shell +npm ci --ignore-scripts +npm run update-terminal-assets +npm test +``` + +`update-terminal-assets` copies the package and then verifies that every emitted +file matches the installed package byte-for-byte, including metadata/licenses, +and that no stale files remain in `dist/`. To rerun that acquisition-only check +without copying, use `npm run verify-terminal-assets` after `npm ci`. This check +intentionally requires `node_modules`; ordinary regression tests do not. + +Review and commit the manifest, lockfile, and generated asset changes together. +This is a manual acquisition step: ordinary .NET builds use the checked-in files +and do not run npm or download frontend packages. + +The update script copies the **complete `dist/` tree**, preserving relative ES +module, module-worker, source-map, declaration, and font paths. It also retains +the package metadata, README, and MIT license. The bundled Cascadia Mono NF font +has its own SIL Open Font License and provenance under +`dist/fonts/cascadia-mono-nf/`. Do not flatten, selectively bundle, or edit these +vendored files. `TerminalView.razor.js` imports only the public `dist/index.js` +entry point, not the package's internal protocol/renderer modules. + +The terminal requires WebGPU in a secure context (HTTPS or localhost), module +workers, transferable OffscreenCanvas, worker animation frames, ResizeObserver, +and CSS Font Loading. There is no Canvas2D renderer fallback. Serve JavaScript +and WOFF2 with their correct MIME types and allow same-origin workers, fonts, +and `/api/terminal` WebSockets in the deployment CSP. The dashboard displays a +localized error if capability checks or mounting fail. + +The component import and socket endpoint resolve beneath `NavigationManager.BaseUri`. +The package import, worker entry, and bundled font resolve relative to their +modules, so deployments under a PathBase retain the prefix throughout the +asset tree. No blob worker, eval, CDN, or cross-origin font permission is +needed. The dashboard's existing `script-src 'self'` also allows same-origin +workers through the CSP worker-source fallback; its production +`default-src 'self'` covers the font and same-origin connections. + +Each reconnect aborts the previous mount and creates a new client. Mounting is +deferred while initially hidden; once connected, changing the Console/Terminal +view retains the client, selection, and producer-backed history. Disposal closes +only this view, never the server-side producer. Sizing changes explicitly request +primary when necessary and wait for role confirmation; normal input does not +take resize ownership. Public font-size limits are 8–32 pixels. + +Role state comes from the public `onRoleChange` callback's `id`, `primaryId`, +and `isPrimary` fields. The backend's direct HMP workload mirror preserves +remote primary identity, takeover, and resize authority in this metadata. +The callback does not expose a full peer roster, and the dashboard does not +infer one from the primary identity. + +### Migration boundaries + +The public API supports auto/fixed sizing, primary requests, keyboard and mouse +input, paste/copy, selection, and producer-backed history. It has no terminal +theme setter, search API, clear-buffer API, or title-change callback. Terminal +colors and content are server-authoritative; inspection UI uses the package's +theme defaults/tokens. The previous terminal hardcoded dark xterm colors rather +than offering a theme control. Search, filtering, clearing the log display, and +downloads belong to the separate Blazor `LogViewer`, which does not use xterm +and is unchanged by this migration. + +The dashboard's lifecycle adapter is covered by Node's built-in test runner. +From the repository root, the core suite can run directly with **no npm install +and no `node_modules` directory**: + +```shell +node --test tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs +``` + +These tests use only Node built-ins and checked-in assets. Both suites also +run in CI through `Infrastructure.Tests` using the existing `NodeCommand` +helper. They verify focused shadow-DOM input isolation from dashboard shortcuts, +cancellation, reconnect generations, visibility, role-gated sizing, failure +state, PathBase asset URLs, deployment asset presence, and exact version parity +between `Directory.Packages.props`, the npm manifest/lockfile, and the vendored +package. Complete installed-package byte comparison belongs to the separate +acquisition verification command above. Neither suite substitutes for a browser +WebGPU rendering test or multi-peer server/CLI integration tests. diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index 55adc7add15..fca1363d639 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -199,7 +199,14 @@ window.copyText = function (text) { }; function isActiveElementInput() { - const currentElement = document.activeElement; + let currentElement = document.activeElement; + // Document.activeElement is the shadow host when Hex1b's textarea has + // focus. Follow focused shadow roots so printable keys remain terminal + // input rather than triggering dashboard navigation shortcuts. + // https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement + while (currentElement.shadowRoot?.activeElement) { + currentElement = currentElement.shadowRoot.activeElement; + } const tagName = currentElement.tagName.toLowerCase(); // fluent components may have shadow roots that contain inputs diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/LICENSE b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/LICENSE new file mode 100644 index 00000000000..3b59cdabd5b --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitch Denny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md new file mode 100644 index 00000000000..2614ee367db --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md @@ -0,0 +1,243 @@ +# @hex1b/web-terminal + +The first-party WebGPU browser terminal for Hex1b. It renders server-authoritative +cells and graphics in a module worker, with local input routing, producer-backed +history and selection, clipboard actions, and primary/secondary view sizing. +There are no runtime package dependencies. + +**Experimental and paired with Hex1b:** this client speaks the evolving HWT1 +transport implemented by the matching Hex1b server. HWT1 and internal browser +modules are not a supported third-party protocol or renderer API. The bootstrap +npm version `0.1.0` must be paired with the server build from the **same +implementation commit**; it is not compatible merely by version number with the +historical Hex1b NuGet `0.1.0`. Subsequent normal CI releases coordinate npm and +NuGet versions; use matching builds from the same release. + +## Install and mount + +```sh +npm install @hex1b/web-terminal +``` + +Give the container a nonzero width and height. Mount resolves after a connected +frame has been presented, or rejects on initialization failure or a 30-second +first-frame timeout. + +```html +
+``` + +```ts +import { WebTerminal } from "@hex1b/web-terminal"; + +const container = document.getElementById("terminal"); +if (!container) throw new Error("Missing terminal container"); + +const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", // Your matching Hex1b HWT1 WebSocket endpoint. + sizing: { mode: "auto", fontSize: 16 }, + onStatus(message, level) { + console.log(level, message); + } +}); + +terminal.focus(); +// On component teardown: +// terminal.dispose(); +``` + +`url` accepts a string or URL; relative URLs resolve against the page, and +`http:`/`https:` become `ws:`/`wss:`. An optional `AbortSignal` cancels mounting +or disposes a mounted view. Disposal removes only the appended element and its +connection, not the container or server-side shared terminal. + +### Browser and deployment requirements + +Use HTTPS or localhost and a browser with WebGPU, module workers, transferable +OffscreenCanvas, worker animation frames, ResizeObserver, and CSS Font Loading. +Clipboard access also requires browser permission and, for relevant actions, a +user gesture. There is no Canvas2D terminal-rendering fallback. + +The package contains browser ES modules, not a single bundle. For bare static +hosting, copy **all of `dist/`**, preserving its directory structure, and import +`/web-terminal/index.js` from a module script. `dist/web-terminal.js` also remains +available for relative static imports. Package consumers should import only from +`@hex1b/web-terminal`; internal protocol/renderer modules are not public exports. + +The worker is created with +`new Worker(new URL("./terminal-worker.js", import.meta.url), { type: "module" })`. +The default font is resolved relative to its module, not the host page. +Bundlers differ in whether they discover and rewrite assets inside dependencies; +this package does not claim universal or individually verified bundler support. +The reliable static deployment layout is the complete emitted tree described +above. + +If your bundler does not handle the dependency's worker URL, explicitly provide +the entry point you deployed: + +```ts +const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", + workerUrl: "/web-terminal/terminal-worker.js" +}); +``` + +`workerUrl` accepts a nonempty string or URL and resolves relative strings against +the page, not the package module. It still creates a **module** worker. Deploy its +complete relative module tree, or provide a separately bundled worker entry from +the same package build. The override does not automatically copy fonts: retain +the default font asset URL or provide explicit `font.faces` URLs as shown below. +The browser's worker origin and CSP restrictions still apply. + +Configure your server's JavaScript and WOFF2 MIME types and CSP to allow these +workers, fonts, and the intended WebSocket endpoint. + +## Configuration and state + +`WebTerminalOptions` includes: + +| Option | Meaning | +| --- | --- | +| `workerUrl` | Optional module-worker entry; useful when worker assets are deployed separately. | +| `scale` | GPU backing scale `0.5`–`3`, or `"auto"` (default, bounded device pixel ratio). | +| `font` | One family and optional downloadable font faces; see below. | +| `sizing` | `{ mode: "auto", fontSize?: number }` or `{ mode: "fixed", columns, rows, fontSize?: number }`. | +| `readOnly` | Disable application input while retaining history inspection and selection. | +| `label` | Accessible label for the terminal's hidden keyboard input. | +| `inputBindings`, `onInput`, `actions` | Per-view input policy and custom actions. | +| `onSelectionUI` | Synchronous, cancelable UI notification hook. | + +Font size is an integer from 8–32, defaulting to 16. Import `MIN_FONT_SIZE` and +`MAX_FONT_SIZE` from `@hex1b/web-terminal` for sizing controls. Requested fixed grids allow +20–300 columns and 10–100 rows. The producer still owns actual grid geometry. +`resize()`, `setSizing()`, and automatic resize requests require primary +ownership. `requestPrimary()` explicitly requests ownership; inspect `peer` or +`onRoleChange` to observe the result. + +The handle exposes `geometry`, `peer`, `connected`, `stats`, `screenText`, +`sizing`, `viewport`, `selection`, `inputBindings`, and `inputContext`. +Metrics start empty; check optional fields before using them. History may be +unavailable, and selection can be unavailable, none, pending, valid, or +invalidated. Narrow `viewport.available` and `selection.status` before using +their state-specific values. `screenText` reflects the presented viewport, not +an independently reconstructed ANSI buffer. + +Callbacks include `onGeometry`, `onRoleChange`, `onSizingChange`, `onStats`, +`onViewportChange`, `onSelectionChange`, `onStatus`, and `onInputError`. + +## Input and clipboard + +Import `InputRoute`, `TerminalAction`, and `defaultInputBindings` to inspect and +customize routing. Defaults preserve browser shortcuts, forward terminal keys, +copy with Cmd+C or Ctrl+Shift+C, and use a local right-click to copy or paste +when application mouse capture does not own that gesture. IME composition and +paste are forwarded through the producer's mode-aware input encoder. + +Overrides match first. Reuse a default binding's ID to replace it, or specify +`{ id: "clipboard.context-click", remove: true }` to remove that default. +`match`, `when`, and `onInput` must finish synchronously. Actions may be async. + +```ts +import { InputRoute, TerminalAction, type WebTerminalOptions } from "@hex1b/web-terminal"; + +const options: WebTerminalOptions = { + url: "/ws/terminal", + inputBindings: [ + { + id: "history.previous-page", + match: input => input.type === "key" && input.key === "PageUp" && input.shift, + action: TerminalAction.ScrollLines, + args: -20 + }, + { id: "clipboard.context-click", remove: true } + ], + onInput(input) { + if (input.type === "key" && input.meta) return InputRoute.Browser; + return InputRoute.Continue; + } +}; +``` + +Named actions are `copySelection`, `pasteClipboard`, `copyOrPaste`, +`clearSelection`, `scrollToLive`, and `scrollLines`. +`terminal.runAction(TerminalAction.ScrollLines, -20)` shares the same +implementation as bindings and UI controls. Custom `actions` receive +`(context, args, input)`; their argument/result types are `unknown`, so custom +handlers validate their own data. Built-in action names cannot be overridden. + +You can also call `scrollLines()`, `scrollToLive()`, `clearSelection()`, +`copySelection({ clear: true })`, `paste(text)`, or `pasteClipboard()` directly. +Copy uses authoritative producer selection text, not rendered cells. Clipboard +actions reject if selection/input/focus changes before their asynchronous work +can be applied safely. Errors are surfaced rather than silently reported as +successful copies or pastes. + +## Selection UI hooks + +`onSelectionUI` receives a typed `SelectionUIEvent`, also dispatched as the +`selectionui` DOM event on `terminal.element`. Its frozen detail includes +selection, viewport, geometry, canvas size, connection/read-only state, and: + +- `overlay`: a stable light-DOM host for custom UI; style your controls and set + `pointer-events: auto` on interactive descendants. +- `rects`: selection rectangles in overlay-local CSS pixels. +- `runAction`: the same typed action API as the terminal handle. +- `signal`: cleanup lifetime, aborted on disposal. + +Call `event.preventDefault()` **synchronously** to replace the default Copy +button. This does not remove selection highlights or transfer ownership of +selection/clipboard state. The callback must return `undefined`, not a Promise. +Events coalesce meaningful changes; `refreshSelectionUI()` re-notifies hosts +after external styling or policy changes. External DOM listeners may also +cancel the default UI. + +Inspection UI inherits the embedding page's `--cp-*` theme tokens and otherwise +uses its own light/dark defaults. Shadow parts include `selection-highlights`, +`selection-highlight`, and `selection-copy-button`. + +## Fonts and licenses + +The default is the bundled **Cascadia Mono NF** variable WOFF2 font. The package +includes the unmodified font, SIL Open Font License, and provenance in +`dist/fonts/cascadia-mono-nf/`; no sample assets are required. + +```ts +const font = { + family: "My Terminal Font", + faces: [{ url: "/fonts/my-terminal.woff2", weight: "100 900", style: "normal" }] +}; +``` + +Custom face URLs resolve against the host page before the configuration reaches +the worker. Page-loaded fonts are not inherited by workers: supply face URLs, +a locally installed family, or a generic family such as `monospace`. A generic +family cannot have downloadable faces. Font loading failures reject rather +than silently selecting a different font. + +Hex1b's code is MIT licensed (`LICENSE`); the bundled font uses its separate +SIL Open Font License. + +## Build, test, and pack + +From this package directory, with Node.js 22 or later: + +```sh +npm ci +npm run build +npm test +npm pack +``` + +The strict TypeScript build emits JavaScript, declarations, declaration maps, +and source maps (with embedded sources), then copies fonts into `dist/`. +`npm test` runs zero-dependency `node:test` tests against those emitted modules +and strict public-consumer declaration checks in NodeNext and bundler modes. +`npm run typecheck` validates sources without emitting. + +`prepack` rebuilds for `npm pack` and manual `npm publish` from this directory. +Only `dist/`, this README, the MIT license, and package metadata are shipped. +A prepared tarball is self-contained and can be published with +`npm publish ./hex1b-web-terminal-.tgz --ignore-scripts`; it does not +need development sources or build scripts. The package name is always +`@hex1b/web-terminal`, including GitHub Packages. Registry selection is left to +the caller; no registry is pinned in `package.json`. diff --git a/src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2 b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2 similarity index 100% rename from src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2 rename to src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2 diff --git a/src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf/LICENSE.txt b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/LICENSE.txt similarity index 100% rename from src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf/LICENSE.txt rename to src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/LICENSE.txt diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/README.md b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/README.md new file mode 100644 index 00000000000..cacff43753b --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/README.md @@ -0,0 +1,29 @@ +# Cascadia Mono NF + +The web terminal's default font is Microsoft's Cascadia Mono with Nerd Font +symbols and without programming ligatures. The font file is unmodified. + +- Upstream: +- Release: [v2407.24](https://github.com/microsoft/cascadia-code/releases/tag/v2407.24) +- Upstream release path: `woff2/CascadiaMonoNF.woff2` +- Retrieved from Aspire's copy at commit + [`1dd4584e3df56f5544a3e7f5fda8aa767f92318e`](https://github.com/microsoft/aspire/tree/1dd4584e3df56f5544a3e7f5fda8aa767f92318e/src/Aspire.Dashboard/wwwroot/fonts/cascadia-mono-nf) +- Size: 976,460 bytes +- SHA-256: `bd42b0c992de9c42d8a770112e8140d67d6437798be39d49e57f4954e2f6e8e2` + +## License and redistribution + +Copyright (c) 2019 - Present, Microsoft Corporation, +with Reserved Font Name Cascadia Code. + +The font is licensed under the **SIL Open Font License 1.1**, not Hex1b's code +license. The full, unmodified license and copyright notice are in +[`LICENSE.txt`](LICENSE.txt). Include that notice and license whenever +redistributing the font, including future embedded-resource or npm packaging. +OFL permits bundling and embedding alongside software; the font cannot be sold +on its own. Modified versions must respect the reserved-font-name and other +OFL conditions. + +To update, take the unmodified WOFF2 from an official upstream release, retain +the accompanying license, and update the version, provenance, size, and hash +above. Do not subset or patch the asset as an incidental packaging step. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.d.ts new file mode 100644 index 00000000000..a29e024d576 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.d.ts @@ -0,0 +1,27 @@ +/** Commands contain producer identities; text and ranges are never inferred from painted cells. */ +export declare class HistoryState { + #private; + constructor(send: (command: TerminalCommand) => void, change?: () => void); + get viewport(): HistoryViewport; + get selection(): HistorySelectionState; + accept(history: HistoryMetadata | null, revision: number): boolean; + begin(point: TerminalPoint, { mode, extend }: { + mode: SelectionMode; + extend?: boolean; + }): void; + extend(point: TerminalPoint): void; + scroll(delta: number, endpoint?: TerminalPoint): void; + live(): void; + clear(): void; + endGesture(cancelled?: boolean): void; + cancelCopy(error: unknown): void; + copy(): Promise; + disconnect(): void; +} +import type { SelectionMode, TerminalPoint, TerminalSelection, TerminalViewport } from "./types.js"; +import type { HistoryMetadata, TerminalCommand } from "./wire-types.js"; +type OmitEach = T extends unknown ? Omit : never; +type HistoryViewport = OmitEach; +type HistorySelectionState = OmitEach; +export {}; +//# sourceMappingURL=history-state.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.d.ts.map new file mode 100644 index 00000000000..deb455afaa7 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"history-state.d.ts","sourceRoot":"","sources":["../src/history-state.ts"],"names":[],"mappings":"AAIA,mGAAmG;AACnG,qBAAa,YAAY;;gBAaX,IAAI,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,EAAE,MAAM,GAAE,MAAM,IAAe;IAKnF,IAAI,QAAQ,IAAI,eAAe,CAK9B;IAED,IAAI,SAAS,IAAI,qBAAqB,CAYrC;IAED,MAAM,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO;IAuDlE,KAAK,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,IAAI,EAAE,MAAc,EAAE,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI;IAStG,MAAM,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI;IAalC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,GAAG,IAAI;IAcrD,IAAI;IAQJ,KAAK;IAQL,UAAU,CAAC,SAAS,UAAQ;IAc5B,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI;IAEhC,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC;IAyBvB,UAAU;CAIX;AACD,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACpG,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAExE,KAAK,QAAQ,CAAC,CAAC,EAAE,CAAC,SAAS,WAAW,IAAI,CAAC,SAAS,OAAO,GAAG,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,KAAK,CAAC;AACjF,KAAK,eAAe,GAAG,QAAQ,CAAC,gBAAgB,EAAE,YAAY,GAAG,QAAQ,CAAC,CAAC;AAC3E,KAAK,qBAAqB,GAAG,QAAQ,CAAC,iBAAiB,EAAE,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,CAAC,CAAC"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.js new file mode 100644 index 00000000000..a625549f08e --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.js @@ -0,0 +1,194 @@ +const unavailable = "Text history is not available for this terminal view"; +const expired = "Selection expired: its text was changed, evicted, reset, or resized. Select the text again."; +const changedBeforeCopy = "Selection changed before copy completed. Copy the new selection again."; +/** Commands contain producer identities; text and ranges are never inferred from painted cells. */ +export class HistoryState { + #send; + #change; + #history = null; + #revision = 0; + #nextRequest = 0; + #selectionRequest = 0; + #viewportRequest = 0; + #pendingMode = "character"; + #pendingAction = "clear"; + #pendingCopy; + #deferredEndpoint; + constructor(send, change = () => { }) { + this.#send = send; + this.#change = change; + } + get viewport() { + if (!this.#history) + return { available: false, following: true, pending: false }; + const { selection, copy, ...viewport } = this.#history; + return { ...viewport, rowIds: [...viewport.rowIds], available: true, revision: this.#revision, + pending: viewport.requestId < this.#viewportRequest }; + } + get selection() { + if (!this.#history) + return { status: "unavailable", mode: "character", ranges: [], text: null, message: unavailable }; + const selection = this.#history.selection; + if (selection.requestId < this.#selectionRequest || this.#deferredEndpoint) { + return { requestId: this.#selectionRequest, status: "pending", mode: this.#pendingMode, + canExtend: this.#pendingAction !== "clear", + ranges: this.#pendingAction === "clear" ? [] : selection.ranges.map(range => ({ ...range })), text: null, + message: "Resolving selection…" }; + } + return { ...selection, canExtend: selection.status === "valid", + ranges: selection.ranges.map(range => ({ ...range })), revision: this.#revision, + message: selection.status === "invalidated" ? expired : "" }; + } + accept(history, revision) { + if (revision <= this.#revision) + return false; + const previousGeneration = this.#history?.generation; + this.#history = history; + this.#revision = revision; + if (!history || (previousGeneration && previousGeneration !== history.generation) || + history.selection.status === "invalidated") { + this.endGesture(true); + this.#rejectCopy(new Error(expired)); + } + const pending = this.#pendingCopy; + if (pending && history?.copy?.requestId === pending.requestId) { + if (history.copy.status !== "valid" || history.selection.status !== "valid" || + history.selection.requestId !== pending.selectionRequestId || + history.generation !== pending.generation || this.#selectionRequest > pending.selectionRequestId || + history.copy.text !== history.selection.text) { + this.#rejectCopy(new Error(expired)); + } + else { + clearTimeout(pending.timer); + this.#pendingCopy = undefined; + pending.resolve(history.copy.text); + } + } + if (this.#deferredEndpoint && !this.viewport.pending) { + const point = this.#deferredEndpoint; + this.#deferredEndpoint = undefined; + this.extend(point); + } + this.#change(); + return true; + } + #requireHistory() { + if (!this.#history) + throw new Error(unavailable); + return this.#history; + } + #pointRowId(point) { + const history = this.#requireHistory(); + if (!point || !Number.isInteger(point.x) || point.x < 0 || point.x > 1023 || + !Number.isInteger(point.y) || point.y < 0 || + typeof history.rowIds[point.y] !== "string" || !history.rowIds[point.y].length) { + throw new Error("Terminal viewport is not ready for that selection point. Wait for the next frame and try again."); + } + return history.rowIds[point.y]; + } + #selectionChanged(mode = this.selection.mode, action = "extend") { + this.#rejectCopy(new Error(changedBeforeCopy)); + this.#pendingMode = mode; + this.#pendingAction = action; + this.#selectionRequest = ++this.#nextRequest; + return this.#selectionRequest; + } + begin(point, { mode, extend = false }) { + const rowId = this.#pointRowId(point); + this.#deferredEndpoint = undefined; + const requestId = this.#selectionChanged(mode, extend ? "extend" : "start"); + this.#send({ type: "selection", action: extend ? "extend" : "start", mode, requestId, + generation: this.#requireHistory().generation, rowId, column: point.x }); + this.#change(); + } + extend(point) { + this.#pointRowId(point); + if (this.viewport.pending) { + this.#rejectCopy(new Error(changedBeforeCopy)); + this.#pendingMode = this.selection.mode; + this.#pendingAction = "extend"; + this.#deferredEndpoint = { ...point }; + this.#change(); + return; + } + this.begin(point, { mode: this.selection.mode, extend: true }); + } + scroll(delta, endpoint) { + this.#requireHistory(); + if (!Number.isSafeInteger(delta) || delta < -2147483648 || delta > 2147483647) + throw new RangeError("Scroll delta must be a signed 32-bit integer"); + if (endpoint) + this.#pointRowId(endpoint); + if (!delta && !endpoint) + return; + if (endpoint) + this.#deferredEndpoint = undefined; + else + this.#flushDeferredEndpoint(); + const requestId = endpoint ? this.#selectionChanged() : ++this.#nextRequest; + this.#viewportRequest = requestId; + this.#send({ type: "viewport", requestId, delta, + ...(endpoint ? { extend: { row: endpoint.y, column: endpoint.x } } : {}) }); + this.#change(); + } + live() { + this.#requireHistory(); + this.#flushDeferredEndpoint(); + this.#viewportRequest = ++this.#nextRequest; + this.#send({ type: "viewport", live: true, requestId: this.#viewportRequest }); + this.#change(); + } + clear() { + this.#requireHistory(); + this.#deferredEndpoint = undefined; + const requestId = this.#selectionChanged(this.selection.mode, "clear"); + this.#send({ type: "selection", action: "clear", requestId }); + this.#change(); + } + endGesture(cancelled = false) { + if (cancelled && this.#deferredEndpoint) { + this.#deferredEndpoint = undefined; + this.#change(); + } + } + #flushDeferredEndpoint() { + if (!this.#deferredEndpoint) + return; + const endpoint = this.#deferredEndpoint; + this.#deferredEndpoint = undefined; + // Preserve command order if another scroll overtakes the pending presentation. + this.scroll(0, endpoint); + } + cancelCopy(error) { this.#rejectCopy(error); } + copy() { + const history = this.#requireHistory(); + const selection = this.selection; + if (selection.status !== "valid") + throw new Error(selection.message || "Select text before copying."); + this.#rejectCopy(new Error("A newer copy request replaced this request.")); + const requestId = ++this.#nextRequest; + const { promise, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => this.#rejectCopy(new Error("Timed out resolving selection. Copy again.")), 10000); + this.#pendingCopy = { requestId, selectionRequestId: selection.requestId, generation: history.generation, + resolve, reject, timer }; + try { + this.#send({ type: "copy", requestId, selectionRequestId: selection.requestId, generation: history.generation }); + } + catch (error) { + this.#rejectCopy(error); + } + return promise; + } + #rejectCopy(error) { + if (!this.#pendingCopy) + return; + clearTimeout(this.#pendingCopy.timer); + this.#pendingCopy.reject(error); + this.#pendingCopy = undefined; + } + disconnect() { + this.#rejectCopy(new Error("Terminal view disconnected before copy completed.")); + this.#deferredEndpoint = undefined; + } +} +//# sourceMappingURL=history-state.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.js.map new file mode 100644 index 00000000000..a2236812fc1 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/history-state.js.map @@ -0,0 +1 @@ +{"version":3,"file":"history-state.js","sourceRoot":"","sources":["../src/history-state.ts"],"names":[],"mappings":"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","sourcesContent":["const unavailable = \"Text history is not available for this terminal view\";\nconst expired = \"Selection expired: its text was changed, evicted, reset, or resized. Select the text again.\";\nconst changedBeforeCopy = \"Selection changed before copy completed. Copy the new selection again.\";\n\n/** Commands contain producer identities; text and ranges are never inferred from painted cells. */\nexport class HistoryState {\n #send: (command: TerminalCommand) => void;\n #change: () => void;\n #history: HistoryMetadata | null = null;\n #revision = 0;\n #nextRequest = 0;\n #selectionRequest = 0;\n #viewportRequest = 0;\n #pendingMode: SelectionMode = \"character\";\n #pendingAction = \"clear\";\n #pendingCopy: PendingCopy | undefined;\n #deferredEndpoint: TerminalPoint | undefined;\n\n constructor(send: (command: TerminalCommand) => void, change: () => void = () => {}) {\n this.#send = send;\n this.#change = change;\n }\n\n get viewport(): HistoryViewport {\n if (!this.#history) return { available: false, following: true, pending: false };\n const { selection, copy, ...viewport } = this.#history;\n return { ...viewport, rowIds: [...viewport.rowIds], available: true, revision: this.#revision,\n pending: viewport.requestId < this.#viewportRequest };\n }\n\n get selection(): HistorySelectionState {\n if (!this.#history) return { status: \"unavailable\", mode: \"character\", ranges: [], text: null, message: unavailable };\n const selection = this.#history.selection;\n if (selection.requestId < this.#selectionRequest || this.#deferredEndpoint) {\n return { requestId: this.#selectionRequest, status: \"pending\", mode: this.#pendingMode,\n canExtend: this.#pendingAction !== \"clear\",\n ranges: this.#pendingAction === \"clear\" ? [] : selection.ranges.map(range => ({ ...range })), text: null,\n message: \"Resolving selection…\" };\n }\n return { ...selection, canExtend: selection.status === \"valid\",\n ranges: selection.ranges.map(range => ({ ...range })), revision: this.#revision,\n message: selection.status === \"invalidated\" ? expired : \"\" };\n }\n\n accept(history: HistoryMetadata | null, revision: number): boolean {\n if (revision <= this.#revision) return false;\n const previousGeneration = this.#history?.generation;\n this.#history = history;\n this.#revision = revision;\n if (!history || (previousGeneration && previousGeneration !== history.generation) ||\n history.selection.status === \"invalidated\") {\n this.endGesture(true);\n this.#rejectCopy(new Error(expired));\n }\n const pending = this.#pendingCopy;\n if (pending && history?.copy?.requestId === pending.requestId) {\n if (history.copy.status !== \"valid\" || history.selection.status !== \"valid\" ||\n history.selection.requestId !== pending.selectionRequestId ||\n history.generation !== pending.generation || this.#selectionRequest > pending.selectionRequestId ||\n history.copy.text !== history.selection.text) {\n this.#rejectCopy(new Error(expired));\n } else {\n clearTimeout(pending.timer);\n this.#pendingCopy = undefined;\n pending.resolve(history.copy.text);\n }\n }\n if (this.#deferredEndpoint && !this.viewport.pending) {\n const point = this.#deferredEndpoint;\n this.#deferredEndpoint = undefined;\n this.extend(point);\n }\n this.#change();\n return true;\n }\n\n #requireHistory(): HistoryMetadata {\n if (!this.#history) throw new Error(unavailable);\n return this.#history;\n }\n\n #pointRowId(point: TerminalPoint): string {\n const history = this.#requireHistory();\n if (!point || !Number.isInteger(point.x) || point.x < 0 || point.x > 1023 ||\n !Number.isInteger(point.y) || point.y < 0 ||\n typeof history.rowIds[point.y] !== \"string\" || !history.rowIds[point.y].length) {\n throw new Error(\"Terminal viewport is not ready for that selection point. Wait for the next frame and try again.\");\n }\n return history.rowIds[point.y];\n }\n\n #selectionChanged(mode = this.selection.mode, action = \"extend\") {\n this.#rejectCopy(new Error(changedBeforeCopy));\n this.#pendingMode = mode;\n this.#pendingAction = action;\n this.#selectionRequest = ++this.#nextRequest;\n return this.#selectionRequest;\n }\n\n begin(point: TerminalPoint, { mode, extend = false }: { mode: SelectionMode; extend?: boolean }): void {\n const rowId = this.#pointRowId(point);\n this.#deferredEndpoint = undefined;\n const requestId = this.#selectionChanged(mode, extend ? \"extend\" : \"start\");\n this.#send({ type: \"selection\", action: extend ? \"extend\" : \"start\", mode, requestId,\n generation: this.#requireHistory().generation, rowId, column: point.x });\n this.#change();\n }\n\n extend(point: TerminalPoint): void {\n this.#pointRowId(point);\n if (this.viewport.pending) {\n this.#rejectCopy(new Error(changedBeforeCopy));\n this.#pendingMode = this.selection.mode;\n this.#pendingAction = \"extend\";\n this.#deferredEndpoint = { ...point };\n this.#change();\n return;\n }\n this.begin(point, { mode: this.selection.mode, extend: true });\n }\n\n scroll(delta: number, endpoint?: TerminalPoint): void {\n this.#requireHistory();\n if (!Number.isSafeInteger(delta) || delta < -2147483648 || delta > 2147483647) throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n if (endpoint) this.#pointRowId(endpoint);\n if (!delta && !endpoint) return;\n if (endpoint) this.#deferredEndpoint = undefined;\n else this.#flushDeferredEndpoint();\n const requestId = endpoint ? this.#selectionChanged() : ++this.#nextRequest;\n this.#viewportRequest = requestId;\n this.#send({ type: \"viewport\", requestId, delta,\n ...(endpoint ? { extend: { row: endpoint.y, column: endpoint.x } } : {}) });\n this.#change();\n }\n\n live() {\n this.#requireHistory();\n this.#flushDeferredEndpoint();\n this.#viewportRequest = ++this.#nextRequest;\n this.#send({ type: \"viewport\", live: true, requestId: this.#viewportRequest });\n this.#change();\n }\n\n clear() {\n this.#requireHistory();\n this.#deferredEndpoint = undefined;\n const requestId = this.#selectionChanged(this.selection.mode, \"clear\");\n this.#send({ type: \"selection\", action: \"clear\", requestId });\n this.#change();\n }\n\n endGesture(cancelled = false) {\n if (cancelled && this.#deferredEndpoint) {\n this.#deferredEndpoint = undefined;\n this.#change();\n }\n }\n\n #flushDeferredEndpoint() {\n if (!this.#deferredEndpoint) return;\n const endpoint = this.#deferredEndpoint;\n this.#deferredEndpoint = undefined;\n // Preserve command order if another scroll overtakes the pending presentation.\n this.scroll(0, endpoint);\n }\n cancelCopy(error: unknown): void { this.#rejectCopy(error); }\n\n copy(): Promise {\n const history = this.#requireHistory();\n const selection = this.selection;\n if (selection.status !== \"valid\") throw new Error(selection.message || \"Select text before copying.\");\n this.#rejectCopy(new Error(\"A newer copy request replaced this request.\"));\n const requestId = ++this.#nextRequest;\n const { promise, resolve, reject } = Promise.withResolvers();\n const timer = setTimeout(() => this.#rejectCopy(new Error(\"Timed out resolving selection. Copy again.\")), 10000);\n this.#pendingCopy = { requestId, selectionRequestId: selection.requestId, generation: history.generation,\n resolve, reject, timer };\n try {\n this.#send({ type: \"copy\", requestId, selectionRequestId: selection.requestId, generation: history.generation });\n } catch (error) {\n this.#rejectCopy(error);\n }\n return promise;\n }\n\n #rejectCopy(error: unknown): void {\n if (!this.#pendingCopy) return;\n clearTimeout(this.#pendingCopy.timer);\n this.#pendingCopy.reject(error);\n this.#pendingCopy = undefined;\n }\n\n disconnect() {\n this.#rejectCopy(new Error(\"Terminal view disconnected before copy completed.\"));\n this.#deferredEndpoint = undefined;\n }\n}\nimport type { SelectionMode, TerminalPoint, TerminalSelection, TerminalViewport } from \"./types.js\";\nimport type { HistoryMetadata, TerminalCommand } from \"./wire-types.js\";\n\ntype OmitEach = T extends unknown ? Omit : never;\ntype HistoryViewport = OmitEach;\ntype HistorySelectionState = OmitEach;\ninterface PendingCopy {\n requestId: number; selectionRequestId: number; generation: string;\n resolve: (text: string) => void; reject: (error: unknown) => void;\n timer: ReturnType;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts new file mode 100644 index 00000000000..c403b566328 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts @@ -0,0 +1,5 @@ +export { WebTerminal } from "./web-terminal.js"; +export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; +export { MIN_FONT_SIZE, MAX_FONT_SIZE } from "./terminal-sizing.js"; +export type * from "./types.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map new file mode 100644 index 00000000000..33ceb329e3d --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACpE,mBAAmB,YAAY,CAAC"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js new file mode 100644 index 00000000000..dd3306b5dbb --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js @@ -0,0 +1,4 @@ +export { WebTerminal } from "./web-terminal.js"; +export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; +export { MIN_FONT_SIZE, MAX_FONT_SIZE } from "./terminal-sizing.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map new file mode 100644 index 00000000000..191573565b2 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC","sourcesContent":["export { WebTerminal } from \"./web-terminal.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\nexport { MIN_FONT_SIZE, MAX_FONT_SIZE } from \"./terminal-sizing.js\";\nexport type * from \"./types.js\";\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.d.ts new file mode 100644 index 00000000000..8830c332806 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.d.ts @@ -0,0 +1,42 @@ +export declare const InputRoute: Readonly<{ + Continue: "continue"; + Consume: "consume"; + Application: "application"; + Browser: "browser"; +}>; +export declare const TerminalAction: Readonly<{ + CopySelection: "copySelection"; + PasteClipboard: "pasteClipboard"; + CopyOrPaste: "copyOrPaste"; + ClearSelection: "clearSelection"; + ScrollToLive: "scrollToLive"; + ScrollLines: "scrollLines"; +}>; +/** Fresh, inspectable defaults. Overrides are per mounted view, never global. */ +export declare function defaultInputBindings(): InputBinding[]; +/** Resolves intent only. DOM cancellation and action execution belong to the mounted view. */ +export declare class InputPolicy { + #private; + constructor({ inputBindings, onInput, actions }?: InputPolicyOptions); + get bindings(): ({ + id: string; + match: (input: Readonly, context: TerminalInputContext) => boolean; + when?: (context: TerminalInputContext, input: Readonly) => boolean; + remove?: false; + route: InputRouteValue; + action?: never; + args?: never; + } | { + id: string; + match: (input: Readonly, context: TerminalInputContext) => boolean; + when?: (context: TerminalInputContext, input: Readonly) => boolean; + remove?: false; + action: string | InputActionHandler; + args?: unknown; + route?: never; + })[]; + resolve(input: Readonly, context: TerminalInputContext): InputDecision; +} +export declare function inputModifiers(event: Pick): InputModifiers; +import type { InputActionHandler, InputBinding, InputDecision, InputModifiers, InputPolicyOptions, InputRouteValue, TerminalInput, TerminalInputContext } from "./types.js"; +//# sourceMappingURL=input-policy.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.d.ts.map new file mode 100644 index 00000000000..4d10b207adb --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"input-policy.d.ts","sourceRoot":"","sources":["../src/input-policy.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,UAAU;;;;;EAErB,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;EAGzB,CAAC;AASH,iFAAiF;AACjF,wBAAgB,oBAAoB,IAAI,YAAY,EAAE,CA2BrD;AAOD,8FAA8F;AAC9F,qBAAa,WAAW;;gBAKV,EAAE,aAAkB,EAAE,OAAO,EAAE,OAAY,EAAE,GAAE,kBAAuB;IAgClF,IAAI,QAAQ;;;;;;;;;;;;;;;;SAA8D;IAkB1E,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,EAAE,oBAAoB,GAAG,aAAa;CAoBtF;AAUD,wBAAgB,cAAc,CAAC,KAAK,EAAE,IAAI,CAAC,aAAa,EAAE,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC,GAAG,cAAc,CAExH;AACD,OAAO,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAwB,aAAa,EAAE,cAAc,EACjG,kBAAkB,EAAE,eAAe,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.js new file mode 100644 index 00000000000..0212fa5c401 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.js @@ -0,0 +1,143 @@ +export const InputRoute = Object.freeze({ + Continue: "continue", Consume: "consume", Application: "application", Browser: "browser" +}); +export const TerminalAction = Object.freeze({ + CopySelection: "copySelection", PasteClipboard: "pasteClipboard", CopyOrPaste: "copyOrPaste", + ClearSelection: "clearSelection", ScrollToLive: "scrollToLive", ScrollLines: "scrollLines" +}); +const browserCtrlKeys = new Set(["l", "t", "v", "w", "+", "-", "=", "0"]); +const browserFunctionKeys = new Set(["F1", "F3", "F5", "F6", "F7", "F10", "F11", "F12"]); +const terminalKeys = new Set(["Tab", "Enter", "Backspace", "Delete", "Escape", "ArrowUp", "ArrowDown", + "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Insert", + ...Array.from({ length: 12 }, (_, index) => `F${index + 1}`)]); +const routes = new Set(Object.values(InputRoute)); +/** Fresh, inspectable defaults. Overrides are per mounted view, never global. */ +export function defaultInputBindings() { + return [ + { + id: "clipboard.copy-key", + match: input => input.type === "key" && input.key.toLowerCase() === "c" && !input.alt && + ((input.meta && !input.ctrl) || (input.ctrl && input.shift && !input.meta)), + action: TerminalAction.CopySelection + }, + { + id: "browser.shortcuts", + match: input => input.type === "key" && (input.meta || browserFunctionKeys.has(input.key) || + (input.ctrl && (input.key === "Tab" || input.key === "F4" || browserCtrlKeys.has(input.key.toLowerCase()) || + (input.shift && input.key.length === 1))) || (input.alt && !input.ctrl)), + route: InputRoute.Browser + }, + { + id: "terminal.keys", + match: input => input.type === "key" && (terminalKeys.has(input.key) || (input.ctrl && input.key.length === 1)), + route: InputRoute.Application + }, + { + id: "clipboard.context-click", + match: (input, context) => input.type === "pointer" && input.button === "right" && !input.meta && + (!context.mouseCaptured || input.shift || context.historical || context.readOnly), + action: TerminalAction.CopyOrPaste + } + ]; +} +function synchronous(callback, name) { + if (typeof callback !== "function" || callback.constructor.name === "AsyncFunction") + throw new TypeError(`${name} must be a synchronous function`); +} +/** Resolves intent only. DOM cancellation and action execution belong to the mounted view. */ +export class InputPolicy { + #bindings; + #intercept; + #actions; + constructor({ inputBindings = [], onInput, actions = {} } = {}) { + if (!Array.isArray(inputBindings)) + throw new TypeError("inputBindings must be an array"); + if (!actions || typeof actions !== "object" || Array.isArray(actions)) + throw new TypeError("actions must be an object"); + this.#actions = new Set(Object.values(TerminalAction)); + for (const [name, handler] of Object.entries(actions)) { + if (!name || this.#actions.has(name) || typeof handler !== "function") + throw new TypeError(`Invalid or reserved action: ${name}`); + this.#actions.add(name); + } + if (onInput !== undefined) + synchronous(onInput, "onInput"); + this.#intercept = onInput; + const defaults = defaultInputBindings(); + const ids = new Set(); + const overrides = inputBindings.map((binding) => { + if (!binding || typeof binding.id !== "string" || !binding.id || ids.has(binding.id)) + throw new TypeError("Each input binding needs a unique, nonempty id"); + ids.add(binding.id); + if (binding.remove === true) { + if (!defaults.some(item => item.id === binding.id)) + throw new TypeError(`Cannot remove unknown default binding: ${binding.id}`); + if (Object.keys(binding).some(key => !["id", "remove"].includes(key))) + throw new TypeError(`Removed binding ${binding.id} must contain only id and remove`); + return null; + } + synchronous(binding.match, `Binding ${binding.id}.match`); + if (binding.when !== undefined) + synchronous(binding.when, `Binding ${binding.id}.when`); + this.#decision(binding); + return Object.freeze({ ...binding }); + }).filter((binding) => binding !== null); + this.#bindings = [...overrides, ...defaults.filter(binding => !ids.has(binding.id))]; + } + get bindings() { return this.#bindings.map(binding => ({ ...binding })); } + #decision(value) { + if (isRoute(value)) + return { route: value }; + if (!isRecord(value) || typeof value.then === "function") + throw new TypeError("Input routing must synchronously return a route or action"); + const hasAction = Object.hasOwn(value, "action"); + const hasRoute = Object.hasOwn(value, "route"); + if (hasAction === hasRoute) + throw new TypeError("Specify exactly one action or route"); + if (hasRoute) { + if (!isRoute(value.route)) + throw new TypeError(`Unknown input route: ${String(value.route)}`); + return { route: value.route }; + } + if (!isActionHandler(value.action) && (typeof value.action !== "string" || !this.#actions.has(value.action))) + throw new TypeError(`Unknown terminal action: ${String(value.action)}`); + return { action: value.action, args: value.args }; + } + resolve(input, context) { + const intercepted = this.#intercept?.(input, context); + if (intercepted !== undefined) { + const decision = this.#decision(intercepted); + if (decision.route !== InputRoute.Continue) + return decision; + } + for (const binding of this.#bindings) { + const matched = binding.match(input, context); + if (typeof matched !== "boolean") + throw new TypeError(`Binding ${binding.id}.match must return a boolean`); + if (!matched) + continue; + if (binding.when) { + const enabled = binding.when(context, input); + if (typeof enabled !== "boolean") + throw new TypeError(`Binding ${binding.id}.when must return a boolean`); + if (!enabled) + continue; + } + const decision = this.#decision(binding); + if (decision.route !== InputRoute.Continue) + return decision; + } + return { route: InputRoute.Continue }; + } +} +function isRoute(value) { + return typeof value === "string" && [...routes].some(route => route === value); +} +function isActionHandler(value) { + return typeof value === "function"; +} +export function inputModifiers(event) { + return { ctrl: !!event.ctrlKey, alt: !!event.altKey, shift: !!event.shiftKey, meta: !!event.metaKey }; +} +import { isRecord } from "./validation.js"; +//# sourceMappingURL=input-policy.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.js.map new file mode 100644 index 00000000000..8ba35939931 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/input-policy.js.map @@ -0,0 +1 @@ +{"version":3,"file":"input-policy.js","sourceRoot":"","sources":["../src/input-policy.ts"],"names":[],"mappings":"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","sourcesContent":["export const InputRoute = Object.freeze({\n Continue: \"continue\", Consume: \"consume\", Application: \"application\", Browser: \"browser\"\n});\n\nexport const TerminalAction = Object.freeze({\n CopySelection: \"copySelection\", PasteClipboard: \"pasteClipboard\", CopyOrPaste: \"copyOrPaste\",\n ClearSelection: \"clearSelection\", ScrollToLive: \"scrollToLive\", ScrollLines: \"scrollLines\"\n});\n\nconst browserCtrlKeys = new Set([\"l\", \"t\", \"v\", \"w\", \"+\", \"-\", \"=\", \"0\"]);\nconst browserFunctionKeys = new Set([\"F1\", \"F3\", \"F5\", \"F6\", \"F7\", \"F10\", \"F11\", \"F12\"]);\nconst terminalKeys = new Set([\"Tab\", \"Enter\", \"Backspace\", \"Delete\", \"Escape\", \"ArrowUp\", \"ArrowDown\",\n \"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\", \"PageUp\", \"PageDown\", \"Insert\",\n ...Array.from({ length: 12 }, (_, index) => `F${index + 1}`)]);\nconst routes = new Set(Object.values(InputRoute));\n\n/** Fresh, inspectable defaults. Overrides are per mounted view, never global. */\nexport function defaultInputBindings(): InputBinding[] {\n return [\n {\n id: \"clipboard.copy-key\",\n match: input => input.type === \"key\" && input.key.toLowerCase() === \"c\" && !input.alt &&\n ((input.meta && !input.ctrl) || (input.ctrl && input.shift && !input.meta)),\n action: TerminalAction.CopySelection\n },\n {\n id: \"browser.shortcuts\",\n match: input => input.type === \"key\" && (input.meta || browserFunctionKeys.has(input.key) ||\n (input.ctrl && (input.key === \"Tab\" || input.key === \"F4\" || browserCtrlKeys.has(input.key.toLowerCase()) ||\n (input.shift && input.key.length === 1))) || (input.alt && !input.ctrl)),\n route: InputRoute.Browser\n },\n {\n id: \"terminal.keys\",\n match: input => input.type === \"key\" && (terminalKeys.has(input.key) || (input.ctrl && input.key.length === 1)),\n route: InputRoute.Application\n },\n {\n id: \"clipboard.context-click\",\n match: (input, context) => input.type === \"pointer\" && input.button === \"right\" && !input.meta &&\n (!context.mouseCaptured || input.shift || context.historical || context.readOnly),\n action: TerminalAction.CopyOrPaste\n }\n ];\n}\n\nfunction synchronous(callback: unknown, name: string): void {\n if (typeof callback !== \"function\" || callback.constructor.name === \"AsyncFunction\")\n throw new TypeError(`${name} must be a synchronous function`);\n}\n\n/** Resolves intent only. DOM cancellation and action execution belong to the mounted view. */\nexport class InputPolicy {\n #bindings: InputBinding[];\n #intercept: InputPolicyOptions[\"onInput\"];\n #actions: Set;\n\n constructor({ inputBindings = [], onInput, actions = {} }: InputPolicyOptions = {}) {\n if (!Array.isArray(inputBindings)) throw new TypeError(\"inputBindings must be an array\");\n if (!actions || typeof actions !== \"object\" || Array.isArray(actions)) throw new TypeError(\"actions must be an object\");\n this.#actions = new Set(Object.values(TerminalAction));\n for (const [name, handler] of Object.entries(actions)) {\n if (!name || this.#actions.has(name) || typeof handler !== \"function\")\n throw new TypeError(`Invalid or reserved action: ${name}`);\n this.#actions.add(name);\n }\n if (onInput !== undefined) synchronous(onInput, \"onInput\");\n this.#intercept = onInput;\n const defaults = defaultInputBindings();\n const ids = new Set();\n const overrides = inputBindings.map((binding: InputBindingOverride): InputBinding | null => {\n if (!binding || typeof binding.id !== \"string\" || !binding.id || ids.has(binding.id))\n throw new TypeError(\"Each input binding needs a unique, nonempty id\");\n ids.add(binding.id);\n if (binding.remove === true) {\n if (!defaults.some(item => item.id === binding.id))\n throw new TypeError(`Cannot remove unknown default binding: ${binding.id}`);\n if (Object.keys(binding).some(key => ![\"id\", \"remove\"].includes(key)))\n throw new TypeError(`Removed binding ${binding.id} must contain only id and remove`);\n return null;\n }\n synchronous(binding.match, `Binding ${binding.id}.match`);\n if (binding.when !== undefined) synchronous(binding.when, `Binding ${binding.id}.when`);\n this.#decision(binding);\n return Object.freeze({ ...binding });\n }).filter((binding): binding is InputBinding => binding !== null);\n this.#bindings = [...overrides, ...defaults.filter(binding => !ids.has(binding.id))];\n }\n\n get bindings() { return this.#bindings.map(binding => ({ ...binding })); }\n\n #decision(value: unknown): InputDecision {\n if (isRoute(value)) return { route: value };\n if (!isRecord(value) || typeof value.then === \"function\")\n throw new TypeError(\"Input routing must synchronously return a route or action\");\n const hasAction = Object.hasOwn(value, \"action\");\n const hasRoute = Object.hasOwn(value, \"route\");\n if (hasAction === hasRoute) throw new TypeError(\"Specify exactly one action or route\");\n if (hasRoute) {\n if (!isRoute(value.route)) throw new TypeError(`Unknown input route: ${String(value.route)}`);\n return { route: value.route };\n }\n if (!isActionHandler(value.action) && (typeof value.action !== \"string\" || !this.#actions.has(value.action)))\n throw new TypeError(`Unknown terminal action: ${String(value.action)}`);\n return { action: value.action, args: value.args };\n }\n\n resolve(input: Readonly, context: TerminalInputContext): InputDecision {\n const intercepted = this.#intercept?.(input, context);\n if (intercepted !== undefined) {\n const decision = this.#decision(intercepted);\n if (decision.route !== InputRoute.Continue) return decision;\n }\n for (const binding of this.#bindings) {\n const matched = binding.match(input, context);\n if (typeof matched !== \"boolean\") throw new TypeError(`Binding ${binding.id}.match must return a boolean`);\n if (!matched) continue;\n if (binding.when) {\n const enabled = binding.when(context, input);\n if (typeof enabled !== \"boolean\") throw new TypeError(`Binding ${binding.id}.when must return a boolean`);\n if (!enabled) continue;\n }\n const decision = this.#decision(binding);\n if (decision.route !== InputRoute.Continue) return decision;\n }\n return { route: InputRoute.Continue };\n }\n}\n\nfunction isRoute(value: unknown): value is InputRouteValue {\n return typeof value === \"string\" && [...routes].some(route => route === value);\n}\n\nfunction isActionHandler(value: unknown): value is InputActionHandler {\n return typeof value === \"function\";\n}\n\nexport function inputModifiers(event: Pick): InputModifiers {\n return { ctrl: !!event.ctrlKey, alt: !!event.altKey, shift: !!event.shiftKey, meta: !!event.metaKey };\n}\nimport type { InputActionHandler, InputBinding, InputBindingOverride, InputDecision, InputModifiers,\n InputPolicyOptions, InputRouteValue, TerminalInput, TerminalInputContext } from \"./types.js\";\nimport { isRecord } from \"./validation.js\";\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts new file mode 100644 index 00000000000..45950017ba1 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts @@ -0,0 +1,26 @@ +import type { GestureState } from "./selection-input.js"; +import type { InputDecision, MouseTrackingMode, SelectionMode, TerminalInput, TerminalPoint } from "./types.js"; +import type { MouseCommand } from "./wire-types.js"; +interface MouseInspection { + state?: () => Omit; + begin?: (point: TerminalPoint, selection: { + mode: SelectionMode; + extend: boolean; + }) => void; + extend?: (point: TerminalPoint) => void; + scroll?: (delta: number, endpoint?: TerminalPoint) => void; + end?: (cancelled: boolean) => void; + resolve?: (input: TerminalInput) => InputDecision; + execute?: (decision: Extract, input: TerminalInput) => void; +} +export interface MouseCapture { + update(columns: number, rows: number, tracking: MouseTrackingMode): void; + cancel(): void; + dispose(): void; +} +/** Capture input intent only; the server chooses and encodes the mouse protocol. */ +export declare function captureMouse(canvas: HTMLCanvasElement, send: (command: MouseCommand) => void, focus: () => void, inspection?: MouseInspection): MouseCapture; +export {}; +//# sourceMappingURL=mouse-input.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map new file mode 100644 index 00000000000..bcb2f604cab --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"mouse-input.d.ts","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAmB,iBAAiB,EAAiB,aAAa,EAC3F,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,KAAK,EAA6B,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAK/E,UAAU,eAAe;IACvB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAC7C,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC5F,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IACxC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3D,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,aAAa,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CACjG;AACD,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACzE,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,EAC3F,KAAK,EAAE,MAAM,IAAI,EAAE,UAAU,GAAE,eAAoB,GAAG,YAAY,CA+SnE"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js new file mode 100644 index 00000000000..d9d77d68133 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js @@ -0,0 +1,356 @@ +import { cellPoint, WheelAccumulator, SelectionGesture } from "./selection-input.js"; +import { InputRoute, inputModifiers } from "./input-policy.js"; +const buttons = [["left", 1], ["middle", 4], ["right", 2]]; +const pointerButtons = ["left", "middle", "right"]; +/** Capture input intent only; the server chooses and encodes the mouse protocol. */ +export function captureMouse(canvas, send, focus, inspection = {}) { + const listeners = new AbortController(); + const options = { signal: listeners.signal }; + let columns = 1, rows = 1; + let tracking = 0; + let pointerId = null; + const pressed = new Set(); + let lastPoint; + let pendingMove; + let lastMove; + let scheduled; + const wheel = new WheelAccumulator(); + const gesture = new SelectionGesture(); + let wheelOwner; + let pointerEvent; + let autoScroll; + let click = { count: 0, time: 0, x: -1, y: -1 }; + let selectionClick; + let completedClick; + let routedGesture; + let contextMenuRoute; + const state = () => ({ tracking, ...inspection.state?.() }); + function point(event, clamp = false) { + return cellPoint(event, canvas.getBoundingClientRect(), columns, rows, clamp); + } + function updateAutoScroll() { + clearTimeout(autoScroll); + autoScroll = undefined; + if (gesture.owner !== "local" || !pointerEvent) + return; + const bounds = canvas.getBoundingClientRect(); + const distance = pointerEvent.clientY < bounds.top ? pointerEvent.clientY - bounds.top + : pointerEvent.clientY >= bounds.bottom ? pointerEvent.clientY - bounds.bottom + 1 : 0; + if (!distance) + return; + autoScroll = setTimeout(() => { + autoScroll = undefined; + if (!pointerEvent) + return; + const position = point(pointerEvent, true); + if (gesture.owner === "local" && position) { + if (selectionClick) + selectionClick.dragged = true; + click.count = 0; + const lines = Math.sign(distance) * Math.min(8, Math.max(1, Math.ceil(Math.abs(distance) / (bounds.height / rows)))); + inspection.scroll?.(lines, gesture.scrollPoint(position)); + updateAutoScroll(); + } + }, 60); + } + function flushMove() { + if (scheduled !== undefined) + cancelAnimationFrame(scheduled); + scheduled = undefined; + if (pendingMove) + send(pendingMove); + pendingMove = undefined; + } + function changeButtons(event, position) { + for (const [button, mask] of buttons) { + const down = (event.buttons & mask) !== 0; + if (down === pressed.has(button)) + continue; + flushMove(); + if (down) + pressed.add(button); + else + pressed.delete(button); + if (down || tracking !== 9) + send({ type: "mouse", action: down ? "down" : "up", button, ...position }); + lastMove = undefined; + } + } + function cancel(report = true, cancelled = true) { + inspection.end?.(cancelled); + clearTimeout(autoScroll); + autoScroll = undefined; + pointerEvent = undefined; + selectionClick = undefined; + completedClick = undefined; + gesture.end(); + routedGesture = undefined; + if (report) + flushMove(); + else { + if (scheduled !== undefined) + cancelAnimationFrame(scheduled); + scheduled = undefined; + pendingMove = undefined; + } + if (report && tracking !== 9 && lastPoint) { + for (const button of pressed) + send({ type: "mouse", action: "up", button, ...lastPoint }); + } + pressed.clear(); + const captured = pointerId; + pointerId = null; + if (captured !== null && canvas.hasPointerCapture(captured)) + canvas.releasePointerCapture(captured); + lastMove = undefined; + wheel.reset(); + wheelOwner = undefined; + } + canvas.addEventListener("pointerdown", event => { + if (event.defaultPrevented && pointerId === null) + return; + if (event.pointerType !== "mouse" || ![0, 1, 2].includes(event.button)) + return; + const position = point(event); + if (!position) + return; + if (pointerId !== null) { + if (routedGesture !== InputRoute.Browser) + event.preventDefault(); + if ((gesture.owner === "app" || routedGesture === InputRoute.Application) && pointerId === event.pointerId) + changeButtons(event, position); + return; + } + focus(); + const input = { type: "pointer", button: pointerButtons[event.button], + point: Object.freeze({ ...position }), ...inputModifiers(event) }; + const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue }; + const route = decision.action !== undefined ? InputRoute.Consume : decision.route; + contextMenuRoute = event.button === 2 ? route : undefined; + if (route !== InputRoute.Continue) { + completedClick = selectionClick = undefined; + click.count = 0; + routedGesture = route; + pointerId = event.pointerId; + lastPoint = position; + canvas.setPointerCapture(pointerId); + if (route !== InputRoute.Browser) + event.preventDefault(); + if (route === InputRoute.Application) + flushMove(); + else { + if (scheduled !== undefined) + cancelAnimationFrame(scheduled); + scheduled = undefined; + pendingMove = undefined; + } + if (route === InputRoute.Application) + changeButtons(event, position); + if (decision.action !== undefined) + inspection.execute?.(decision, input); + return; + } + // Keep focus on the hidden keyboard input instead of the canvas. + event.preventDefault(); + if (event.metaKey) + return; + const now = performance.now(); + click.count = now - click.time < 500 && position.x === click.x && position.y === click.y + ? click.count % 3 + 1 : 1; + click = { count: click.count, time: now, x: position.x, y: position.y }; + const start = gesture.begin({ button: event.button, shiftKey: event.shiftKey, altKey: event.altKey, + detail: event.detail || click.count }, position, state()); + if (!start) + return; + pointerId = event.pointerId; + pointerEvent = event; + lastPoint = position; + completedClick = undefined; + canvas.setPointerCapture(pointerId); + if (start.owner === "local") { + if (scheduled !== undefined) + cancelAnimationFrame(scheduled); + scheduled = undefined; + pendingMove = undefined; + lastMove = undefined; + selectionClick = { point: position, mode: start.mode, extend: start.extend, dragged: false }; + inspection.begin?.(position, start); + } + else + changeButtons(event, position); + }, options); + canvas.addEventListener("pointermove", event => { + if (event.pointerType !== "mouse") + return; + if (pointerId === null && event.buttons) + return; + if (pointerId !== null && pointerId !== event.pointerId) + return; + const position = point(event, pointerId !== null); + if (!position) + return; + lastPoint = position; + if (routedGesture && routedGesture !== InputRoute.Application) + return; + if (gesture.owner === "local") { + pointerEvent = event; + const previous = gesture.endpoint; + const endpoint = gesture.move(position); + if (endpoint && previous && (endpoint.x !== previous.x || endpoint.y !== previous.y)) { + click.count = 0; + if (selectionClick) + selectionClick.dragged = true; + inspection.extend?.(endpoint); + } + updateAutoScroll(); + return; + } + if (!tracking || (!routedGesture && gesture.owner === null && (state().historical || state().readOnly || event.shiftKey))) + return; + if (pointerId === event.pointerId) + changeButtons(event, position); + if (event.metaKey || (tracking !== 1003 && !(tracking === 1002 && pressed.size))) + return; + const button = buttons.find(([name]) => pressed.has(name))?.[0] ?? "none"; + const move = { type: "mouse", action: "move", button, ...position }; + const key = JSON.stringify(move); + if (key === lastMove) + return; + lastMove = key; + pendingMove = move; + if (scheduled === undefined) + scheduled = requestAnimationFrame(flushMove); + }, options); + canvas.addEventListener("pointerup", event => { + if (pointerId !== event.pointerId) + return; + const position = point(event, true) ?? lastPoint; + if (routedGesture) { + if (routedGesture === InputRoute.Application && position) + changeButtons(event, position); + if (event.buttons === 0) + cancel(routedGesture === InputRoute.Application); + return; + } + if (gesture.owner === "local") { + if (position && gesture.endpoint && (position.x !== gesture.endpoint.x || position.y !== gesture.endpoint.y)) { + if (selectionClick) + selectionClick.dragged = true; + const endpoint = gesture.move(position); + if (endpoint) + inspection.extend?.(endpoint); + } + const completed = selectionClick; + cancel(false, false); + completedClick = completed; + return; + } + if (position) + changeButtons(event, position); + if (!pressed.size) + cancel(); + }, options); + canvas.addEventListener("click", event => { + const completed = completedClick; + completedClick = undefined; + if (!completed || completed.dragged || !Number.isInteger(event.detail) || event.detail < 1) + return; + click.count = event.detail; + // Some browsers expose native multiclick counts only on click, not pointerdown. + const mode = event.detail >= 3 ? "line" : event.detail === 2 ? "word" : "character"; + if (!completed.extend && completed.mode !== "rectangle" && mode !== completed.mode) + inspection.begin?.(completed.point, { mode, extend: false }); + }, options); + canvas.addEventListener("pointercancel", () => cancel(), options); + canvas.addEventListener("lostpointercapture", () => { + if (pointerId !== null) + cancel(); + }, options); + window.addEventListener("blur", () => cancel(), options); + canvas.addEventListener("contextmenu", event => { + if (contextMenuRoute !== undefined && contextMenuRoute !== InputRoute.Continue) { + const route = contextMenuRoute; + contextMenuRoute = undefined; + if (route !== InputRoute.Browser) + event.preventDefault(); + return; + } + if (tracking || gesture.owner === "local") + event.preventDefault(); + }, options); + canvas.addEventListener("wheel", event => { + const input = { type: "wheel", deltaX: event.deltaX, deltaY: event.deltaY, + deltaMode: event.deltaMode, point: point(event), ...inputModifiers(event) }; + const decision = pointerId === null + ? inspection.resolve?.(input) ?? { route: InputRoute.Continue } + : { route: routedGesture ?? InputRoute.Continue }; + if (decision.route === InputRoute.Browser) + return; + if (decision.action !== undefined || decision.route === InputRoute.Consume) { + event.preventDefault(); + if (decision.action !== undefined) + inspection.execute?.(decision, input); + return; + } + // Ctrl+wheel (including trackpad pinch) remains browser zoom. + if (decision.route !== InputRoute.Application && (event.ctrlKey || event.metaKey)) + return; + const owner = decision.route === InputRoute.Application ? "app" : gesture.wheelOwner(event, state()); + if (owner === "app" && tracking === 9) + return; + const position = point(gesture.owner === "local" && pointerEvent ? pointerEvent : event, gesture.owner === "local"); + if (!position) + return; + event.preventDefault(); + flushMove(); + const bounds = canvas.getBoundingClientRect(); + if (wheelOwner !== owner) + wheel.reset(); + wheelOwner = owner; + const { x: horizontal, y: vertical } = wheel.take(event, bounds, rows); + if (owner === "local") { + if (vertical) { + if (selectionClick) + selectionClick.dragged = true; + click.count = 0; + inspection.scroll?.(vertical, gesture.scrollPoint(position)); + } + return; + } + const wheelDirections = [ + [vertical, "wheelUp", "wheelDown"], + [horizontal, "wheelLeft", "wheelRight"] + ]; + for (const [steps, negative, positive] of wheelDirections) { + if (steps) + send({ + type: "mouse", action: "wheel", button: steps < 0 ? negative : positive, + count: Math.min(32, Math.abs(steps)), ...position + }); + } + }, { ...options, passive: false }); + return { + update(nextColumns, nextRows, nextTracking) { + if (columns !== nextColumns || rows !== nextRows || tracking !== nextTracking) { + if (lastPoint) { + lastPoint = { ...lastPoint, + x: Math.min(lastPoint.x, nextColumns - 1), + y: Math.min(lastPoint.y, nextRows - 1) + }; + } + // Application mode changes do not transfer ownership mid-gesture. + if (columns !== nextColumns || rows !== nextRows) + cancel(nextTracking !== 0); + columns = nextColumns; + rows = nextRows; + tracking = nextTracking; + } + }, + cancel() { cancel(); }, + dispose() { + cancel(false); + listeners.abort(); + } + }; +} +//# sourceMappingURL=mouse-input.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map new file mode 100644 index 00000000000..0edeef538ab --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map @@ -0,0 +1 @@ +{"version":3,"file":"mouse-input.js","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"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","sourcesContent":["import { cellPoint, WheelAccumulator, SelectionGesture } from \"./selection-input.js\";\nimport { InputRoute, inputModifiers } from \"./input-policy.js\";\nimport type { GestureState } from \"./selection-input.js\";\nimport type { InputDecision, InputRouteValue, MouseTrackingMode, PointerButton, SelectionMode,\n TerminalInput, TerminalPoint } from \"./types.js\";\nimport type { CellPosition, MouseButton, MouseCommand } from \"./wire-types.js\";\n\nconst buttons: readonly (readonly [PointerButton, number])[] = [[\"left\", 1], [\"middle\", 4], [\"right\", 2]];\nconst pointerButtons: readonly PointerButton[] = [\"left\", \"middle\", \"right\"];\ninterface SelectionClick { point: TerminalPoint; mode: SelectionMode; extend: boolean; dragged: boolean }\ninterface MouseInspection {\n state?: () => Omit;\n begin?: (point: TerminalPoint, selection: { mode: SelectionMode; extend: boolean }) => void;\n extend?: (point: TerminalPoint) => void;\n scroll?: (delta: number, endpoint?: TerminalPoint) => void;\n end?: (cancelled: boolean) => void;\n resolve?: (input: TerminalInput) => InputDecision;\n execute?: (decision: Extract, input: TerminalInput) => void;\n}\nexport interface MouseCapture {\n update(columns: number, rows: number, tracking: MouseTrackingMode): void;\n cancel(): void;\n dispose(): void;\n}\n\n/** Capture input intent only; the server chooses and encodes the mouse protocol. */\nexport function captureMouse(canvas: HTMLCanvasElement, send: (command: MouseCommand) => void,\n focus: () => void, inspection: MouseInspection = {}): MouseCapture {\n const listeners = new AbortController();\n const options = { signal: listeners.signal };\n let columns = 1, rows = 1;\n let tracking: MouseTrackingMode = 0;\n let pointerId: number | null = null;\n const pressed = new Set();\n let lastPoint: CellPosition | undefined;\n let pendingMove: MouseCommand | undefined;\n let lastMove: string | undefined;\n let scheduled: number | undefined;\n const wheel = new WheelAccumulator();\n const gesture = new SelectionGesture();\n let wheelOwner: \"local\" | \"app\" | undefined;\n let pointerEvent: PointerEvent | undefined;\n let autoScroll: ReturnType | undefined;\n let click = { count: 0, time: 0, x: -1, y: -1 };\n let selectionClick: SelectionClick | undefined;\n let completedClick: SelectionClick | undefined;\n let routedGesture: InputRouteValue | undefined;\n let contextMenuRoute: InputRouteValue | undefined;\n const state = () => ({ tracking, ...inspection.state?.() });\n\n function point(event: MouseEvent, clamp = false): CellPosition | null {\n return cellPoint(event, canvas.getBoundingClientRect(), columns, rows, clamp);\n }\n\n function updateAutoScroll() {\n clearTimeout(autoScroll);\n autoScroll = undefined;\n if (gesture.owner !== \"local\" || !pointerEvent) return;\n const bounds = canvas.getBoundingClientRect();\n const distance = pointerEvent.clientY < bounds.top ? pointerEvent.clientY - bounds.top\n : pointerEvent.clientY >= bounds.bottom ? pointerEvent.clientY - bounds.bottom + 1 : 0;\n if (!distance) return;\n autoScroll = setTimeout(() => {\n autoScroll = undefined;\n if (!pointerEvent) return;\n const position = point(pointerEvent, true);\n if (gesture.owner === \"local\" && position) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n const lines = Math.sign(distance) * Math.min(8, Math.max(1, Math.ceil(Math.abs(distance) / (bounds.height / rows))));\n inspection.scroll?.(lines, gesture.scrollPoint(position));\n updateAutoScroll();\n }\n }, 60);\n }\n\n function flushMove() {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n if (pendingMove) send(pendingMove);\n pendingMove = undefined;\n }\n\n function changeButtons(event: PointerEvent, position: CellPosition): void {\n for (const [button, mask] of buttons) {\n const down = (event.buttons & mask) !== 0;\n if (down === pressed.has(button)) continue;\n flushMove();\n if (down) pressed.add(button);\n else pressed.delete(button);\n if (down || tracking !== 9)\n send({ type: \"mouse\", action: down ? \"down\" : \"up\", button, ...position });\n lastMove = undefined;\n }\n }\n\n function cancel(report = true, cancelled = true) {\n inspection.end?.(cancelled);\n clearTimeout(autoScroll);\n autoScroll = undefined;\n pointerEvent = undefined;\n selectionClick = undefined;\n completedClick = undefined;\n gesture.end();\n routedGesture = undefined;\n if (report) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (report && tracking !== 9 && lastPoint) {\n for (const button of pressed)\n send({ type: \"mouse\", action: \"up\", button, ...lastPoint });\n }\n pressed.clear();\n const captured = pointerId;\n pointerId = null;\n if (captured !== null && canvas.hasPointerCapture(captured))\n canvas.releasePointerCapture(captured);\n lastMove = undefined;\n wheel.reset();\n wheelOwner = undefined;\n }\n\n canvas.addEventListener(\"pointerdown\", event => {\n if (event.defaultPrevented && pointerId === null) return;\n if (event.pointerType !== \"mouse\" || ![0, 1, 2].includes(event.button)) return;\n const position = point(event);\n if (!position) return;\n if (pointerId !== null) {\n if (routedGesture !== InputRoute.Browser) event.preventDefault();\n if ((gesture.owner === \"app\" || routedGesture === InputRoute.Application) && pointerId === event.pointerId)\n changeButtons(event, position);\n return;\n }\n focus();\n const input: TerminalInput = { type: \"pointer\", button: pointerButtons[event.button],\n point: Object.freeze({ ...position }), ...inputModifiers(event) };\n const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue };\n const route = decision.action !== undefined ? InputRoute.Consume : decision.route;\n contextMenuRoute = event.button === 2 ? route : undefined;\n if (route !== InputRoute.Continue) {\n completedClick = selectionClick = undefined;\n click.count = 0;\n routedGesture = route;\n pointerId = event.pointerId;\n lastPoint = position;\n canvas.setPointerCapture(pointerId);\n if (route !== InputRoute.Browser) event.preventDefault();\n if (route === InputRoute.Application) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (route === InputRoute.Application) changeButtons(event, position);\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Keep focus on the hidden keyboard input instead of the canvas.\n event.preventDefault();\n if (event.metaKey) return;\n const now = performance.now();\n click.count = now - click.time < 500 && position.x === click.x && position.y === click.y\n ? click.count % 3 + 1 : 1;\n click = { count: click.count, time: now, x: position.x, y: position.y };\n const start = gesture.begin({ button: event.button, shiftKey: event.shiftKey, altKey: event.altKey,\n detail: event.detail || click.count }, position, state());\n if (!start) return;\n pointerId = event.pointerId;\n pointerEvent = event;\n lastPoint = position;\n completedClick = undefined;\n canvas.setPointerCapture(pointerId);\n if (start.owner === \"local\") {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n lastMove = undefined;\n selectionClick = { point: position, mode: start.mode, extend: start.extend, dragged: false };\n inspection.begin?.(position, start);\n }\n else changeButtons(event, position);\n }, options);\n\n canvas.addEventListener(\"pointermove\", event => {\n if (event.pointerType !== \"mouse\") return;\n if (pointerId === null && event.buttons) return;\n if (pointerId !== null && pointerId !== event.pointerId) return;\n const position = point(event, pointerId !== null);\n if (!position) return;\n lastPoint = position;\n if (routedGesture && routedGesture !== InputRoute.Application) return;\n if (gesture.owner === \"local\") {\n pointerEvent = event;\n const previous = gesture.endpoint;\n const endpoint = gesture.move(position);\n if (endpoint && previous && (endpoint.x !== previous.x || endpoint.y !== previous.y)) {\n click.count = 0;\n if (selectionClick) selectionClick.dragged = true;\n inspection.extend?.(endpoint);\n }\n updateAutoScroll();\n return;\n }\n if (!tracking || (!routedGesture && gesture.owner === null && (state().historical || state().readOnly || event.shiftKey))) return;\n if (pointerId === event.pointerId) changeButtons(event, position);\n if (event.metaKey || (tracking !== 1003 && !(tracking === 1002 && pressed.size))) return;\n const button = buttons.find(([name]) => pressed.has(name))?.[0] ?? \"none\";\n const move: MouseCommand = { type: \"mouse\", action: \"move\", button, ...position };\n const key = JSON.stringify(move);\n if (key === lastMove) return;\n lastMove = key;\n pendingMove = move;\n if (scheduled === undefined) scheduled = requestAnimationFrame(flushMove);\n }, options);\n\n canvas.addEventListener(\"pointerup\", event => {\n if (pointerId !== event.pointerId) return;\n const position = point(event, true) ?? lastPoint;\n if (routedGesture) {\n if (routedGesture === InputRoute.Application && position) changeButtons(event, position);\n if (event.buttons === 0) cancel(routedGesture === InputRoute.Application);\n return;\n }\n if (gesture.owner === \"local\") {\n if (position && gesture.endpoint && (position.x !== gesture.endpoint.x || position.y !== gesture.endpoint.y)) {\n if (selectionClick) selectionClick.dragged = true;\n const endpoint = gesture.move(position);\n if (endpoint) inspection.extend?.(endpoint);\n }\n const completed = selectionClick;\n cancel(false, false);\n completedClick = completed;\n return;\n }\n if (position) changeButtons(event, position);\n if (!pressed.size) cancel();\n }, options);\n canvas.addEventListener(\"click\", event => {\n const completed = completedClick;\n completedClick = undefined;\n if (!completed || completed.dragged || !Number.isInteger(event.detail) || event.detail < 1) return;\n click.count = event.detail;\n // Some browsers expose native multiclick counts only on click, not pointerdown.\n const mode = event.detail >= 3 ? \"line\" : event.detail === 2 ? \"word\" : \"character\";\n if (!completed.extend && completed.mode !== \"rectangle\" && mode !== completed.mode)\n inspection.begin?.(completed.point, { mode, extend: false });\n }, options);\n canvas.addEventListener(\"pointercancel\", () => cancel(), options);\n canvas.addEventListener(\"lostpointercapture\", () => {\n if (pointerId !== null) cancel();\n }, options);\n window.addEventListener(\"blur\", () => cancel(), options);\n canvas.addEventListener(\"contextmenu\", event => {\n if (contextMenuRoute !== undefined && contextMenuRoute !== InputRoute.Continue) {\n const route = contextMenuRoute;\n contextMenuRoute = undefined;\n if (route !== InputRoute.Browser) event.preventDefault();\n return;\n }\n if (tracking || gesture.owner === \"local\") event.preventDefault();\n }, options);\n canvas.addEventListener(\"wheel\", event => {\n const input: TerminalInput = { type: \"wheel\", deltaX: event.deltaX, deltaY: event.deltaY,\n deltaMode: event.deltaMode, point: point(event), ...inputModifiers(event) };\n const decision: InputDecision = pointerId === null\n ? inspection.resolve?.(input) ?? { route: InputRoute.Continue }\n : { route: routedGesture ?? InputRoute.Continue };\n if (decision.route === InputRoute.Browser) return;\n if (decision.action !== undefined || decision.route === InputRoute.Consume) {\n event.preventDefault();\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Ctrl+wheel (including trackpad pinch) remains browser zoom.\n if (decision.route !== InputRoute.Application && (event.ctrlKey || event.metaKey)) return;\n const owner = decision.route === InputRoute.Application ? \"app\" : gesture.wheelOwner(event, state());\n if (owner === \"app\" && tracking === 9) return;\n const position = point(gesture.owner === \"local\" && pointerEvent ? pointerEvent : event, gesture.owner === \"local\");\n if (!position) return;\n event.preventDefault();\n flushMove();\n const bounds = canvas.getBoundingClientRect();\n if (wheelOwner !== owner) wheel.reset();\n wheelOwner = owner;\n const { x: horizontal, y: vertical } = wheel.take(event, bounds, rows);\n if (owner === \"local\") {\n if (vertical) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n inspection.scroll?.(vertical, gesture.scrollPoint(position));\n }\n return;\n }\n const wheelDirections: [number, MouseButton, MouseButton][] = [\n [vertical, \"wheelUp\", \"wheelDown\"],\n [horizontal, \"wheelLeft\", \"wheelRight\"]\n ];\n for (const [steps, negative, positive] of wheelDirections) {\n if (steps) send({\n type: \"mouse\", action: \"wheel\", button: steps < 0 ? negative : positive,\n count: Math.min(32, Math.abs(steps)), ...position\n });\n }\n }, { ...options, passive: false });\n\n return {\n update(nextColumns, nextRows, nextTracking) {\n if (columns !== nextColumns || rows !== nextRows || tracking !== nextTracking) {\n if (lastPoint) {\n lastPoint = { ...lastPoint,\n x: Math.min(lastPoint.x, nextColumns - 1),\n y: Math.min(lastPoint.y, nextRows - 1)\n };\n }\n // Application mode changes do not transfer ownership mid-gesture.\n if (columns !== nextColumns || rows !== nextRows) cancel(nextTracking !== 0);\n columns = nextColumns;\n rows = nextRows;\n tracking = nextTracking;\n }\n },\n cancel() { cancel(); },\n dispose() {\n cancel(false);\n listeners.abort();\n }\n };\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts new file mode 100644 index 00000000000..917551e65ee --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts @@ -0,0 +1,17 @@ +import type { HistoryMetadata, TerminalCell, TerminalFrame } from "./wire-types.js"; +export declare const LIMITS: Readonly<{ + commandBytes: number; + frameBytes: number; + metadataBytes: number; + cells: 262144; + images: 4096; + placements: 16384; + textureBytes: number; +}>; +export declare function assertCommandSize(command: unknown): void; +export declare function validateHistory(history: unknown, columns: number, rows: number): asserts history is HistoryMetadata | null; +/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */ +export declare function decodeFrame(buffer: unknown): TerminalFrame; +/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */ +export declare function screenText(cells: readonly (TerminalCell | undefined)[], columns: number, rows: number): string; +//# sourceMappingURL=protocol.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map new file mode 100644 index 00000000000..844e78e1204 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,eAAe,EAAiB,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIlH,eAAO,MAAM,MAAM;;;;;;;;EAQjB,CAAC;AAKH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGxD;AAyBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,GAAG,IAAI,CAqC1H;AAmGD,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,CAiD1D;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAY9G"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js new file mode 100644 index 00000000000..48fbe71d38a --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js @@ -0,0 +1,270 @@ +import { isRecord } from "./validation.js"; +// Binary validation is deliberately independent of the GPU and the transport. +export const LIMITS = Object.freeze({ + commandBytes: 64 * 1024, + frameBytes: 96 * 1024 * 1024, + metadataBytes: 8 * 1024 * 1024, + cells: 262144, + images: 4096, + placements: 16384, + textureBytes: 256 * 1024 * 1024, +}); +const utf8 = new TextDecoder("utf-8", { fatal: true }); +const utf8Encoder = new TextEncoder(); +export function assertCommandSize(command) { + if (utf8Encoder.encode(JSON.stringify(command)).byteLength > LIMITS.commandBytes) + throw new RangeError("Terminal input exceeds the 64 KiB message limit. Send a smaller amount of text."); +} +function integer(value, name, min = 0, max = Number.MAX_SAFE_INTEGER) { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) { + throw new Error(`Invalid ${name}: ${String(value)}`); + } + return value; +} +function array(value, name, limit) { + if (!Array.isArray(value) || value.length > limit) + throw new Error(`Invalid ${name}`); +} +function key(value) { + if (typeof value !== "string" || !value.length || value.length > 1024) { + throw new Error("Invalid image key"); + } +} +function rowId(value, name) { + if (typeof value !== "string" || !/^[1-9][0-9]{0,18}$/u.test(value) || BigInt(value) > 9223372036854775807n) { + throw new Error(`Invalid ${name}`); + } +} +export function validateHistory(history, columns, rows) { + if (history === null) + return; + if (!isRecord(history)) + throw new Error("Missing history metadata"); + rowId(history.generation, "history generation"); + if (history.buffer !== "main" && history.buffer !== "alternate") + throw new Error("Invalid history buffer"); + const totalRows = integer(history.totalRows, "history total rows", rows, 2147483647); + const liveTop = integer(history.liveTop, "history live top", 0, totalRows - rows); + if (liveTop !== totalRows - rows) + throw new Error("Inconsistent history extent"); + integer(history.top, "history top", 0, liveTop); + if (typeof history.following !== "boolean" || (history.following && history.top !== history.liveTop)) { + throw new Error("Invalid history following state"); + } + integer(history.requestId, "viewport request id"); + array(history.rowIds, "viewport row ids", rows); + if (history.rowIds.length !== rows || new Set(history.rowIds).size !== rows) + throw new Error("Invalid viewport row ids"); + for (const id of history.rowIds) + rowId(id, "viewport row id"); + const selection = history.selection; + if (!isRecord(selection) || typeof selection.status !== "string" || + !["none", "valid", "invalidated"].includes(selection.status)) + throw new Error("Invalid selection status"); + integer(selection.requestId, "selection request id"); + if (typeof selection.mode !== "string" || !["character", "word", "line", "rectangle"].includes(selection.mode)) + throw new Error("Invalid selection mode"); + array(selection.ranges, "selection ranges", rows); + let previousRow = -1; + for (const range of selection.ranges) { + if (!isRecord(range)) + throw new Error("Invalid selection range"); + const row = integer(range.row, "selection range row", previousRow + 1, rows - 1); + const startColumn = integer(range.startColumn, "selection start column", 0, columns - 1); + integer(range.endColumn, "selection end column", startColumn + 1, columns); + previousRow = row; + } + validateSelectionText(selection); + if (selection.status !== "valid" && selection.ranges.length) + throw new Error("Inactive selection has highlight ranges"); + if (history.copy !== null) { + if (!isRecord(history.copy)) + throw new Error("Missing copy metadata"); + integer(history.copy.requestId, "copy request id", 1); + validateSelectionText(history.copy); + } +} +function validateSelectionText(selection) { + if (typeof selection.status !== "string" || !["none", "valid", "invalidated"].includes(selection.status) || + (selection.status === "valid" + ? typeof selection.text !== "string" || selection.text.length > LIMITS.metadataBytes + : selection.text !== null)) { + throw new Error("Invalid selection text"); + } +} +function validateMetadata(metadata) { + if (!isRecord(metadata) || metadata.version !== 1 || typeof metadata.full !== "boolean") { + throw new Error("Unsupported frame metadata version"); + } + integer(metadata.revision, "revision", 1); + integer(metadata.baseRevision, "base revision"); + const columns = integer(metadata.columns, "columns", 1, 1024); + const rows = integer(metadata.rows, "rows", 1, 512); + if (typeof metadata.mouseTracking !== "number" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) { + throw new Error("Unsupported mouse tracking mode"); + } + if (!isRecord(metadata.peer) || typeof metadata.peer.isPrimary !== "boolean") + throw new Error("Invalid peer state"); + for (const field of ["id", "primaryId"]) { + const id = metadata.peer[field]; + if (id !== null && (typeof id !== "string" || !id.length || id.length > 256)) { + throw new Error(`Invalid peer ${field}`); + } + } + if (metadata.peer.id !== null && metadata.peer.isPrimary !== (metadata.peer.id === metadata.peer.primaryId)) { + throw new Error("Inconsistent primary peer state"); + } + integer(columns * rows, "cell count", 1, LIMITS.cells); + validateHistory(metadata.history, columns, rows); + if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) { + throw new Error("This spike requires server geometry of 10 × 20 logical pixels"); + } + for (const field of ["defaultBackground", "defaultForeground"]) { + if (metadata[field] !== undefined) + integer(metadata[field], field, 0, 0xffffffff); + } + if (!isRecord(metadata.cursor) || typeof metadata.cursor.visible !== "boolean") + throw new Error("Invalid cursor"); + integer(metadata.cursor.x, "cursor x", -1, 1024); + integer(metadata.cursor.y, "cursor y", -1, 512); + const shapes = ["Default", "BlinkingBlock", "SteadyBlock", "BlinkingUnderline", "SteadyUnderline", "BlinkingBar", "SteadyBar"]; + if (typeof metadata.cursor.shape === "string") + metadata.cursor.shape = shapes.indexOf(metadata.cursor.shape); + integer(metadata.cursor.shape, "cursor shape", 0, 6); + array(metadata.images, "images", LIMITS.images); + array(metadata.retainedImages, "retained image keys", LIMITS.images); + array(metadata.placements, "placements", LIMITS.placements); + array(metadata.warnings, "warnings", 256); + if (metadata.warnings.some(w => typeof w !== "string")) + throw new Error("Invalid warning"); + if (!isRecord(metadata.stats)) + throw new Error("Invalid server metrics"); + for (const field of ["workloadBytes", "outputBatches", "captureMs", "elapsedMs"]) { + const metric = metadata.stats[field]; + if (typeof metric !== "number" || !Number.isFinite(metric) || metric < 0) { + throw new Error(`Invalid server metric ${field}`); + } + } + const retained = new Set(); + for (const imageKey of metadata.retainedImages) { + key(imageKey); + if (retained.has(imageKey)) + throw new Error("Duplicate retained image key"); + retained.add(imageKey); + } + const imageKeys = new Set(); + let decodedImageBytes = 0; + for (const image of metadata.images) { + if (!isRecord(image)) + throw new Error("Invalid image"); + key(image.key); + if (imageKeys.has(image.key) || !retained.has(image.key)) + throw new Error("Inconsistent new image keys"); + imageKeys.add(image.key); + const width = integer(image.width, "image width", 1, 16384); + const height = integer(image.height, "image height", 1, 16384); + const byteLength = integer(image.byteLength, "image byte length", 1, LIMITS.frameBytes); + if (image.format !== "rgba" && image.format !== "png") + throw new Error("Unsupported image format"); + if (image.format === "rgba" && byteLength !== width * height * 4) { + throw new Error("RGBA image size mismatch"); + } + decodedImageBytes += width * height * 4; + if (decodedImageBytes > LIMITS.textureBytes) + throw new Error("New images exceed decoded texture budget"); + } + for (const placement of metadata.placements) { + if (!isRecord(placement)) + throw new Error("Invalid placement"); + key(placement.key); + if (!retained.has(placement.key)) + throw new Error("Placement references an unretained image"); + if (placement.kind !== "kgp" && placement.kind !== "sixel") + throw new Error("Invalid placement kind"); + for (const field of ["x", "y", "width", "height", "sourceX", "sourceY", "sourceWidth", "sourceHeight", "clipX", "clipY", "clipWidth", "clipHeight", "z"]) { + const coordinate = placement[field]; + if (typeof coordinate !== "number" || !Number.isFinite(coordinate) || Math.abs(coordinate) > 2147483648) { + throw new Error(`Invalid placement ${field}`); + } + } + for (const field of ["width", "height", "sourceWidth", "sourceHeight", "clipWidth", "clipHeight"]) { + const coordinate = placement[field]; + if (typeof coordinate !== "number" || coordinate < 0) + throw new Error(`Negative placement ${field}`); + } + } +} +/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */ +export function decodeFrame(buffer) { + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.frameBytes) { + throw new Error("Invalid or oversized binary frame"); + } + const view = new DataView(buffer); + let offset = 0; + const requireBytes = (count) => { + if (count < 0 || count > view.byteLength - offset) + throw new Error("Truncated HWT1 frame"); + }; + const u32 = () => { requireBytes(4); const n = view.getUint32(offset, true); offset += 4; return n; }; + if (u32() !== 0x31545748) + throw new Error("Unsupported frame magic (expected HWT1)"); + const metadataLength = integer(u32(), "metadata length", 2, LIMITS.metadataBytes); + requireBytes(metadataLength); + const metadata = JSON.parse(utf8.decode(new Uint8Array(buffer, offset, metadataLength))); + offset += metadataLength; + validateMetadata(metadata); + const cellCount = metadata.columns * metadata.rows; + const changedCount = integer(u32(), "changed cell count", 0, cellCount); + if (changedCount > Math.floor((view.byteLength - offset) / 22)) + throw new Error("Truncated cell records"); + if (metadata.full && changedCount !== cellCount) + throw new Error("Incomplete full frame"); + const cells = []; + const seen = new Set(); + for (let i = 0; i < changedCount; i++) { + requireBytes(22); + const index = u32(); + if (index >= cellCount || seen.has(index)) + throw new Error("Invalid or duplicate cell index"); + seen.add(index); + const foreground = u32(); + const background = u32(); + const underlineColor = u32(); + const attributes = view.getUint16(offset, true); + const width = view.getUint8(offset + 2); + const underlineStyle = view.getUint8(offset + 3); + const textLength = view.getUint16(offset + 4, true); + offset += 6; + if (underlineStyle > 5) + throw new Error("Unsupported underline style"); + requireBytes(textLength); + const text = utf8.decode(new Uint8Array(buffer, offset, textLength)); + offset += textLength; + cells.push({ index, foreground, background, underlineColor, attributes, width, underlineStyle, text }); + } + const imageBytes = metadata.images.reduce((total, image) => total + image.byteLength, 0); + if (imageBytes !== view.byteLength - offset) + throw new Error("Image payload length mismatch"); + const images = metadata.images.map(image => { + const bytes = new Uint8Array(buffer, offset, image.byteLength); + offset += image.byteLength; + return { ...image, bytes }; + }); + return { metadata, cells, images }; +} +/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */ +export function screenText(cells, columns, rows) { + const lines = []; + for (let y = 0; y < rows; y++) { + let line = ""; + for (let x = 0; x < columns; x++) { + const cell = cells[y * columns + x]; + if (!cell || cell.width === 0) + continue; + line += cell.attributes & 64 ? " ".repeat(cell.width) : (cell.text || " "); + } + lines.push(line.replace(/ +$/u, "")); + } + return lines.join("\n"); +} +//# sourceMappingURL=protocol.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map new file mode 100644 index 00000000000..c5d7c1b4ee5 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map @@ -0,0 +1 @@ +{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FrameMetadata, HistoryMetadata, SelectionText, TerminalCell, TerminalFrame } from \"./wire-types.js\";\nimport { isRecord } from \"./validation.js\";\n\n// Binary validation is deliberately independent of the GPU and the transport.\nexport const LIMITS = Object.freeze({\n commandBytes: 64 * 1024,\n frameBytes: 96 * 1024 * 1024,\n metadataBytes: 8 * 1024 * 1024,\n cells: 262144,\n images: 4096,\n placements: 16384,\n textureBytes: 256 * 1024 * 1024,\n});\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\nconst utf8Encoder = new TextEncoder();\n\nexport function assertCommandSize(command: unknown): void {\n if (utf8Encoder.encode(JSON.stringify(command)).byteLength > LIMITS.commandBytes)\n throw new RangeError(\"Terminal input exceeds the 64 KiB message limit. Send a smaller amount of text.\");\n}\n\nfunction integer(value: unknown, name: string, min = 0, max = Number.MAX_SAFE_INTEGER): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max) {\n throw new Error(`Invalid ${name}: ${String(value)}`);\n }\n return value;\n}\n\nfunction array(value: unknown, name: string, limit: number): asserts value is unknown[] {\n if (!Array.isArray(value) || value.length > limit) throw new Error(`Invalid ${name}`);\n}\n\nfunction key(value: unknown): asserts value is string {\n if (typeof value !== \"string\" || !value.length || value.length > 1024) {\n throw new Error(\"Invalid image key\");\n }\n}\n\nfunction rowId(value: unknown, name: string): asserts value is string {\n if (typeof value !== \"string\" || !/^[1-9][0-9]{0,18}$/u.test(value) || BigInt(value) > 9223372036854775807n) {\n throw new Error(`Invalid ${name}`);\n }\n}\n\nexport function validateHistory(history: unknown, columns: number, rows: number): asserts history is HistoryMetadata | null {\n if (history === null) return;\n if (!isRecord(history)) throw new Error(\"Missing history metadata\");\n rowId(history.generation, \"history generation\");\n if (history.buffer !== \"main\" && history.buffer !== \"alternate\") throw new Error(\"Invalid history buffer\");\n const totalRows = integer(history.totalRows, \"history total rows\", rows, 2147483647);\n const liveTop = integer(history.liveTop, \"history live top\", 0, totalRows - rows);\n if (liveTop !== totalRows - rows) throw new Error(\"Inconsistent history extent\");\n integer(history.top, \"history top\", 0, liveTop);\n if (typeof history.following !== \"boolean\" || (history.following && history.top !== history.liveTop)) {\n throw new Error(\"Invalid history following state\");\n }\n integer(history.requestId, \"viewport request id\");\n array(history.rowIds, \"viewport row ids\", rows);\n if (history.rowIds.length !== rows || new Set(history.rowIds).size !== rows) throw new Error(\"Invalid viewport row ids\");\n for (const id of history.rowIds) rowId(id, \"viewport row id\");\n const selection = history.selection;\n if (!isRecord(selection) || typeof selection.status !== \"string\" ||\n ![\"none\", \"valid\", \"invalidated\"].includes(selection.status)) throw new Error(\"Invalid selection status\");\n integer(selection.requestId, \"selection request id\");\n if (typeof selection.mode !== \"string\" || ![\"character\", \"word\", \"line\", \"rectangle\"].includes(selection.mode)) throw new Error(\"Invalid selection mode\");\n array(selection.ranges, \"selection ranges\", rows);\n let previousRow = -1;\n for (const range of selection.ranges) {\n if (!isRecord(range)) throw new Error(\"Invalid selection range\");\n const row = integer(range.row, \"selection range row\", previousRow + 1, rows - 1);\n const startColumn = integer(range.startColumn, \"selection start column\", 0, columns - 1);\n integer(range.endColumn, \"selection end column\", startColumn + 1, columns);\n previousRow = row;\n }\n validateSelectionText(selection);\n if (selection.status !== \"valid\" && selection.ranges.length) throw new Error(\"Inactive selection has highlight ranges\");\n if (history.copy !== null) {\n if (!isRecord(history.copy)) throw new Error(\"Missing copy metadata\");\n integer(history.copy.requestId, \"copy request id\", 1);\n validateSelectionText(history.copy);\n }\n}\n\nfunction validateSelectionText(selection: Record): asserts selection is Record & SelectionText {\n if (typeof selection.status !== \"string\" || ![\"none\", \"valid\", \"invalidated\"].includes(selection.status) ||\n (selection.status === \"valid\"\n ? typeof selection.text !== \"string\" || selection.text.length > LIMITS.metadataBytes\n : selection.text !== null)) {\n throw new Error(\"Invalid selection text\");\n }\n}\n\nfunction validateMetadata(metadata: unknown): asserts metadata is FrameMetadata {\n if (!isRecord(metadata) || metadata.version !== 1 || typeof metadata.full !== \"boolean\") {\n throw new Error(\"Unsupported frame metadata version\");\n }\n integer(metadata.revision, \"revision\", 1);\n integer(metadata.baseRevision, \"base revision\");\n const columns = integer(metadata.columns, \"columns\", 1, 1024);\n const rows = integer(metadata.rows, \"rows\", 1, 512);\n if (typeof metadata.mouseTracking !== \"number\" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) {\n throw new Error(\"Unsupported mouse tracking mode\");\n }\n if (!isRecord(metadata.peer) || typeof metadata.peer.isPrimary !== \"boolean\") throw new Error(\"Invalid peer state\");\n for (const field of [\"id\", \"primaryId\"]) {\n const id = metadata.peer[field];\n if (id !== null && (typeof id !== \"string\" || !id.length || id.length > 256)) {\n throw new Error(`Invalid peer ${field}`);\n }\n }\n if (metadata.peer.id !== null && metadata.peer.isPrimary !== (metadata.peer.id === metadata.peer.primaryId)) {\n throw new Error(\"Inconsistent primary peer state\");\n }\n integer(columns * rows, \"cell count\", 1, LIMITS.cells);\n validateHistory(metadata.history, columns, rows);\n if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) {\n throw new Error(\"This spike requires server geometry of 10 × 20 logical pixels\");\n }\n for (const field of [\"defaultBackground\", \"defaultForeground\"]) {\n if (metadata[field] !== undefined) integer(metadata[field], field, 0, 0xffffffff);\n }\n if (!isRecord(metadata.cursor) || typeof metadata.cursor.visible !== \"boolean\") throw new Error(\"Invalid cursor\");\n integer(metadata.cursor.x, \"cursor x\", -1, 1024);\n integer(metadata.cursor.y, \"cursor y\", -1, 512);\n const shapes = [\"Default\", \"BlinkingBlock\", \"SteadyBlock\", \"BlinkingUnderline\", \"SteadyUnderline\", \"BlinkingBar\", \"SteadyBar\"];\n if (typeof metadata.cursor.shape === \"string\") metadata.cursor.shape = shapes.indexOf(metadata.cursor.shape);\n integer(metadata.cursor.shape, \"cursor shape\", 0, 6);\n array(metadata.images, \"images\", LIMITS.images);\n array(metadata.retainedImages, \"retained image keys\", LIMITS.images);\n array(metadata.placements, \"placements\", LIMITS.placements);\n array(metadata.warnings, \"warnings\", 256);\n if (metadata.warnings.some(w => typeof w !== \"string\")) throw new Error(\"Invalid warning\");\n if (!isRecord(metadata.stats)) throw new Error(\"Invalid server metrics\");\n for (const field of [\"workloadBytes\", \"outputBatches\", \"captureMs\", \"elapsedMs\"]) {\n const metric = metadata.stats[field];\n if (typeof metric !== \"number\" || !Number.isFinite(metric) || metric < 0) {\n throw new Error(`Invalid server metric ${field}`);\n }\n }\n const retained = new Set();\n for (const imageKey of metadata.retainedImages) {\n key(imageKey);\n if (retained.has(imageKey)) throw new Error(\"Duplicate retained image key\");\n retained.add(imageKey);\n }\n const imageKeys = new Set();\n let decodedImageBytes = 0;\n for (const image of metadata.images) {\n if (!isRecord(image)) throw new Error(\"Invalid image\");\n key(image.key);\n if (imageKeys.has(image.key) || !retained.has(image.key)) throw new Error(\"Inconsistent new image keys\");\n imageKeys.add(image.key);\n const width = integer(image.width, \"image width\", 1, 16384);\n const height = integer(image.height, \"image height\", 1, 16384);\n const byteLength = integer(image.byteLength, \"image byte length\", 1, LIMITS.frameBytes);\n if (image.format !== \"rgba\" && image.format !== \"png\") throw new Error(\"Unsupported image format\");\n if (image.format === \"rgba\" && byteLength !== width * height * 4) {\n throw new Error(\"RGBA image size mismatch\");\n }\n decodedImageBytes += width * height * 4;\n if (decodedImageBytes > LIMITS.textureBytes) throw new Error(\"New images exceed decoded texture budget\");\n }\n for (const placement of metadata.placements) {\n if (!isRecord(placement)) throw new Error(\"Invalid placement\");\n key(placement.key);\n if (!retained.has(placement.key)) throw new Error(\"Placement references an unretained image\");\n if (placement.kind !== \"kgp\" && placement.kind !== \"sixel\") throw new Error(\"Invalid placement kind\");\n for (const field of [\"x\", \"y\", \"width\", \"height\", \"sourceX\", \"sourceY\", \"sourceWidth\", \"sourceHeight\", \"clipX\", \"clipY\", \"clipWidth\", \"clipHeight\", \"z\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || !Number.isFinite(coordinate) || Math.abs(coordinate) > 2147483648) {\n throw new Error(`Invalid placement ${field}`);\n }\n }\n for (const field of [\"width\", \"height\", \"sourceWidth\", \"sourceHeight\", \"clipWidth\", \"clipHeight\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || coordinate < 0) throw new Error(`Negative placement ${field}`);\n }\n }\n}\n\n/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */\nexport function decodeFrame(buffer: unknown): TerminalFrame {\n if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.frameBytes) {\n throw new Error(\"Invalid or oversized binary frame\");\n }\n const view = new DataView(buffer);\n let offset = 0;\n const requireBytes = (count: number) => {\n if (count < 0 || count > view.byteLength - offset) throw new Error(\"Truncated HWT1 frame\");\n };\n const u32 = () => { requireBytes(4); const n = view.getUint32(offset, true); offset += 4; return n; };\n if (u32() !== 0x31545748) throw new Error(\"Unsupported frame magic (expected HWT1)\");\n const metadataLength = integer(u32(), \"metadata length\", 2, LIMITS.metadataBytes);\n requireBytes(metadataLength);\n const metadata: unknown = JSON.parse(utf8.decode(new Uint8Array(buffer, offset, metadataLength)));\n offset += metadataLength;\n validateMetadata(metadata);\n const cellCount = metadata.columns * metadata.rows;\n const changedCount = integer(u32(), \"changed cell count\", 0, cellCount);\n if (changedCount > Math.floor((view.byteLength - offset) / 22)) throw new Error(\"Truncated cell records\");\n if (metadata.full && changedCount !== cellCount) throw new Error(\"Incomplete full frame\");\n const cells = [];\n const seen = new Set();\n for (let i = 0; i < changedCount; i++) {\n requireBytes(22);\n const index = u32();\n if (index >= cellCount || seen.has(index)) throw new Error(\"Invalid or duplicate cell index\");\n seen.add(index);\n const foreground = u32();\n const background = u32();\n const underlineColor = u32();\n const attributes = view.getUint16(offset, true);\n const width = view.getUint8(offset + 2);\n const underlineStyle = view.getUint8(offset + 3);\n const textLength = view.getUint16(offset + 4, true);\n offset += 6;\n if (underlineStyle > 5) throw new Error(\"Unsupported underline style\");\n requireBytes(textLength);\n const text = utf8.decode(new Uint8Array(buffer, offset, textLength));\n offset += textLength;\n cells.push({ index, foreground, background, underlineColor, attributes, width, underlineStyle, text });\n }\n const imageBytes = metadata.images.reduce((total, image) => total + image.byteLength, 0);\n if (imageBytes !== view.byteLength - offset) throw new Error(\"Image payload length mismatch\");\n const images = metadata.images.map(image => {\n const bytes = new Uint8Array(buffer, offset, image.byteLength);\n offset += image.byteLength;\n return { ...image, bytes };\n });\n return { metadata, cells, images };\n}\n\n/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */\nexport function screenText(cells: readonly (TerminalCell | undefined)[], columns: number, rows: number): string {\n const lines = [];\n for (let y = 0; y < rows; y++) {\n let line = \"\";\n for (let x = 0; x < columns; x++) {\n const cell = cells[y * columns + x];\n if (!cell || cell.width === 0) continue;\n line += cell.attributes & 64 ? \" \".repeat(cell.width) : (cell.text || \" \");\n }\n lines.push(line.replace(/ +$/u, \"\"));\n }\n return lines.join(\"\\n\");\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts new file mode 100644 index 00000000000..31d7d0796e5 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts @@ -0,0 +1,113 @@ +import type { FontMetrics, LoadedFont, NormalizedFont } from "./terminal-font.js"; +import type { TerminalFont, TerminalSize } from "./types.js"; +import type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from "./wire-types.js"; +type Vector4 = [number, number, number, number]; +interface TextureResource { + texture: GPUTexture; + bindGroup: GPUBindGroup; + width: number; + height: number; +} +interface Shelf { + x: number; + y: number; + rowHeight: number; +} +interface GlyphPlacement { + key: string; + cell: TerminalCell; + x: number; + y: number; + width: number; + height: number; +} +interface Glyph { + colored: boolean; + u0: number; + v0: number; + u1: number; + v1: number; +} +interface Batch { + resource: TextureResource; + start: number; + count: number; +} +/** WebGPU instanced quads; Canvas2D is used only to rasterize reusable glyphs. */ +export declare class TerminalRenderer { + canvas: OffscreenCanvas; + scale: number; + backingScale: number; + device: GPUDevice; + fontConfiguration: NormalizedFont; + fontMetrics: Map; + context: GPUCanvasContext; + images: Map; + glyphs: Map; + imageUploadBytes: number; + imagePayloadBytes: number; + glyphUploadBytes: number; + atlasRebuilds: number; + textureBytes: number; + instances: Float32Array; + instanceBuffer: GPUBuffer | null; + instanceBufferBytes: number; + disposed: boolean; + columns: number; + rows: number; + font: LoadedFont; + format: GPUTextureFormat; + uniform: GPUBuffer; + sampler: GPUSampler; + pipeline: GPURenderPipeline; + rasterCanvas: OffscreenCanvas; + raster: OffscreenCanvasRenderingContext2D; + atlas: TextureResource; + glyphKeyUnits: number; + shelf: Shelf; + canvasLimited: boolean; + width: number; + height: number; + quadCount: number; + batches: Batch[]; + static create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error | GPUError) => void, font: TerminalFont): Promise; + constructor(canvas: OffscreenCanvas, scale: number, device: GPUDevice, font: NormalizedFont); + initialize(): Promise; + createTexture(width: number, height: number, label: string): TextureResource; + resetAtlas(size: number): void; + resize(columns: number, rows: number, viewport?: TerminalSize): void; + /** Call only between submissions. Missing/over-budget resources terminate the session. */ + updateImages(incoming: readonly FrameImage[], retainedKeys: readonly string[]): Promise; + prepareGlyphs(cells: readonly (TerminalCell | undefined)[]): void; + uploadGlyph({ key, cell, x, y, width, height }: GlyphPlacement): void; + /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */ + quad(resource: TextureResource, x: number, y: number, width: number, height: number, color: Vector4, mode?: number, uv?: Vector4, clip?: Vector4): void; + solid(x: number, y: number, width: number, height: number, color: Vector4): void; + placement(placement: ImagePlacement): void; + decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void; + render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean): { + cpuMs: number; + quads: number; + drawCalls: number; + }; + metrics(): { + fontFamily: string; + rasterScale: number; + backingScale: number; + backingWidth: number; + backingHeight: number; + imageCount: number; + textureBytes: number; + atlasGlyphs: number; + atlasBytes: number; + atlasRebuilds: number; + imageUploadBytes: number; + imagePayloadBytes: number; + glyphUploadBytes: number; + instanceBufferBytes: number; + }; + idle(): Promise; + dispose(): void; +} +export {}; +//# sourceMappingURL=renderer.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map new file mode 100644 index 00000000000..d2967de063c --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js new file mode 100644 index 00000000000..e90640c1a81 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js @@ -0,0 +1,595 @@ +import { LIMITS } from "./protocol.js"; +import { loadFont, measureFont, normalizeFont } from "./terminal-font.js"; +const CELL_WIDTH = 10; +const CELL_HEIGHT = 20; +const MAX_QUADS = 1024 * 1024; +const MAX_GLYPHS = 16384; +const MAX_GLYPH_KEY_UNITS = 1024 * 1024; +const STRIDE = 16; +const WHITE = [1, 1, 1, 1]; +const shader = /* wgsl */ ` +struct Viewport { size: vec2f, padding: vec2f } +@group(0) @binding(0) var viewport: Viewport; +@group(0) @binding(1) var image: texture_2d; +@group(0) @binding(2) var imageSampler: sampler; + +struct VertexOut { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, + @location(1) color: vec4f, + @location(2) @interpolate(flat) mode: f32, +} + +@vertex fn vertex( + @builtin(vertex_index) index: u32, + @location(0) rect: vec4f, + @location(1) uvRect: vec4f, + @location(2) color: vec4f, + @location(3) mode: f32, +) -> VertexOut { + let corners = array( + vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), + vec2f(0, 1), vec2f(1, 0), vec2f(1, 1) + ); + let corner = corners[index]; + let position = rect.xy + corner * rect.zw; + var out: VertexOut; + out.position = vec4f(position / viewport.size * vec2f(2, -2) + vec2f(-1, 1), 0, 1); + out.uv = mix(uvRect.xy, uvRect.zw, corner); + out.color = color; + out.mode = mode; + return out; +} + +@fragment fn fragment(in: VertexOut) -> @location(0) vec4f { + // Explicit LOD avoids derivative-uniformity requirements across solid/mask/image batches. + let texel = textureSampleLevel(image, imageSampler, in.uv, 0); + if (in.mode < 0.5) { return in.color; } + if (in.mode < 1.5) { return vec4f(in.color.rgb, in.color.a * texel.a); } + return texel * in.color; +}`; +function rgba(packed) { + return [ + (packed & 255) / 255, + ((packed >>> 8) & 255) / 255, + ((packed >>> 16) & 255) / 255, + ((packed >>> 24) & 255) / 255, + ]; +} +function glyphKey(cell) { + return `${cell.attributes & 5}/${cell.width}/${cell.text}`; +} +/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */ +function packGlyphs(glyphs, size, scale, initial = { x: 0, y: 0, rowHeight: 0 }) { + let { x, y, rowHeight } = initial; + const placements = []; + for (const [key, cell] of glyphs) { + const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4; + const height = Math.ceil(CELL_HEIGHT * scale) + 4; + if (width > size || height > size) + return null; + if (x + width > size) { + x = 0; + y += rowHeight; + rowHeight = 0; + } + if (y + height > size) + return null; + placements.push({ key, cell, x, y, width, height }); + x += width; + rowHeight = Math.max(rowHeight, height); + } + return { placements, shelf: { x, y, rowHeight } }; +} +/** WebGPU instanced quads; Canvas2D is used only to rasterize reusable glyphs. */ +export class TerminalRenderer { + canvas; + scale; + backingScale; + device; + fontConfiguration; + fontMetrics; + context; + images; + glyphs; + imageUploadBytes; + imagePayloadBytes; + glyphUploadBytes; + atlasRebuilds; + textureBytes; + instances; + instanceBuffer; + instanceBufferBytes; + disposed; + columns; + rows; + // Initialized by create() before the renderer can prepare or submit frames. + font; + format; + uniform; + sampler; + pipeline; + rasterCanvas; + raster; + atlas; + glyphKeyUnits = 0; + shelf = { x: 0, y: 0, rowHeight: 0 }; + canvasLimited = false; + width = 0; + height = 0; + quadCount = 0; + batches = []; + static async create(canvas, scale, onFatal, font) { + const normalizedFont = normalizeFont(font); + if (!self.isSecureContext) + throw new Error("WebGPU requires HTTPS or localhost"); + if (!navigator.gpu) + throw new Error("WebGPU is unavailable in this browser worker"); + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) + throw new Error("No WebGPU adapter is available; check browser GPU support"); + const device = await adapter.requestDevice(); + const renderer = new TerminalRenderer(canvas, scale, device, normalizedFont); + device.lost.then(info => { + if (!renderer.disposed) + onFatal(new Error(`WebGPU device lost: ${info.message || info.reason}`)); + }); + device.addEventListener("uncapturederror", event => onFatal(event.error)); + try { + await renderer.initialize(); + return renderer; + } + catch (error) { + renderer.dispose(); + throw error; + } + } + constructor(canvas, scale, device, font) { + if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) + throw new Error("Invalid backing scale"); + this.canvas = canvas; + this.scale = scale; + this.backingScale = scale; + this.device = device; + this.fontConfiguration = font; + this.fontMetrics = new Map(); + const context = canvas.getContext("webgpu"); + if (!context) + throw new Error("Could not create an OffscreenCanvas WebGPU context"); + this.context = context; + this.images = new Map(); + this.glyphs = new Map(); + this.imageUploadBytes = 0; + this.imagePayloadBytes = 0; + this.glyphUploadBytes = 0; + this.atlasRebuilds = 0; + this.textureBytes = 0; + this.instances = new Float32Array(4096 * STRIDE); + this.instanceBuffer = null; + this.instanceBufferBytes = 0; + this.disposed = false; + this.columns = 0; + this.rows = 0; + } + async initialize() { + this.font = await loadFont(this.fontConfiguration); + const device = this.device; + this.format = navigator.gpu.getPreferredCanvasFormat(); + this.context.configure({ device, format: this.format, alphaMode: "opaque" }); + this.uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + this.sampler = device.createSampler({ minFilter: "linear", magFilter: "linear" }); + const module = device.createShaderModule({ code: shader }); + this.pipeline = await device.createRenderPipelineAsync({ + layout: "auto", + vertex: { + module, + entryPoint: "vertex", + buffers: [{ + arrayStride: STRIDE * 4, + stepMode: "instance", + attributes: [ + { shaderLocation: 0, offset: 0, format: "float32x4" }, + { shaderLocation: 1, offset: 16, format: "float32x4" }, + { shaderLocation: 2, offset: 32, format: "float32x4" }, + { shaderLocation: 3, offset: 48, format: "float32" }, + ], + }], + }, + fragment: { + module, + entryPoint: "fragment", + targets: [{ + format: this.format, + blend: { + color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha" }, + alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }, + }, + }], + }, + primitive: { topology: "triangle-list" }, + }); + this.rasterCanvas = new OffscreenCanvas(1, 1); + const raster = this.rasterCanvas.getContext("2d", { willReadFrequently: true }); + if (!raster) + throw new Error("Worker glyph rasterization is unavailable"); + this.raster = raster; + this.resetAtlas(Math.min(2048, device.limits.maxTextureDimension2D)); + } + createTexture(width, height, label) { + if (width > this.device.limits.maxTextureDimension2D || height > this.device.limits.maxTextureDimension2D) { + throw new Error(`${label} exceeds the GPU texture dimension limit`); + } + const texture = this.device.createTexture({ + label, + size: [width, height], + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + }); + const bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: this.uniform } }, + { binding: 1, resource: texture.createView() }, + { binding: 2, resource: this.sampler }, + ], + }); + return { texture, bindGroup, width, height }; + } + resetAtlas(size) { + this.atlas?.texture.destroy(); + this.atlas = this.createTexture(size, size, "Glyph atlas"); + this.glyphs.clear(); + this.glyphKeyUnits = 0; + this.shelf = { x: 0, y: 0, rowHeight: 0 }; + } + resize(columns, rows, viewport) { + const width = columns * CELL_WIDTH; + const height = rows * CELL_HEIGHT; + const limit = this.device.limits.maxTextureDimension2D; + const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity); + this.canvasLimited = width * requested > limit || height * requested > limit; + this.backingScale = Math.min(requested, limit / width, limit / height); + const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale))); + const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale))); + if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight) + return; + this.columns = columns; + this.rows = rows; + this.width = width; + this.height = height; + this.canvas.width = backingWidth; + this.canvas.height = backingHeight; + this.device.queue.writeBuffer(this.uniform, 0, new Float32Array([width, height, 0, 0])); + } + /** Call only between submissions. Missing/over-budget resources terminate the session. */ + async updateImages(incoming, retainedKeys) { + const retained = new Set(retainedKeys); + const replacements = new Map(incoming.map(image => [image.key, image])); + let projectedBytes = 0; + for (const key of retained) { + const image = replacements.get(key) || this.images.get(key); + if (!image) + throw new Error(`Missing retained image resource: ${key}`); + projectedBytes += image.width * image.height * 4; + } + if (projectedBytes > LIMITS.textureBytes) + throw new Error("Retained images exceed the 256 MiB texture budget"); + for (const [key, image] of this.images) { + if (!retained.has(key) || replacements.has(key)) { + image.texture.destroy(); + this.textureBytes -= image.width * image.height * 4; + this.images.delete(key); + } + } + for (const image of incoming) { + const resource = this.createTexture(image.width, image.height, `Image ${image.key}`); + try { + if (image.format === "rgba") { + this.device.queue.writeTexture({ texture: resource.texture }, image.bytes, { bytesPerRow: image.width * 4, rowsPerImage: image.height }, [image.width, image.height]); + } + else { + // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions. + const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength); + if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a || + png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 || + png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) { + throw new Error(`PNG header dimensions do not match resource ${image.key}`); + } + const bitmap = await createImageBitmap(new Blob([image.bytes], { type: "image/png" }), { + premultiplyAlpha: "none", + colorSpaceConversion: "none", + }); + try { + if (bitmap.width !== image.width || bitmap.height !== image.height) + throw new Error("Decoded PNG dimension mismatch"); + this.device.queue.copyExternalImageToTexture({ source: bitmap }, { texture: resource.texture, premultipliedAlpha: false }, [image.width, image.height]); + } + finally { + bitmap.close(); + } + } + this.images.set(image.key, resource); + this.textureBytes += image.width * image.height * 4; + this.imageUploadBytes += image.width * image.height * 4; + this.imagePayloadBytes += image.byteLength; + } + catch (error) { + resource.texture.destroy(); + throw error; + } + } + } + prepareGlyphs(cells) { + const visible = new Map(); + for (const cell of cells) { + if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64)) + continue; + visible.set(glyphKey(cell), cell); + } + const keyUnits = (glyphs) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0); + if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) { + throw new Error("Visible glyph metadata exceeds the bounded glyph cache"); + } + const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key))); + if (!missing.size) + return; + const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS && + this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS; + let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null; + if (!plan) { + let size = this.atlas.width; + const maxSize = Math.min(4096, this.device.limits.maxTextureDimension2D); + while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) { + size = Math.min(size * 2, maxSize); + } + if (!plan) + throw new Error("Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale"); + this.resetAtlas(size); + this.atlasRebuilds++; + } + for (const placement of plan.placements) + this.uploadGlyph(placement); + this.shelf = plan.shelf; + } + uploadGlyph({ key, cell, x, y, width, height }) { + const scale = this.scale; + const raster = this.raster; + const style = cell.attributes & 5; + let metrics = this.fontMetrics.get(style); + if (!metrics) { + metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT); + this.fontMetrics.set(style, metrics); + } + this.rasterCanvas.width = width; + this.rasterCanvas.height = height; + raster.font = metrics.font; + raster.textBaseline = "alphabetic"; + raster.textAlign = "left"; + raster.fillStyle = "white"; + // One transform per font style, not per glyph: borders remain font outlines, + // and graphemes are clipped to their server-owned span without individual stretching. + raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline); + raster.fillText(cell.text, 0, 0); + const pixels = raster.getImageData(0, 0, width, height); + let colored = false; + for (let i = 0; i < pixels.data.length; i += 4) { + if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) { + colored = true; + break; + } + } + this.device.queue.writeTexture({ texture: this.atlas.texture, origin: [x, y] }, pixels.data, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]); + this.glyphUploadBytes += width * height * 4; + this.glyphs.set(key, { + colored, + u0: (x + 2) / this.atlas.width, + v0: (y + 2) / this.atlas.height, + u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width, + v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height, + }); + this.glyphKeyUnits += key.length; + } + /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */ + quad(resource, x, y, width, height, color, mode = 0, uv = [0, 0, 1, 1], clip = [0, 0, this.width, this.height]) { + if (width <= 0 || height <= 0 || color[3] <= 0) + return; + const left = Math.max(0, x, clip[0]); + const top = Math.max(0, y, clip[1]); + const right = Math.min(this.width, x + width, clip[0] + clip[2]); + const bottom = Math.min(this.height, y + height, clip[1] + clip[3]); + if (right <= left || bottom <= top) + return; + if (this.quadCount >= MAX_QUADS) + throw new Error("Frame exceeds bounded quad budget"); + const offset = this.quadCount * STRIDE; + if (offset + STRIDE > this.instances.length) { + const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE)); + grown.set(this.instances); + this.instances = grown; + } + const du = uv[2] - uv[0]; + const dv = uv[3] - uv[1]; + this.instances.set([ + left, top, right - left, bottom - top, + uv[0] + (left - x) / width * du, + uv[1] + (top - y) / height * dv, + uv[0] + (right - x) / width * du, + uv[1] + (bottom - y) / height * dv, + ...color, mode, 0, 0, 0, + ], offset); + const last = this.batches[this.batches.length - 1]; + if (last?.resource === resource) + last.count++; + else + this.batches.push({ resource, start: this.quadCount, count: 1 }); + this.quadCount++; + } + solid(x, y, width, height, color) { + this.quad(this.atlas, x, y, width, height, color); + } + placement(placement) { + const image = this.images.get(placement.key); + if (!image) + throw new Error(`Placement texture is missing: ${placement.key}`); + const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement; + if (!sw || !sh || !placement.width || !placement.height) + return; + // Clip out-of-texture source regions in destination space instead of stretching edge texels. + const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width; + const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height; + const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width; + const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height; + const left = Math.max(sourceLeft, placement.clipX); + const top = Math.max(sourceTop, placement.clipY); + const right = Math.min(sourceRight, placement.clipX + placement.clipWidth); + const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight); + this.quad(image, placement.x, placement.y, placement.width, placement.height, WHITE, 2, [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height], [left, top, Math.max(0, right - left), Math.max(0, bottom - top)]); + } + decorations(cell, x, y, width, foreground) { + if (cell.attributes & 128) + this.solid(x, y + 10, width, 1, foreground); + if (cell.attributes & 256) + this.solid(x, y + 1, width, 1, foreground); + const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0); + const color = rgba(cell.underlineColor); + if (style === 1) + this.solid(x, y + 18, width, 1, color); + else if (style === 2) { + this.solid(x, y + 16, width, 1, color); + this.solid(x, y + 18, width, 1, color); + } + else if (style === 3) { + for (let dx = 0; dx < width; dx++) { + this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color); + } + } + else if (style === 4 || style === 5) { + const step = style === 4 ? 2 : 5; + const segment = style === 4 ? 1 : 3; + for (let dx = 0; dx < width; dx += step) + this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color); + } + } + render(cells, metadata, blinkOn) { + const start = performance.now(); + this.quadCount = 0; + this.batches = []; + const placements = metadata.placements.map((placement, order) => ({ + placement, + order, + z: placement.kind === "sixel" ? -1 : placement.z, + })).sort((a, b) => a.z - b.z || a.order - b.order); + for (const item of placements) + if (item.z < -1073741824) + this.placement(item.placement); + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + if (!cell) + continue; + this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background)); + } + for (const item of placements) + if (item.z >= -1073741824 && item.z < 0) + this.placement(item.placement); + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn)) + continue; + const x = (i % this.columns) * CELL_WIDTH; + const y = Math.floor(i / this.columns) * CELL_HEIGHT; + const width = Math.min(cell.width * CELL_WIDTH, this.width - x); + const foreground = rgba(cell.foreground); + const glyph = this.glyphs.get(glyphKey(cell)); + if (glyph) { + const tint = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground; + this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT, tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]); + } + // Reverse and dim are already reflected in server-projected colors. + this.decorations(cell, x, y, width, foreground); + } + for (const item of placements) + if (item.z >= 0) + this.placement(item.placement); + const cursor = metadata.cursor; + const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1; + if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) { + const cell = cells[cursor.y * this.columns + cursor.x]; + const color = rgba(cell?.foreground ?? 0xffffffff); + const x = cursor.x * CELL_WIDTH; + const y = cursor.y * CELL_HEIGHT; + if (cursor.shape === 3 || cursor.shape === 4) + this.solid(x, y + 18, CELL_WIDTH, 2, color); + else if (cursor.shape === 5 || cursor.shape === 6) + this.solid(x, y, 2, CELL_HEIGHT, color); + else { + color[3] *= 0.55; + this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color); + } + } + const usedBytes = this.quadCount * STRIDE * 4; + if (!this.instanceBuffer || usedBytes > this.instanceBufferBytes) { + this.instanceBuffer?.destroy(); + this.instanceBufferBytes = Math.max(256, this.instances.byteLength); + this.instanceBuffer = this.device.createBuffer({ + size: this.instanceBufferBytes, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + } + if (usedBytes) + this.device.queue.writeBuffer(this.instanceBuffer, 0, this.instances, 0, this.quadCount * STRIDE); + const encoder = this.device.createCommandEncoder(); + const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: this.context.getCurrentTexture().createView(), + clearValue: { r: base[0], g: base[1], b: base[2], a: 1 }, + loadOp: "clear", + storeOp: "store", + }], + }); + pass.setPipeline(this.pipeline); + pass.setVertexBuffer(0, this.instanceBuffer); + for (const batch of this.batches) { + pass.setBindGroup(0, batch.resource.bindGroup); + pass.draw(6, batch.count, 0, batch.start); + } + pass.end(); + this.device.queue.submit([encoder.finish()]); + return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length }; + } + metrics() { + return { + fontFamily: this.font.family, + rasterScale: this.scale, + backingScale: this.backingScale, + backingWidth: this.canvas.width, + backingHeight: this.canvas.height, + imageCount: this.images.size, + textureBytes: this.textureBytes, + atlasGlyphs: this.glyphs.size, + atlasBytes: this.atlas.width * this.atlas.height * 4, + atlasRebuilds: this.atlasRebuilds, + imageUploadBytes: this.imageUploadBytes, + imagePayloadBytes: this.imagePayloadBytes, + glyphUploadBytes: this.glyphUploadBytes, + instanceBufferBytes: this.instanceBufferBytes, + }; + } + async idle() { + await this.device.queue.onSubmittedWorkDone(); + } + dispose() { + this.disposed = true; + for (const image of this.images.values()) + image.texture.destroy(); + this.images.clear(); + this.atlas?.texture.destroy(); + this.instanceBuffer?.destroy(); + this.uniform?.destroy(); + this.font?.dispose(); + this.fontMetrics.clear(); + this.context.unconfigure(); + this.device.destroy(); + } +} +//# sourceMappingURL=renderer.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map new file mode 100644 index 00000000000..530dcca1fd0 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map @@ -0,0 +1 @@ +{"version":3,"file":"renderer.js","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAY1E,MAAM,UAAU,GAAG,EAAE,CAAC;AACtB,MAAM,WAAW,GAAG,EAAE,CAAC;AACvB,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,CAAC;AAC9B,MAAM,UAAU,GAAG,KAAK,CAAC;AACzB,MAAM,mBAAmB,GAAG,IAAI,GAAG,IAAI,CAAC;AACxC,MAAM,MAAM,GAAG,EAAE,CAAC;AAClB,MAAM,KAAK,GAAY,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AAEpC,MAAM,MAAM,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwCxB,CAAC;AAEH,SAAS,IAAI,CAAC,MAAc;IAC1B,OAAO;QACL,CAAC,MAAM,GAAG,GAAG,CAAC,GAAG,GAAG;QACpB,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG;QAC5B,CAAC,CAAC,MAAM,KAAK,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG;QAC7B,CAAC,CAAC,MAAM,KAAK,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG;KAC9B,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,IAAkB;IAClC,OAAO,GAAG,IAAI,CAAC,UAAU,GAAG,CAAC,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;AAC7D,CAAC;AAED,4FAA4F;AAC5F,SAAS,UAAU,CAAC,MAAyC,EAAE,IAAY,EAAE,KAAa,EACxF,UAAiB,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE;IAC7C,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IAClC,MAAM,UAAU,GAAG,EAAE,CAAC;IACtB,KAAK,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,MAAM,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,UAAU,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QAClD,IAAI,KAAK,GAAG,IAAI,IAAI,MAAM,GAAG,IAAI;YAAE,OAAO,IAAI,CAAC;QAC/C,IAAI,CAAC,GAAG,KAAK,GAAG,IAAI,EAAE,CAAC;YAAC,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC,IAAI,SAAS,CAAC;YAAC,SAAS,GAAG,CAAC,CAAC;QAAC,CAAC;QAC/D,IAAI,CAAC,GAAG,MAAM,GAAG,IAAI;YAAE,OAAO,IAAI,CAAC;QACnC,UAAU,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QACpD,CAAC,IAAI,KAAK,CAAC;QACX,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IAC1C,CAAC;IACD,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,EAAE,CAAC;AACpD,CAAC;AAED,kFAAkF;AAClF,MAAM,OAAO,gBAAgB;IAC3B,MAAM,CAAkB;IACxB,KAAK,CAAS;IACd,YAAY,CAAS;IACrB,MAAM,CAAY;IAClB,iBAAiB,CAAiB;IAClC,WAAW,CAA2B;IACtC,OAAO,CAAmB;IAC1B,MAAM,CAA+B;IACrC,MAAM,CAAqB;IAC3B,gBAAgB,CAAS;IACzB,iBAAiB,CAAS;IAC1B,gBAAgB,CAAS;IACzB,aAAa,CAAS;IACtB,YAAY,CAAS;IACrB,SAAS,CAA4B;IACrC,cAAc,CAAmB;IACjC,mBAAmB,CAAS;IAC5B,QAAQ,CAAU;IAClB,OAAO,CAAS;IAChB,IAAI,CAAS;IACb,4EAA4E;IAC5E,IAAI,CAAc;IAClB,MAAM,CAAoB;IAC1B,OAAO,CAAa;IACpB,OAAO,CAAc;IACrB,QAAQ,CAAqB;IAC7B,YAAY,CAAmB;IAC/B,MAAM,CAAqC;IAC3C,KAAK,CAAmB;IACxB,aAAa,GAAG,CAAC,CAAC;IAClB,KAAK,GAAU,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;IAC5C,aAAa,GAAG,KAAK,CAAC;IACtB,KAAK,GAAG,CAAC,CAAC;IACV,MAAM,GAAG,CAAC,CAAC;IACX,SAAS,GAAG,CAAC,CAAC;IACd,OAAO,GAAY,EAAE,CAAC;IAEtB,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAuB,EAAE,KAAa,EAAE,OAA0C,EACpG,IAAkB;QAClB,MAAM,cAAc,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;QAC3C,IAAI,CAAC,IAAI,CAAC,eAAe;YAAE,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QACjF,IAAI,CAAC,SAAS,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,8CAA8C,CAAC,CAAC;QACpF,MAAM,OAAO,GAAG,MAAM,SAAS,CAAC,GAAG,CAAC,cAAc,EAAE,CAAC;QACrD,IAAI,CAAC,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,2DAA2D,CAAC,CAAC;QAC3F,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,aAAa,EAAE,CAAC;QAC7C,MAAM,QAAQ,GAAG,IAAI,gBAAgB,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,CAAC,CAAC;QAC7E,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;YACtB,IAAI,CAAC,QAAQ,CAAC,QAAQ;gBAAE,OAAO,CAAC,IAAI,KAAK,CAAC,uBAAuB,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACnG,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,gBAAgB,CAAC,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;QAC1E,IAAI,CAAC;YACH,MAAM,QAAQ,CAAC,UAAU,EAAE,CAAC;YAC5B,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,QAAQ,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,YAAY,MAAuB,EAAE,KAAa,EAAE,MAAiB,EAAE,IAAoB;QACzF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,GAAG,IAAI,KAAK,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;QAClG,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,YAAY,GAAG,KAAK,CAAC;QAC1B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAC;QAC9B,IAAI,CAAC,WAAW,GAAG,IAAI,GAAG,EAAE,CAAC;QAC7B,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;QAC5C,IAAI,CAAC,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;QACpF,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACxB,IAAI,CAAC,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACxB,IAAI,CAAC,gBAAgB,GAAG,CAAC,CAAC;QAC1B,IAAI,CAAC,iBAAiB,GAAG,CAAC,CAAC;QAC3B,IAAI,CAAC,gBAAgB,GAAG,CAAC,CAAC;QAC1B,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;QACvB,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;QACtB,IAAI,CAAC,SAAS,GAAG,IAAI,YAAY,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC;QACjD,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC;QAC3B,IAAI,CAAC,mBAAmB,GAAG,CAAC,CAAC;QAC7B,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;QACtB,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC;IAChB,CAAC;IAED,KAAK,CAAC,UAAU;QACd,IAAI,CAAC,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACnD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC3B,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,wBAAwB,EAAE,CAAC;QACvD,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC7E,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,cAAc,CAAC,OAAO,GAAG,cAAc,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC1G,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,aAAa,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAClF,MAAM,MAAM,GAAG,MAAM,CAAC,kBAAkB,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC3D,IAAI,CAAC,QAAQ,GAAG,MAAM,MAAM,CAAC,yBAAyB,CAAC;YACrD,MAAM,EAAE,MAAM;YACd,MAAM,EAAE;gBACN,MAAM;gBACN,UAAU,EAAE,QAAQ;gBACpB,OAAO,EAAE,CAAC;wBACR,WAAW,EAAE,MAAM,GAAG,CAAC;wBACvB,QAAQ,EAAE,UAAU;wBACpB,UAAU,EAAE;4BACV,EAAE,cAAc,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE;4BACrD,EAAE,cAAc,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE;4BACtD,EAAE,cAAc,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE;4BACtD,EAAE,cAAc,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE;yBACrD;qBACF,CAAC;aACH;YACD,QAAQ,EAAE;gBACR,MAAM;gBACN,UAAU,EAAE,UAAU;gBACtB,OAAO,EAAE,CAAC;wBACR,MAAM,EAAE,IAAI,CAAC,MAAM;wBACnB,KAAK,EAAE;4BACL,KAAK,EAAE,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE,qBAAqB,EAAE;4BACnE,KAAK,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE,qBAAqB,EAAE;yBAC9D;qBACF,CAAC;aACH;YACD,SAAS,EAAE,EAAE,QAAQ,EAAE,eAAe,EAAE;SACzC,CAAC,CAAC;QACH,IAAI,CAAC,YAAY,GAAG,IAAI,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,kBAAkB,EAAE,IAAI,EAAE,CAAC,CAAC;QAChF,IAAI,CAAC,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,2CAA2C,CAAC,CAAC;QAC1E,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,qBAAqB,CAAC,CAAC,CAAC;IACvE,CAAC;IAED,aAAa,CAAC,KAAa,EAAE,MAAc,EAAE,KAAa;QACxD,IAAI,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,qBAAqB,IAAI,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,qBAAqB,EAAE,CAAC;YAC1G,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,0CAA0C,CAAC,CAAC;QACtE,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC;YACxC,KAAK;YACL,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC;YACrB,MAAM,EAAE,YAAY;YACpB,KAAK,EAAE,eAAe,CAAC,eAAe,GAAG,eAAe,CAAC,QAAQ,GAAG,eAAe,CAAC,iBAAiB;SACtG,CAAC,CAAC;QACH,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC;YAC5C,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,kBAAkB,CAAC,CAAC,CAAC;YAC3C,OAAO,EAAE;gBACP,EAAE,OAAO,EAAE,CAAC,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,OAAO,EAAE,EAAE;gBAClD,EAAE,OAAO,EAAE,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,UAAU,EAAE,EAAE;gBAC9C,EAAE,OAAO,EAAE,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,OAAO,EAAE;aACvC;SACF,CAAC,CAAC;QACH,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAC/C,CAAC;IAED,UAAU,CAAC,IAAY;QACrB,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC;QAC9B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,EAAE,IAAI,EAAE,aAAa,CAAC,CAAC;QAC3D,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACpB,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;QACvB,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;IAC5C,CAAC;IAED,MAAM,CAAC,OAAe,EAAE,IAAY,EAAE,QAAuB;QAC3D,MAAM,KAAK,GAAG,OAAO,GAAG,UAAU,CAAC;QACnC,MAAM,MAAM,GAAG,IAAI,GAAG,WAAW,CAAC;QAClC,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,qBAAqB,CAAC;QACvD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;QACrI,IAAI,CAAC,aAAa,GAAG,KAAK,GAAG,SAAS,GAAG,KAAK,IAAI,MAAM,GAAG,SAAS,GAAG,KAAK,CAAC;QAC7E,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,GAAG,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,CAAC;QACvE,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QACxF,MAAM,aAAa,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAC1F,IAAI,IAAI,CAAC,OAAO,KAAK,OAAO,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,MAAM,CAAC,KAAK,KAAK,YAAY,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,aAAa;YAAE,OAAO;QACzI,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,MAAM,CAAC,KAAK,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,aAAa,CAAC;QACnC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,IAAI,YAAY,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1F,CAAC;IAED,0FAA0F;IAC1F,KAAK,CAAC,YAAY,CAAC,QAA+B,EAAE,YAA+B;QACjF,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,YAAY,CAAC,CAAC;QACvC,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;QACxE,IAAI,cAAc,GAAG,CAAC,CAAC;QACvB,KAAK,MAAM,GAAG,IAAI,QAAQ,EAAE,CAAC;YAC3B,MAAM,KAAK,GAAG,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC5D,IAAI,CAAC,KAAK;gBAAE,MAAM,IAAI,KAAK,CAAC,oCAAoC,GAAG,EAAE,CAAC,CAAC;YACvE,cAAc,IAAI,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;QACnD,CAAC;QACD,IAAI,cAAc,GAAG,MAAM,CAAC,YAAY;YAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;QAC/G,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACvC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;gBAChD,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;gBACxB,IAAI,CAAC,YAAY,IAAI,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;gBACpD,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;QACD,KAAK,MAAM,KAAK,IAAI,QAAQ,EAAE,CAAC;YAC7B,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,SAAS,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;YACrF,IAAI,CAAC;gBACH,IAAI,KAAK,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;oBAC5B,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,CAC5B,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,EAC7B,KAAK,CAAC,KAAK,EACX,EAAE,WAAW,EAAE,KAAK,CAAC,KAAK,GAAG,CAAC,EAAE,YAAY,EAAE,KAAK,CAAC,MAAM,EAAE,EAC5D,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAC5B,CAAC;gBACJ,CAAC;qBAAM,CAAC;oBACN,sFAAsF;oBACtF,MAAM,GAAG,GAAG,IAAI,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;oBAC7F,IAAI,GAAG,CAAC,UAAU,GAAG,EAAE,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,UAAU,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,UAAU;wBACzF,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,IAAI,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,UAAU;wBAC3D,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,KAAK,CAAC,KAAK,IAAI,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,KAAK,CAAC,MAAM,EAAE,CAAC;wBAC5E,MAAM,IAAI,KAAK,CAAC,+CAA+C,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;oBAC9E,CAAC;oBACD,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,EAAE;wBACrF,gBAAgB,EAAE,MAAM;wBACxB,oBAAoB,EAAE,MAAM;qBAC7B,CAAC,CAAC;oBACH,IAAI,CAAC;wBACH,IAAI,MAAM,CAAC,KAAK,KAAK,KAAK,CAAC,KAAK,IAAI,MAAM,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM;4BAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;wBACtH,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,0BAA0B,CAC1C,EAAE,MAAM,EAAE,MAAM,EAAE,EAClB,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,kBAAkB,EAAE,KAAK,EAAE,EACxD,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAC5B,CAAC;oBACJ,CAAC;4BAAS,CAAC;wBACT,MAAM,CAAC,KAAK,EAAE,CAAC;oBACjB,CAAC;gBACH,CAAC;gBACD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;gBACrC,IAAI,CAAC,YAAY,IAAI,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;gBACpD,IAAI,CAAC,gBAAgB,IAAI,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;gBACxD,IAAI,CAAC,iBAAiB,IAAI,KAAK,CAAC,UAAU,CAAC;YAC7C,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;gBAC3B,MAAM,KAAK,CAAC;YACd,CAAC;QACH,CAAC;IACH,CAAC;IAED,aAAa,CAAC,KAA4C;QACxD,MAAM,OAAO,GAAG,IAAI,GAAG,EAAwB,CAAC;QAChD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC;gBAAE,SAAS;YAClF,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC;QACpC,CAAC;QACD,MAAM,QAAQ,GAAG,CAAC,MAAyC,EAAE,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,KAAK,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACjI,IAAI,OAAO,CAAC,IAAI,GAAG,UAAU,IAAI,QAAQ,CAAC,OAAO,CAAC,GAAG,mBAAmB,EAAE,CAAC;YACzE,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;QAC5E,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAC/E,IAAI,CAAC,OAAO,CAAC,IAAI;YAAE,OAAO;QAC1B,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,UAAU;YAChE,IAAI,CAAC,aAAa,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,mBAAmB,CAAC;QAChE,IAAI,IAAI,GAAG,YAAY,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC/F,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,IAAI,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC;YAC5B,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,qBAAqB,CAAC,CAAC;YACzE,OAAO,CAAC,CAAC,IAAI,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,IAAI,GAAG,OAAO,EAAE,CAAC;gBACzE,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,EAAE,OAAO,CAAC,CAAC;YACrC,CAAC;YACD,IAAI,CAAC,IAAI;gBAAE,MAAM,IAAI,KAAK,CAAC,sFAAsF,CAAC,CAAC;YACnH,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;YACtB,IAAI,CAAC,aAAa,EAAE,CAAC;QACvB,CAAC;QACD,KAAK,MAAM,SAAS,IAAI,IAAI,CAAC,UAAU;YAAE,IAAI,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC;QACrE,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;IAC1B,CAAC;IAED,WAAW,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAkB;QAC5D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACzB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;QAClC,IAAI,OAAO,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,GAAG,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,WAAW,CAAC,CAAC;YAC1F,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QACvC,CAAC;QACD,IAAI,CAAC,YAAY,CAAC,KAAK,GAAG,KAAK,CAAC;QAChC,IAAI,CAAC,YAAY,CAAC,MAAM,GAAG,MAAM,CAAC;QAClC,MAAM,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAC3B,MAAM,CAAC,YAAY,GAAG,YAAY,CAAC;QACnC,MAAM,CAAC,SAAS,GAAG,MAAM,CAAC;QAC1B,MAAM,CAAC,SAAS,GAAG,OAAO,CAAC;QAC3B,6EAA6E;QAC7E,sFAAsF;QACtF,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;QACnF,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QACjC,MAAM,MAAM,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;QACxD,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;gBAC/G,OAAO,GAAG,IAAI,CAAC;gBACf,MAAM;YACR,CAAC;QACH,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,CAC5B,EAAE,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAC/C,MAAM,CAAC,IAAI,EACX,EAAE,WAAW,EAAE,KAAK,GAAG,CAAC,EAAE,YAAY,EAAE,MAAM,EAAE,EAChD,CAAC,KAAK,EAAE,MAAM,CAAC,CAChB,CAAC;QACF,IAAI,CAAC,gBAAgB,IAAI,KAAK,GAAG,MAAM,GAAG,CAAC,CAAC;QAC5C,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE;YACnB,OAAO;YACP,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK;YAC9B,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM;YAC/B,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,KAAK,GAAG,UAAU,GAAG,KAAK,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK;YAChE,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM;SACtD,CAAC,CAAC;QACH,IAAI,CAAC,aAAa,IAAI,GAAG,CAAC,MAAM,CAAC;IACnC,CAAC;IAED,sFAAsF;IACtF,IAAI,CAAC,QAAyB,EAAE,CAAS,EAAE,CAAS,EAAE,KAAa,EAAE,MAAc,EACjF,KAAc,EAAE,IAAI,GAAG,CAAC,EAAE,KAAc,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EACpD,OAAgB,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC;QAC/C,IAAI,KAAK,IAAI,CAAC,IAAI,MAAM,IAAI,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;YAAE,OAAO;QACvD,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACrC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACpC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACjE,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,GAAG,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACpE,IAAI,KAAK,IAAI,IAAI,IAAI,MAAM,IAAI,GAAG;YAAE,OAAO;QAC3C,IAAI,IAAI,CAAC,SAAS,IAAI,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;QACtF,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC;QACvC,IAAI,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;YAC5C,MAAM,KAAK,GAAG,IAAI,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC,CAAC,CAAC;YACxF,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAC1B,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;QACzB,CAAC;QACD,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC;QACzB,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC;QACzB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC;YACjB,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,IAAI,EAAE,MAAM,GAAG,GAAG;YACrC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,KAAK,GAAG,EAAE;YAC/B,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,MAAM,GAAG,EAAE;YAC/B,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,KAAK,GAAG,EAAE;YAChC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,MAAM,GAAG,EAAE;YAClC,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;SACxB,EAAE,MAAM,CAAC,CAAC;QACX,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACnD,IAAI,IAAI,EAAE,QAAQ,KAAK,QAAQ;YAAE,IAAI,CAAC,KAAK,EAAE,CAAC;;YACzC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC;QACtE,IAAI,CAAC,SAAS,EAAE,CAAC;IACnB,CAAC;IAED,KAAK,CAAC,CAAS,EAAE,CAAS,EAAE,KAAa,EAAE,MAAc,EAAE,KAAc;QACvE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IACpD,CAAC;IAED,SAAS,CAAC,SAAyB;QACjC,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAC7C,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,SAAS,CAAC,GAAG,EAAE,CAAC,CAAC;QAC9E,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,YAAY,EAAE,EAAE,EAAE,GAAG,SAAS,CAAC;QAClF,IAAI,CAAC,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,CAAC,SAAS,CAAC,MAAM;YAAE,OAAO;QAChE,6FAA6F;QAC7F,MAAM,UAAU,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,KAAK,CAAC;QACzE,MAAM,SAAS,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,MAAM,CAAC;QACzE,MAAM,WAAW,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,KAAK,CAAC;QACxF,MAAM,YAAY,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,MAAM,GAAG,EAAE,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,MAAM,CAAC;QAC3F,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;QACnD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;QACjD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,SAAS,CAAC,KAAK,GAAG,SAAS,CAAC,SAAS,CAAC,CAAC;QAC3E,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,SAAS,CAAC,KAAK,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;QAC9E,IAAI,CAAC,IAAI,CACP,KAAK,EAAE,SAAS,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,EAAE,SAAS,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,EAC5E,CAAC,EAAE,GAAG,KAAK,CAAC,KAAK,EAAE,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,EACxF,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,CAAC,CAClE,CAAC;IACJ,CAAC;IAED,WAAW,CAAC,IAAkB,EAAE,CAAS,EAAE,CAAS,EAAE,KAAa,EAAE,UAAmB;QACtF,IAAI,IAAI,CAAC,UAAU,GAAG,GAAG;YAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,CAAC;QACvE,IAAI,IAAI,CAAC,UAAU,GAAG,GAAG;YAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,CAAC;QACtE,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACnE,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QACxC,IAAI,KAAK,KAAK,CAAC;YAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;aACnD,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;YACrB,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;YACvC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;QACzC,CAAC;aAAM,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;YACvB,KAAK,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,KAAK,EAAE,EAAE,EAAE,EAAE,CAAC;gBAClC,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;YACzF,CAAC;QACH,CAAC;aAAM,IAAI,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACjC,MAAM,OAAO,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACpC,KAAK,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,KAAK,EAAE,EAAE,IAAI,IAAI;gBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,EAAE,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;QAC/G,CAAC;IACH,CAAC;IAED,MAAM,CAAC,KAA4C,EAAE,QAAuB,EAAE,OAAgB;QAC5F,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;QAChC,IAAI,CAAC,SAAS,GAAG,CAAC,CAAC;QACnB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;QAClB,MAAM,UAAU,GAAG,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;YAChE,SAAS;YACT,KAAK;YACL,CAAC,EAAE,SAAS,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;SACjD,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QACnD,KAAK,MAAM,IAAI,IAAI,UAAU;YAAE,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,UAAU;gBAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACxF,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACtB,IAAI,CAAC,IAAI;gBAAE,SAAS;YACpB,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAC1I,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,UAAU;YAAE,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC;gBAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACvG,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACtB,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;gBAAE,SAAS;YACrG,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,UAAU,CAAC;YAC1C,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,WAAW,CAAC;YACrD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,GAAG,UAAU,EAAE,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;YAChE,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YACzC,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;YAC9C,IAAI,KAAK,EAAE,CAAC;gBACV,MAAM,IAAI,GAAY,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC;gBACtG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,KAAK,GAAG,UAAU,EAAE,WAAW,EAC9D,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;YAC3E,CAAC;YACD,oEAAoE;YACpE,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC;QAClD,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,UAAU;YAAE,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC;gBAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC/E,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;QAC/B,MAAM,WAAW,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,GAAG,CAAC,KAAK,CAAC,CAAC;QACjE,IAAI,MAAM,CAAC,OAAO,IAAI,CAAC,CAAC,WAAW,IAAI,OAAO,CAAC,IAAI,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,IAAI,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YACrI,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;YACvD,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,EAAE,UAAU,IAAI,UAAU,CAAC,CAAC;YACnD,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,UAAU,CAAC;YAChC,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,WAAW,CAAC;YACjC,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC;gBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;iBACrF,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC;gBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,CAAC;iBACtF,CAAC;gBACJ,KAAK,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;gBACjB,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,CAAC,CAAC;YACnD,CAAC;QACH,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,GAAG,MAAM,GAAG,CAAC,CAAC;QAC9C,IAAI,CAAC,IAAI,CAAC,cAAc,IAAI,SAAS,GAAG,IAAI,CAAC,mBAAmB,EAAE,CAAC;YACjE,IAAI,CAAC,cAAc,EAAE,OAAO,EAAE,CAAC;YAC/B,IAAI,CAAC,mBAAmB,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;YACpE,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC;gBAC7C,IAAI,EAAE,IAAI,CAAC,mBAAmB;gBAC9B,KAAK,EAAE,cAAc,CAAC,MAAM,GAAG,cAAc,CAAC,QAAQ;aACvD,CAAC,CAAC;QACL,CAAC;QACD,IAAI,SAAS;YAAE,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,cAAc,EAAE,CAAC,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC,EAAE,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,CAAC;QACjH,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,oBAAoB,EAAE,CAAC;QACnD,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,iBAAiB,IAAI,KAAK,CAAC,CAAC,CAAC,EAAE,UAAU,IAAI,UAAU,CAAC,CAAC;QACpF,MAAM,IAAI,GAAG,OAAO,CAAC,eAAe,CAAC;YACnC,gBAAgB,EAAE,CAAC;oBACjB,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE,CAAC,UAAU,EAAE;oBACnD,UAAU,EAAE,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE;oBACxD,MAAM,EAAE,OAAO;oBACf,OAAO,EAAE,OAAO;iBACjB,CAAC;SACH,CAAC,CAAC;QACH,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAChC,IAAI,CAAC,eAAe,CAAC,CAAC,EAAE,IAAI,CAAC,cAAc,CAAC,CAAC;QAC7C,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YACjC,IAAI,CAAC,YAAY,CAAC,CAAC,EAAE,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;YAC/C,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAC5C,CAAC;QACD,IAAI,CAAC,GAAG,EAAE,CAAC;QACX,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QAC7C,OAAO,EAAE,KAAK,EAAE,WAAW,CAAC,GAAG,EAAE,GAAG,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;IACrG,CAAC;IAED,OAAO;QACL,OAAO;YACL,UAAU,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM;YAC5B,WAAW,EAAE,IAAI,CAAC,KAAK;YACvB,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK;YAC/B,aAAa,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;YACjC,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI;YAC5B,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,WAAW,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI;YAC7B,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC;YACpD,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;YACvC,iBAAiB,EAAE,IAAI,CAAC,iBAAiB;YACzC,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;YACvC,mBAAmB,EAAE,IAAI,CAAC,mBAAmB;SAC9C,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,IAAI;QACR,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,mBAAmB,EAAE,CAAC;IAChD,CAAC;IAED,OAAO;QACL,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE;YAAE,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QAClE,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACpB,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC;QAC9B,IAAI,CAAC,cAAc,EAAE,OAAO,EAAE,CAAC;QAC/B,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC;QACxB,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC;QACrB,IAAI,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;QACzB,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;IACxB,CAAC;CACF","sourcesContent":["import { LIMITS } from \"./protocol.js\";\nimport { loadFont, measureFont, normalizeFont } from \"./terminal-font.js\";\nimport type { FontMetrics, LoadedFont, NormalizedFont } from \"./terminal-font.js\";\nimport type { TerminalFont, TerminalSize } from \"./types.js\";\nimport type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from \"./wire-types.js\";\n\ntype Vector4 = [number, number, number, number];\ninterface TextureResource { texture: GPUTexture; bindGroup: GPUBindGroup; width: number; height: number }\ninterface Shelf { x: number; y: number; rowHeight: number }\ninterface GlyphPlacement { key: string; cell: TerminalCell; x: number; y: number; width: number; height: number }\ninterface Glyph { colored: boolean; u0: number; v0: number; u1: number; v1: number }\ninterface Batch { resource: TextureResource; start: number; count: number }\n\nconst CELL_WIDTH = 10;\nconst CELL_HEIGHT = 20;\nconst MAX_QUADS = 1024 * 1024;\nconst MAX_GLYPHS = 16384;\nconst MAX_GLYPH_KEY_UNITS = 1024 * 1024;\nconst STRIDE = 16;\nconst WHITE: Vector4 = [1, 1, 1, 1];\n\nconst shader = /* wgsl */ `\nstruct Viewport { size: vec2f, padding: vec2f }\n@group(0) @binding(0) var viewport: Viewport;\n@group(0) @binding(1) var image: texture_2d;\n@group(0) @binding(2) var imageSampler: sampler;\n\nstruct VertexOut {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n @location(1) color: vec4f,\n @location(2) @interpolate(flat) mode: f32,\n}\n\n@vertex fn vertex(\n @builtin(vertex_index) index: u32,\n @location(0) rect: vec4f,\n @location(1) uvRect: vec4f,\n @location(2) color: vec4f,\n @location(3) mode: f32,\n) -> VertexOut {\n let corners = array(\n vec2f(0, 0), vec2f(1, 0), vec2f(0, 1),\n vec2f(0, 1), vec2f(1, 0), vec2f(1, 1)\n );\n let corner = corners[index];\n let position = rect.xy + corner * rect.zw;\n var out: VertexOut;\n out.position = vec4f(position / viewport.size * vec2f(2, -2) + vec2f(-1, 1), 0, 1);\n out.uv = mix(uvRect.xy, uvRect.zw, corner);\n out.color = color;\n out.mode = mode;\n return out;\n}\n\n@fragment fn fragment(in: VertexOut) -> @location(0) vec4f {\n // Explicit LOD avoids derivative-uniformity requirements across solid/mask/image batches.\n let texel = textureSampleLevel(image, imageSampler, in.uv, 0);\n if (in.mode < 0.5) { return in.color; }\n if (in.mode < 1.5) { return vec4f(in.color.rgb, in.color.a * texel.a); }\n return texel * in.color;\n}`;\n\nfunction rgba(packed: number): Vector4 {\n return [\n (packed & 255) / 255,\n ((packed >>> 8) & 255) / 255,\n ((packed >>> 16) & 255) / 255,\n ((packed >>> 24) & 255) / 255,\n ];\n}\n\nfunction glyphKey(cell: TerminalCell): string {\n return `${cell.attributes & 5}/${cell.width}/${cell.text}`;\n}\n\n/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */\nfunction packGlyphs(glyphs: ReadonlyMap, size: number, scale: number,\n initial: Shelf = { x: 0, y: 0, rowHeight: 0 }): { placements: GlyphPlacement[]; shelf: Shelf } | null {\n let { x, y, rowHeight } = initial;\n const placements = [];\n for (const [key, cell] of glyphs) {\n const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4;\n const height = Math.ceil(CELL_HEIGHT * scale) + 4;\n if (width > size || height > size) return null;\n if (x + width > size) { x = 0; y += rowHeight; rowHeight = 0; }\n if (y + height > size) return null;\n placements.push({ key, cell, x, y, width, height });\n x += width;\n rowHeight = Math.max(rowHeight, height);\n }\n return { placements, shelf: { x, y, rowHeight } };\n}\n\n/** WebGPU instanced quads; Canvas2D is used only to rasterize reusable glyphs. */\nexport class TerminalRenderer {\n canvas: OffscreenCanvas;\n scale: number;\n backingScale: number;\n device: GPUDevice;\n fontConfiguration: NormalizedFont;\n fontMetrics: Map;\n context: GPUCanvasContext;\n images: Map;\n glyphs: Map;\n imageUploadBytes: number;\n imagePayloadBytes: number;\n glyphUploadBytes: number;\n atlasRebuilds: number;\n textureBytes: number;\n instances: Float32Array;\n instanceBuffer: GPUBuffer | null;\n instanceBufferBytes: number;\n disposed: boolean;\n columns: number;\n rows: number;\n // Initialized by create() before the renderer can prepare or submit frames.\n font!: LoadedFont;\n format!: GPUTextureFormat;\n uniform!: GPUBuffer;\n sampler!: GPUSampler;\n pipeline!: GPURenderPipeline;\n rasterCanvas!: OffscreenCanvas;\n raster!: OffscreenCanvasRenderingContext2D;\n atlas!: TextureResource;\n glyphKeyUnits = 0;\n shelf: Shelf = { x: 0, y: 0, rowHeight: 0 };\n canvasLimited = false;\n width = 0;\n height = 0;\n quadCount = 0;\n batches: Batch[] = [];\n\n static async create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error | GPUError) => void,\n font: TerminalFont): Promise {\n const normalizedFont = normalizeFont(font);\n if (!self.isSecureContext) throw new Error(\"WebGPU requires HTTPS or localhost\");\n if (!navigator.gpu) throw new Error(\"WebGPU is unavailable in this browser worker\");\n const adapter = await navigator.gpu.requestAdapter();\n if (!adapter) throw new Error(\"No WebGPU adapter is available; check browser GPU support\");\n const device = await adapter.requestDevice();\n const renderer = new TerminalRenderer(canvas, scale, device, normalizedFont);\n device.lost.then(info => {\n if (!renderer.disposed) onFatal(new Error(`WebGPU device lost: ${info.message || info.reason}`));\n });\n device.addEventListener(\"uncapturederror\", event => onFatal(event.error));\n try {\n await renderer.initialize();\n return renderer;\n } catch (error) {\n renderer.dispose();\n throw error;\n }\n }\n\n constructor(canvas: OffscreenCanvas, scale: number, device: GPUDevice, font: NormalizedFont) {\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n this.canvas = canvas;\n this.scale = scale;\n this.backingScale = scale;\n this.device = device;\n this.fontConfiguration = font;\n this.fontMetrics = new Map();\n const context = canvas.getContext(\"webgpu\");\n if (!context) throw new Error(\"Could not create an OffscreenCanvas WebGPU context\");\n this.context = context;\n this.images = new Map();\n this.glyphs = new Map();\n this.imageUploadBytes = 0;\n this.imagePayloadBytes = 0;\n this.glyphUploadBytes = 0;\n this.atlasRebuilds = 0;\n this.textureBytes = 0;\n this.instances = new Float32Array(4096 * STRIDE);\n this.instanceBuffer = null;\n this.instanceBufferBytes = 0;\n this.disposed = false;\n this.columns = 0;\n this.rows = 0;\n }\n\n async initialize() {\n this.font = await loadFont(this.fontConfiguration);\n const device = this.device;\n this.format = navigator.gpu.getPreferredCanvasFormat();\n this.context.configure({ device, format: this.format, alphaMode: \"opaque\" });\n this.uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });\n this.sampler = device.createSampler({ minFilter: \"linear\", magFilter: \"linear\" });\n const module = device.createShaderModule({ code: shader });\n this.pipeline = await device.createRenderPipelineAsync({\n layout: \"auto\",\n vertex: {\n module,\n entryPoint: \"vertex\",\n buffers: [{\n arrayStride: STRIDE * 4,\n stepMode: \"instance\",\n attributes: [\n { shaderLocation: 0, offset: 0, format: \"float32x4\" },\n { shaderLocation: 1, offset: 16, format: \"float32x4\" },\n { shaderLocation: 2, offset: 32, format: \"float32x4\" },\n { shaderLocation: 3, offset: 48, format: \"float32\" },\n ],\n }],\n },\n fragment: {\n module,\n entryPoint: \"fragment\",\n targets: [{\n format: this.format,\n blend: {\n color: { srcFactor: \"src-alpha\", dstFactor: \"one-minus-src-alpha\" },\n alpha: { srcFactor: \"one\", dstFactor: \"one-minus-src-alpha\" },\n },\n }],\n },\n primitive: { topology: \"triangle-list\" },\n });\n this.rasterCanvas = new OffscreenCanvas(1, 1);\n const raster = this.rasterCanvas.getContext(\"2d\", { willReadFrequently: true });\n if (!raster) throw new Error(\"Worker glyph rasterization is unavailable\");\n this.raster = raster;\n this.resetAtlas(Math.min(2048, device.limits.maxTextureDimension2D));\n }\n\n createTexture(width: number, height: number, label: string): TextureResource {\n if (width > this.device.limits.maxTextureDimension2D || height > this.device.limits.maxTextureDimension2D) {\n throw new Error(`${label} exceeds the GPU texture dimension limit`);\n }\n const texture = this.device.createTexture({\n label,\n size: [width, height],\n format: \"rgba8unorm\",\n usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,\n });\n const bindGroup = this.device.createBindGroup({\n layout: this.pipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: { buffer: this.uniform } },\n { binding: 1, resource: texture.createView() },\n { binding: 2, resource: this.sampler },\n ],\n });\n return { texture, bindGroup, width, height };\n }\n\n resetAtlas(size: number): void {\n this.atlas?.texture.destroy();\n this.atlas = this.createTexture(size, size, \"Glyph atlas\");\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.shelf = { x: 0, y: 0, rowHeight: 0 };\n }\n\n resize(columns: number, rows: number, viewport?: TerminalSize): void {\n const width = columns * CELL_WIDTH;\n const height = rows * CELL_HEIGHT;\n const limit = this.device.limits.maxTextureDimension2D;\n const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity);\n this.canvasLimited = width * requested > limit || height * requested > limit;\n this.backingScale = Math.min(requested, limit / width, limit / height);\n const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale)));\n const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale)));\n if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight) return;\n this.columns = columns;\n this.rows = rows;\n this.width = width;\n this.height = height;\n this.canvas.width = backingWidth;\n this.canvas.height = backingHeight;\n this.device.queue.writeBuffer(this.uniform, 0, new Float32Array([width, height, 0, 0]));\n }\n\n /** Call only between submissions. Missing/over-budget resources terminate the session. */\n async updateImages(incoming: readonly FrameImage[], retainedKeys: readonly string[]): Promise {\n const retained = new Set(retainedKeys);\n const replacements = new Map(incoming.map(image => [image.key, image]));\n let projectedBytes = 0;\n for (const key of retained) {\n const image = replacements.get(key) || this.images.get(key);\n if (!image) throw new Error(`Missing retained image resource: ${key}`);\n projectedBytes += image.width * image.height * 4;\n }\n if (projectedBytes > LIMITS.textureBytes) throw new Error(\"Retained images exceed the 256 MiB texture budget\");\n for (const [key, image] of this.images) {\n if (!retained.has(key) || replacements.has(key)) {\n image.texture.destroy();\n this.textureBytes -= image.width * image.height * 4;\n this.images.delete(key);\n }\n }\n for (const image of incoming) {\n const resource = this.createTexture(image.width, image.height, `Image ${image.key}`);\n try {\n if (image.format === \"rgba\") {\n this.device.queue.writeTexture(\n { texture: resource.texture },\n image.bytes,\n { bytesPerRow: image.width * 4, rowsPerImage: image.height },\n [image.width, image.height],\n );\n } else {\n // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions.\n const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength);\n if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a ||\n png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 ||\n png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) {\n throw new Error(`PNG header dimensions do not match resource ${image.key}`);\n }\n const bitmap = await createImageBitmap(new Blob([image.bytes], { type: \"image/png\" }), {\n premultiplyAlpha: \"none\",\n colorSpaceConversion: \"none\",\n });\n try {\n if (bitmap.width !== image.width || bitmap.height !== image.height) throw new Error(\"Decoded PNG dimension mismatch\");\n this.device.queue.copyExternalImageToTexture(\n { source: bitmap },\n { texture: resource.texture, premultipliedAlpha: false },\n [image.width, image.height],\n );\n } finally {\n bitmap.close();\n }\n }\n this.images.set(image.key, resource);\n this.textureBytes += image.width * image.height * 4;\n this.imageUploadBytes += image.width * image.height * 4;\n this.imagePayloadBytes += image.byteLength;\n } catch (error) {\n resource.texture.destroy();\n throw error;\n }\n }\n }\n\n prepareGlyphs(cells: readonly (TerminalCell | undefined)[]): void {\n const visible = new Map();\n for (const cell of cells) {\n if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64)) continue;\n visible.set(glyphKey(cell), cell);\n }\n const keyUnits = (glyphs: ReadonlyMap) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0);\n if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) {\n throw new Error(\"Visible glyph metadata exceeds the bounded glyph cache\");\n }\n const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key)));\n if (!missing.size) return;\n const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS &&\n this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS;\n let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null;\n if (!plan) {\n let size = this.atlas.width;\n const maxSize = Math.min(4096, this.device.limits.maxTextureDimension2D);\n while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) {\n size = Math.min(size * 2, maxSize);\n }\n if (!plan) throw new Error(\"Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale\");\n this.resetAtlas(size);\n this.atlasRebuilds++;\n }\n for (const placement of plan.placements) this.uploadGlyph(placement);\n this.shelf = plan.shelf;\n }\n\n uploadGlyph({ key, cell, x, y, width, height }: GlyphPlacement): void {\n const scale = this.scale;\n const raster = this.raster;\n const style = cell.attributes & 5;\n let metrics = this.fontMetrics.get(style);\n if (!metrics) {\n metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT);\n this.fontMetrics.set(style, metrics);\n }\n this.rasterCanvas.width = width;\n this.rasterCanvas.height = height;\n raster.font = metrics.font;\n raster.textBaseline = \"alphabetic\";\n raster.textAlign = \"left\";\n raster.fillStyle = \"white\";\n // One transform per font style, not per glyph: borders remain font outlines,\n // and graphemes are clipped to their server-owned span without individual stretching.\n raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline);\n raster.fillText(cell.text, 0, 0);\n const pixels = raster.getImageData(0, 0, width, height);\n let colored = false;\n for (let i = 0; i < pixels.data.length; i += 4) {\n if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) {\n colored = true;\n break;\n }\n }\n this.device.queue.writeTexture(\n { texture: this.atlas.texture, origin: [x, y] },\n pixels.data,\n { bytesPerRow: width * 4, rowsPerImage: height },\n [width, height],\n );\n this.glyphUploadBytes += width * height * 4;\n this.glyphs.set(key, {\n colored,\n u0: (x + 2) / this.atlas.width,\n v0: (y + 2) / this.atlas.height,\n u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width,\n v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height,\n });\n this.glyphKeyUnits += key.length;\n }\n\n /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */\n quad(resource: TextureResource, x: number, y: number, width: number, height: number,\n color: Vector4, mode = 0, uv: Vector4 = [0, 0, 1, 1],\n clip: Vector4 = [0, 0, this.width, this.height]): void {\n if (width <= 0 || height <= 0 || color[3] <= 0) return;\n const left = Math.max(0, x, clip[0]);\n const top = Math.max(0, y, clip[1]);\n const right = Math.min(this.width, x + width, clip[0] + clip[2]);\n const bottom = Math.min(this.height, y + height, clip[1] + clip[3]);\n if (right <= left || bottom <= top) return;\n if (this.quadCount >= MAX_QUADS) throw new Error(\"Frame exceeds bounded quad budget\");\n const offset = this.quadCount * STRIDE;\n if (offset + STRIDE > this.instances.length) {\n const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE));\n grown.set(this.instances);\n this.instances = grown;\n }\n const du = uv[2] - uv[0];\n const dv = uv[3] - uv[1];\n this.instances.set([\n left, top, right - left, bottom - top,\n uv[0] + (left - x) / width * du,\n uv[1] + (top - y) / height * dv,\n uv[0] + (right - x) / width * du,\n uv[1] + (bottom - y) / height * dv,\n ...color, mode, 0, 0, 0,\n ], offset);\n const last = this.batches[this.batches.length - 1];\n if (last?.resource === resource) last.count++;\n else this.batches.push({ resource, start: this.quadCount, count: 1 });\n this.quadCount++;\n }\n\n solid(x: number, y: number, width: number, height: number, color: Vector4): void {\n this.quad(this.atlas, x, y, width, height, color);\n }\n\n placement(placement: ImagePlacement): void {\n const image = this.images.get(placement.key);\n if (!image) throw new Error(`Placement texture is missing: ${placement.key}`);\n const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement;\n if (!sw || !sh || !placement.width || !placement.height) return;\n // Clip out-of-texture source regions in destination space instead of stretching edge texels.\n const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width;\n const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height;\n const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width;\n const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height;\n const left = Math.max(sourceLeft, placement.clipX);\n const top = Math.max(sourceTop, placement.clipY);\n const right = Math.min(sourceRight, placement.clipX + placement.clipWidth);\n const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight);\n this.quad(\n image, placement.x, placement.y, placement.width, placement.height, WHITE, 2,\n [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height],\n [left, top, Math.max(0, right - left), Math.max(0, bottom - top)],\n );\n }\n\n decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void {\n if (cell.attributes & 128) this.solid(x, y + 10, width, 1, foreground);\n if (cell.attributes & 256) this.solid(x, y + 1, width, 1, foreground);\n const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0);\n const color = rgba(cell.underlineColor);\n if (style === 1) this.solid(x, y + 18, width, 1, color);\n else if (style === 2) {\n this.solid(x, y + 16, width, 1, color);\n this.solid(x, y + 18, width, 1, color);\n } else if (style === 3) {\n for (let dx = 0; dx < width; dx++) {\n this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color);\n }\n } else if (style === 4 || style === 5) {\n const step = style === 4 ? 2 : 5;\n const segment = style === 4 ? 1 : 3;\n for (let dx = 0; dx < width; dx += step) this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color);\n }\n }\n\n render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean) {\n const start = performance.now();\n this.quadCount = 0;\n this.batches = [];\n const placements = metadata.placements.map((placement, order) => ({\n placement,\n order,\n z: placement.kind === \"sixel\" ? -1 : placement.z,\n })).sort((a, b) => a.z - b.z || a.order - b.order);\n for (const item of placements) if (item.z < -1073741824) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell) continue;\n this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background));\n }\n for (const item of placements) if (item.z >= -1073741824 && item.z < 0) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn)) continue;\n const x = (i % this.columns) * CELL_WIDTH;\n const y = Math.floor(i / this.columns) * CELL_HEIGHT;\n const width = Math.min(cell.width * CELL_WIDTH, this.width - x);\n const foreground = rgba(cell.foreground);\n const glyph = this.glyphs.get(glyphKey(cell));\n if (glyph) {\n const tint: Vector4 = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground;\n this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT,\n tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]);\n }\n // Reverse and dim are already reflected in server-projected colors.\n this.decorations(cell, x, y, width, foreground);\n }\n for (const item of placements) if (item.z >= 0) this.placement(item.placement);\n const cursor = metadata.cursor;\n const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1;\n if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) {\n const cell = cells[cursor.y * this.columns + cursor.x];\n const color = rgba(cell?.foreground ?? 0xffffffff);\n const x = cursor.x * CELL_WIDTH;\n const y = cursor.y * CELL_HEIGHT;\n if (cursor.shape === 3 || cursor.shape === 4) this.solid(x, y + 18, CELL_WIDTH, 2, color);\n else if (cursor.shape === 5 || cursor.shape === 6) this.solid(x, y, 2, CELL_HEIGHT, color);\n else {\n color[3] *= 0.55;\n this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color);\n }\n }\n const usedBytes = this.quadCount * STRIDE * 4;\n if (!this.instanceBuffer || usedBytes > this.instanceBufferBytes) {\n this.instanceBuffer?.destroy();\n this.instanceBufferBytes = Math.max(256, this.instances.byteLength);\n this.instanceBuffer = this.device.createBuffer({\n size: this.instanceBufferBytes,\n usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,\n });\n }\n if (usedBytes) this.device.queue.writeBuffer(this.instanceBuffer, 0, this.instances, 0, this.quadCount * STRIDE);\n const encoder = this.device.createCommandEncoder();\n const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000);\n const pass = encoder.beginRenderPass({\n colorAttachments: [{\n view: this.context.getCurrentTexture().createView(),\n clearValue: { r: base[0], g: base[1], b: base[2], a: 1 },\n loadOp: \"clear\",\n storeOp: \"store\",\n }],\n });\n pass.setPipeline(this.pipeline);\n pass.setVertexBuffer(0, this.instanceBuffer);\n for (const batch of this.batches) {\n pass.setBindGroup(0, batch.resource.bindGroup);\n pass.draw(6, batch.count, 0, batch.start);\n }\n pass.end();\n this.device.queue.submit([encoder.finish()]);\n return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length };\n }\n\n metrics() {\n return {\n fontFamily: this.font.family,\n rasterScale: this.scale,\n backingScale: this.backingScale,\n backingWidth: this.canvas.width,\n backingHeight: this.canvas.height,\n imageCount: this.images.size,\n textureBytes: this.textureBytes,\n atlasGlyphs: this.glyphs.size,\n atlasBytes: this.atlas.width * this.atlas.height * 4,\n atlasRebuilds: this.atlasRebuilds,\n imageUploadBytes: this.imageUploadBytes,\n imagePayloadBytes: this.imagePayloadBytes,\n glyphUploadBytes: this.glyphUploadBytes,\n instanceBufferBytes: this.instanceBufferBytes,\n };\n }\n\n async idle() {\n await this.device.queue.onSubmittedWorkDone();\n }\n\n dispose() {\n this.disposed = true;\n for (const image of this.images.values()) image.texture.destroy();\n this.images.clear();\n this.atlas?.texture.destroy();\n this.instanceBuffer?.destroy();\n this.uniform?.destroy();\n this.font?.dispose();\n this.fontMetrics.clear();\n this.context.unconfigure();\n this.device.destroy();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.d.ts new file mode 100644 index 00000000000..68ff0db3197 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.d.ts @@ -0,0 +1,36 @@ +import type { MouseTrackingMode, SelectionMode, TerminalPoint, TerminalSelection } from "./types.js"; +import type { CellPosition } from "./wire-types.js"; +export interface GestureState { + tracking: MouseTrackingMode; + historical?: boolean; + readOnly?: boolean; + selection?: Pick; +} +export type GestureStart = { + owner: "app"; +} | { + owner: "local"; + mode: SelectionMode; + extend: boolean; +}; +type PointEvent = Pick; +export declare function cellPoint(event: PointEvent, bounds: Pick, columns: number, rows: number, clamp?: boolean): CellPosition | null; +export declare class WheelAccumulator { + x: number; + y: number; + reset(): void; + take(event: Pick, bounds: Pick, rows: number): TerminalPoint; +} +/** Ownership and granularity are latched, independent of subsequent modifiers. */ +export declare class SelectionGesture { + owner: "local" | "app" | null; + mode: SelectionMode; + endpoint: TerminalPoint | null; + begin(event: Pick, point: TerminalPoint, { tracking, historical, readOnly, selection }: GestureState): GestureStart | null; + move(point: TerminalPoint): TerminalPoint | null; + scrollPoint(point: TerminalPoint): TerminalPoint | undefined; + wheelOwner(event: Pick, { tracking, historical, readOnly }: GestureState): "local" | "app"; + end(): void; +} +export {}; +//# sourceMappingURL=selection-input.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.d.ts.map new file mode 100644 index 00000000000..93db0635181 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"selection-input.d.ts","sourceRoot":"","sources":["../src/selection-input.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACrG,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpD,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,IAAI,CAAC,iBAAiB,EAAE,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC;CACtE;AACD,MAAM,MAAM,YAAY,GAAG;IAAE,KAAK,EAAE,KAAK,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;AACvG,KAAK,UAAU,GAAG,IAAI,CAAC,UAAU,EAAE,SAAS,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,GAAG,SAAS,CAAC,CAAC;AAE9F,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC,EACrG,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,UAAQ,GAAG,YAAY,GAAG,IAAI,CAUnE;AAED,qBAAa,gBAAgB;IAC3B,CAAC,SAAK;IACN,CAAC,SAAK;IACN,KAAK;IACL,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa;CAY/H;AAED,kFAAkF;AAClF,qBAAa,gBAAgB;IAC3B,KAAK,EAAE,OAAO,GAAG,KAAK,GAAG,IAAI,CAAQ;IACrC,IAAI,EAAE,aAAa,CAAe;IAClC,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAQ;IACtC,KAAK,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,CAAC,EAAE,KAAK,EAAE,aAAa,EAC9F,EAAE,QAAQ,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,YAAY,GAAG,YAAY,GAAG,IAAI;IAYnF,IAAI,CAAC,KAAK,EAAE,aAAa,GAAG,aAAa,GAAG,IAAI;IAKhD,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,aAAa,GAAG,SAAS;IAK5D,UAAU,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,UAAU,CAAC,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE,YAAY,GAAG,OAAO,GAAG,KAAK;IAGlH,GAAG;CACJ"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.js new file mode 100644 index 00000000000..76fdefd15d7 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.js @@ -0,0 +1,69 @@ +export function cellPoint(event, bounds, columns, rows, clamp = false) { + if (!(bounds.width > 0) || !(bounds.height > 0)) + return null; + const x = Math.floor((event.clientX - bounds.left) * columns / bounds.width); + const y = Math.floor((event.clientY - bounds.top) * rows / bounds.height); + if (!clamp && (x < 0 || y < 0 || x >= columns || y >= rows)) + return null; + return { + x: Math.max(0, Math.min(columns - 1, x)), + y: Math.max(0, Math.min(rows - 1, y)), + shift: !!event.shiftKey, alt: !!event.altKey, ctrl: !!event.ctrlKey + }; +} +export class WheelAccumulator { + x = 0; + y = 0; + reset() { this.x = this.y = 0; } + take(event, bounds, rows) { + const cellHeight = bounds.height / rows; + if (!(cellHeight > 0)) + return { x: 0, y: 0 }; + const unit = event.deltaMode === 1 ? 1 : event.deltaMode === 2 ? rows : 1 / cellHeight; + this.x += event.deltaX * unit; + this.y += event.deltaY * unit; + const x = Math.trunc(this.x); + const y = Math.trunc(this.y); + this.x -= x; + this.y -= y; + return { x: Math.max(-32, Math.min(32, x)), y: Math.max(-32, Math.min(32, y)) }; + } +} +/** Ownership and granularity are latched, independent of subsequent modifiers. */ +export class SelectionGesture { + owner = null; + mode = "character"; + endpoint = null; + begin(event, point, { tracking, historical, readOnly, selection }) { + if (this.owner) + return null; + const local = historical || readOnly || !tracking || event.shiftKey; + if (local && event.button !== 0) + return null; + this.owner = local ? "local" : "app"; + if (!local) + return { owner: "app" }; + const extend = !!event.shiftKey && !!selection && ["valid", "pending"].includes(selection.status) && selection.canExtend !== false; + this.mode = extend && selection ? selection.mode : event.altKey ? "rectangle" + : event.detail >= 3 ? "line" : event.detail === 2 ? "word" : "character"; + this.endpoint = { x: point.x, y: point.y }; + return { owner: this.owner, mode: this.mode, extend }; + } + move(point) { + if (this.owner !== "local") + return null; + this.endpoint = { x: point.x, y: point.y }; + return { ...this.endpoint }; + } + scrollPoint(point) { + if (this.owner !== "local") + return undefined; + // A wheel cannot widen a rectangle; only actual pointer movement can. + return { x: this.mode === "rectangle" && this.endpoint ? this.endpoint.x : point.x, y: point.y }; + } + wheelOwner(event, { tracking, historical, readOnly }) { + return this.owner ?? (historical || readOnly || !tracking || event.shiftKey ? "local" : "app"); + } + end() { this.owner = null; } +} +//# sourceMappingURL=selection-input.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.js.map new file mode 100644 index 00000000000..9f6a51bb6e8 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-input.js.map @@ -0,0 +1 @@ +{"version":3,"file":"selection-input.js","sourceRoot":"","sources":["../src/selection-input.ts"],"names":[],"mappings":"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","sourcesContent":["import type { MouseTrackingMode, SelectionMode, TerminalPoint, TerminalSelection } from \"./types.js\";\nimport type { CellPosition } from \"./wire-types.js\";\n\nexport interface GestureState {\n tracking: MouseTrackingMode;\n historical?: boolean;\n readOnly?: boolean;\n selection?: Pick;\n}\nexport type GestureStart = { owner: \"app\" } | { owner: \"local\"; mode: SelectionMode; extend: boolean };\ntype PointEvent = Pick;\n\nexport function cellPoint(event: PointEvent, bounds: Pick,\n columns: number, rows: number, clamp = false): CellPosition | null {\n if (!(bounds.width > 0) || !(bounds.height > 0)) return null;\n const x = Math.floor((event.clientX - bounds.left) * columns / bounds.width);\n const y = Math.floor((event.clientY - bounds.top) * rows / bounds.height);\n if (!clamp && (x < 0 || y < 0 || x >= columns || y >= rows)) return null;\n return {\n x: Math.max(0, Math.min(columns - 1, x)),\n y: Math.max(0, Math.min(rows - 1, y)),\n shift: !!event.shiftKey, alt: !!event.altKey, ctrl: !!event.ctrlKey\n };\n}\n\nexport class WheelAccumulator {\n x = 0;\n y = 0;\n reset() { this.x = this.y = 0; }\n take(event: Pick, bounds: Pick, rows: number): TerminalPoint {\n const cellHeight = bounds.height / rows;\n if (!(cellHeight > 0)) return { x: 0, y: 0 };\n const unit = event.deltaMode === 1 ? 1 : event.deltaMode === 2 ? rows : 1 / cellHeight;\n this.x += event.deltaX * unit;\n this.y += event.deltaY * unit;\n const x = Math.trunc(this.x);\n const y = Math.trunc(this.y);\n this.x -= x;\n this.y -= y;\n return { x: Math.max(-32, Math.min(32, x)), y: Math.max(-32, Math.min(32, y)) };\n }\n}\n\n/** Ownership and granularity are latched, independent of subsequent modifiers. */\nexport class SelectionGesture {\n owner: \"local\" | \"app\" | null = null;\n mode: SelectionMode = \"character\";\n endpoint: TerminalPoint | null = null;\n begin(event: Pick, point: TerminalPoint,\n { tracking, historical, readOnly, selection }: GestureState): GestureStart | null {\n if (this.owner) return null;\n const local = historical || readOnly || !tracking || event.shiftKey;\n if (local && event.button !== 0) return null;\n this.owner = local ? \"local\" : \"app\";\n if (!local) return { owner: \"app\" };\n const extend = !!event.shiftKey && !!selection && [\"valid\", \"pending\"].includes(selection.status) && selection.canExtend !== false;\n this.mode = extend && selection ? selection.mode : event.altKey ? \"rectangle\"\n : event.detail >= 3 ? \"line\" : event.detail === 2 ? \"word\" : \"character\";\n this.endpoint = { x: point.x, y: point.y };\n return { owner: this.owner, mode: this.mode, extend };\n }\n move(point: TerminalPoint): TerminalPoint | null {\n if (this.owner !== \"local\") return null;\n this.endpoint = { x: point.x, y: point.y };\n return { ...this.endpoint };\n }\n scrollPoint(point: TerminalPoint): TerminalPoint | undefined {\n if (this.owner !== \"local\") return undefined;\n // A wheel cannot widen a rectangle; only actual pointer movement can.\n return { x: this.mode === \"rectangle\" && this.endpoint ? this.endpoint.x : point.x, y: point.y };\n }\n wheelOwner(event: Pick, { tracking, historical, readOnly }: GestureState): \"local\" | \"app\" {\n return this.owner ?? (historical || readOnly || !tracking || event.shiftKey ? \"local\" : \"app\");\n }\n end() { this.owner = null; }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.d.ts new file mode 100644 index 00000000000..b7634165894 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.d.ts @@ -0,0 +1,24 @@ +import type { RunTerminalAction, SelectionRange, SelectionUIState, TerminalGeometry, TerminalSize, WebTerminalOptions } from "./types.js"; +export declare function sameSelectionUIState(a: SelectionUIState | undefined, b: SelectionUIState): boolean; +export declare function selectionRectangles(ranges: readonly SelectionRange[], geometry: TerminalGeometry, canvasSize: TerminalSize): Readonly<{ + left: number; + top: number; + width: number; + height: number; +}>[]; +/** Owns UI notification/default rendering, not terminal selection or clipboard state. */ +export declare class SelectionUI { + #private; + constructor({ element, overlay, button, signal, getState, runAction, onSelectionUI, reportError }: { + element: HTMLDivElement; + overlay: HTMLDivElement; + button: HTMLButtonElement; + signal: AbortSignal; + getState: () => SelectionUIState; + runAction: RunTerminalAction; + onSelectionUI?: WebTerminalOptions["onSelectionUI"]; + reportError: (error: unknown) => void; + }); + refresh(force?: boolean): void; +} +//# sourceMappingURL=selection-ui.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.d.ts.map new file mode 100644 index 00000000000..abfb316ad38 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"selection-ui.d.ts","sourceRoot":"","sources":["../src/selection-ui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAC7C,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAY3F,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,gBAAgB,GAAG,SAAS,EAAE,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAQlG;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,cAAc,EAAE,EAAE,QAAQ,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY;;;;;KAO1H;AAED,yFAAyF;AACzF,qBAAa,WAAW;;gBAaV,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,aAAa,EAAE,WAAW,EAAE,EAAE;QACjG,OAAO,EAAE,cAAc,CAAC;QAAC,OAAO,EAAE,cAAc,CAAC;QAAC,MAAM,EAAE,iBAAiB,CAAC;QAAC,MAAM,EAAE,WAAW,CAAC;QACjG,QAAQ,EAAE,MAAM,gBAAgB,CAAC;QAAC,SAAS,EAAE,iBAAiB,CAAC;QAC/D,aAAa,CAAC,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;QAAC,WAAW,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;KAC5F;IA4BD,OAAO,CAAC,KAAK,UAAQ;CAqCtB"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.js new file mode 100644 index 00000000000..01f2f299f75 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.js @@ -0,0 +1,113 @@ +import { isRecord } from "./validation.js"; +const selectionFields = ["status", "mode", "requestId", "active", "pending", "canExtend", + "text", "message", "copying", "copyError"]; +const viewportFields = ["available", "following", "pending", "generation", "buffer", + "top", "liveTop", "totalRows", "requestId"]; +const geometryFields = ["columns", "rows", "cellWidth", "cellHeight", "mouseTracking"]; +const rangeFields = ["row", "startColumn", "endColumn"]; +const equalFields = (a, b, fields) => fields.every(field => a?.[field] === b?.[field]); +export function sameSelectionUIState(a, b) { + return !!a && a.connected === b.connected && a.readOnly === b.readOnly && + equalFields(a.selection, b.selection, selectionFields) && + equalFields(a.viewport, b.viewport, viewportFields) && + equalFields(a.geometry, b.geometry, geometryFields) && + equalFields(a.canvasSize, b.canvasSize, ["width", "height"]) && + a.selection.ranges.length === b.selection.ranges.length && + a.selection.ranges.every((range, index) => equalFields(range, b.selection.ranges[index], rangeFields)); +} +export function selectionRectangles(ranges, geometry, canvasSize) { + const width = canvasSize.width / geometry.columns; + const height = canvasSize.height / geometry.rows; + return ranges.map(range => Object.freeze({ + left: range.startColumn * width, top: range.row * height, + width: (range.endColumn - range.startColumn) * width, height + })); +} +/** Owns UI notification/default rendering, not terminal selection or clipboard state. */ +export class SelectionUI { + #element; + #overlay; + #button; + #signal; + #getState; + #runAction; + #reportError; + #previous; + #notification; + #queued = false; + #force = false; + constructor({ element, overlay, button, signal, getState, runAction, onSelectionUI, reportError }) { + this.#element = element; + this.#overlay = overlay; + this.#button = button; + this.#signal = signal; + this.#getState = getState; + this.#runAction = runAction; + this.#reportError = reportError; + if (onSelectionUI) { + element.addEventListener("selectionui", event => { + if (!this.#notification || event !== this.#notification) + return; + const snapshot = this.#previous; + try { + const result = onSelectionUI(this.#notification); + if (result !== undefined) { + if (isRecord(result) && typeof result.then === "function") + Promise.resolve(result).catch(error => { + if (!signal.aborted && this.#previous === snapshot) + reportError(error); + }); + throw new TypeError("onSelectionUI must finish synchronously; use preventDefault() and signal for UI ownership"); + } + } + catch (error) { + event.preventDefault(); + reportError(error); + } + }, { signal }); + } + } + refresh(force = false) { + this.#force ||= force; + if (this.#queued || this.#signal.aborted) + return; + this.#queued = true; + // Coalesce geometry and history from the same presented frame before notifying the host. + queueMicrotask(() => { + this.#queued = false; + if (this.#signal.aborted) + return; + const state = this.#getState(); + const force = this.#force; + this.#force = false; + if (!force && sameSelectionUIState(this.#previous, state)) + return; + const snapshot = Object.freeze({ + ...state, + selection: Object.freeze({ ...state.selection, + ranges: Object.freeze(state.selection.ranges.map(range => Object.freeze({ ...range }))) }), + viewport: Object.freeze({ ...state.viewport, rowIds: Object.freeze([...(state.viewport.rowIds ?? [])]) }), + geometry: Object.freeze({ ...state.geometry }), + canvasSize: Object.freeze({ ...state.canvasSize }) + }); + this.#previous = snapshot; + const detail = Object.freeze({ + ...snapshot, overlay: this.#overlay, signal: this.#signal, runAction: this.#runAction, + rects: Object.freeze(selectionRectangles(snapshot.selection.ranges, snapshot.geometry, snapshot.canvasSize)) + }); + const event = new CustomEvent("selectionui", { cancelable: true, detail }); + this.#notification = event; + this.#reportError(null); + this.#element.dispatchEvent(event); + if (this.#signal.aborted) + return; + const selection = snapshot.selection; + this.#button.hidden = event.defaultPrevented || ["none", "unavailable"].includes(selection.status); + this.#button.disabled = !snapshot.connected || selection.status !== "valid" || selection.copying; + const label = selection.copying ? "Copying\u2026" : "Copy"; + if (this.#button.textContent !== label) + this.#button.textContent = label; + }); + } +} +//# sourceMappingURL=selection-ui.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.js.map new file mode 100644 index 00000000000..4dc472c0726 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/selection-ui.js.map @@ -0,0 +1 @@ +{"version":3,"file":"selection-ui.js","sourceRoot":"","sources":["../src/selection-ui.ts"],"names":[],"mappings":"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","sourcesContent":["import type { RunTerminalAction, SelectionRange, SelectionUIDetail, SelectionUIEvent,\n SelectionUIState, TerminalGeometry, TerminalSize, WebTerminalOptions } from \"./types.js\";\nimport { isRecord } from \"./validation.js\";\n\nconst selectionFields = [\"status\", \"mode\", \"requestId\", \"active\", \"pending\", \"canExtend\",\n \"text\", \"message\", \"copying\", \"copyError\"] as const;\nconst viewportFields = [\"available\", \"following\", \"pending\", \"generation\", \"buffer\",\n \"top\", \"liveTop\", \"totalRows\", \"requestId\"] as const;\nconst geometryFields = [\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const;\nconst rangeFields = [\"row\", \"startColumn\", \"endColumn\"] as const;\nconst equalFields = (a: T | undefined, b: T | undefined, fields: readonly (keyof T)[]) =>\n fields.every(field => a?.[field] === b?.[field]);\n\nexport function sameSelectionUIState(a: SelectionUIState | undefined, b: SelectionUIState): boolean {\n return !!a && a.connected === b.connected && a.readOnly === b.readOnly &&\n equalFields(a.selection, b.selection, selectionFields) &&\n equalFields(a.viewport, b.viewport, viewportFields) &&\n equalFields(a.geometry, b.geometry, geometryFields) &&\n equalFields(a.canvasSize, b.canvasSize, [\"width\", \"height\"]) &&\n a.selection.ranges.length === b.selection.ranges.length &&\n a.selection.ranges.every((range, index) => equalFields(range, b.selection.ranges[index], rangeFields));\n}\n\nexport function selectionRectangles(ranges: readonly SelectionRange[], geometry: TerminalGeometry, canvasSize: TerminalSize) {\n const width = canvasSize.width / geometry.columns;\n const height = canvasSize.height / geometry.rows;\n return ranges.map(range => Object.freeze({\n left: range.startColumn * width, top: range.row * height,\n width: (range.endColumn - range.startColumn) * width, height\n }));\n}\n\n/** Owns UI notification/default rendering, not terminal selection or clipboard state. */\nexport class SelectionUI {\n #element: HTMLDivElement;\n #overlay: HTMLDivElement;\n #button: HTMLButtonElement;\n #signal: AbortSignal;\n #getState: () => SelectionUIState;\n #runAction: RunTerminalAction;\n #reportError: (error: unknown) => void;\n #previous: SelectionUIState | undefined;\n #notification: SelectionUIEvent | undefined;\n #queued = false;\n #force = false;\n\n constructor({ element, overlay, button, signal, getState, runAction, onSelectionUI, reportError }: {\n element: HTMLDivElement; overlay: HTMLDivElement; button: HTMLButtonElement; signal: AbortSignal;\n getState: () => SelectionUIState; runAction: RunTerminalAction;\n onSelectionUI?: WebTerminalOptions[\"onSelectionUI\"]; reportError: (error: unknown) => void;\n }) {\n this.#element = element;\n this.#overlay = overlay;\n this.#button = button;\n this.#signal = signal;\n this.#getState = getState;\n this.#runAction = runAction;\n this.#reportError = reportError;\n if (onSelectionUI) {\n element.addEventListener(\"selectionui\", event => {\n if (!this.#notification || event !== this.#notification) return;\n const snapshot = this.#previous;\n try {\n const result: unknown = onSelectionUI(this.#notification);\n if (result !== undefined) {\n if (isRecord(result) && typeof result.then === \"function\") Promise.resolve(result).catch(error => {\n if (!signal.aborted && this.#previous === snapshot) reportError(error);\n });\n throw new TypeError(\"onSelectionUI must finish synchronously; use preventDefault() and signal for UI ownership\");\n }\n } catch (error) {\n event.preventDefault();\n reportError(error);\n }\n }, { signal });\n }\n }\n\n refresh(force = false) {\n this.#force ||= force;\n if (this.#queued || this.#signal.aborted) return;\n this.#queued = true;\n // Coalesce geometry and history from the same presented frame before notifying the host.\n queueMicrotask(() => {\n this.#queued = false;\n if (this.#signal.aborted) return;\n const state = this.#getState();\n const force = this.#force;\n this.#force = false;\n if (!force && sameSelectionUIState(this.#previous, state)) return;\n const snapshot: SelectionUIState = Object.freeze({\n ...state,\n selection: Object.freeze({ ...state.selection,\n ranges: Object.freeze(state.selection.ranges.map(range => Object.freeze({ ...range }))) }),\n viewport: Object.freeze({ ...state.viewport, rowIds: Object.freeze([...(state.viewport.rowIds ?? [])]) }),\n geometry: Object.freeze({ ...state.geometry }),\n canvasSize: Object.freeze({ ...state.canvasSize })\n });\n this.#previous = snapshot;\n const detail = Object.freeze({\n ...snapshot, overlay: this.#overlay, signal: this.#signal, runAction: this.#runAction,\n rects: Object.freeze(selectionRectangles(snapshot.selection.ranges, snapshot.geometry, snapshot.canvasSize))\n });\n const event = new CustomEvent(\"selectionui\", { cancelable: true, detail });\n this.#notification = event;\n this.#reportError(null);\n this.#element.dispatchEvent(event);\n if (this.#signal.aborted) return;\n const selection = snapshot.selection;\n this.#button.hidden = event.defaultPrevented || [\"none\", \"unavailable\"].includes(selection.status);\n this.#button.disabled = !snapshot.connected || selection.status !== \"valid\" || selection.copying;\n const label = selection.copying ? \"Copying\\u2026\" : \"Copy\";\n if (this.#button.textContent !== label) this.#button.textContent = label;\n });\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.d.ts new file mode 100644 index 00000000000..437d7a01c96 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.d.ts @@ -0,0 +1,23 @@ +/** Resolve caller-relative URLs before sending the configuration to the worker. */ +export declare function normalizeFont(font?: TerminalFont, baseUrl?: string): NormalizedFont; +/** Use the rendering context's FontFaceSet; a worker cannot inherit page fonts. */ +export declare function loadFont(configuration: TerminalFont): Promise; +/** Fit one font-wide advance and line box to the authoritative cell dimensions. */ +export declare function measureFont(raster: OffscreenCanvasRenderingContext2D, cssFamily: string, attributes: number, scale: number, cellWidth: number, cellHeight: number): FontMetrics; +import type { TerminalFont, TerminalFontFace } from "./types.js"; +export interface NormalizedFont { + family: string; + faces: Required[]; +} +export interface LoadedFont { + family: string; + cssFamily: string; + dispose(): void; +} +export interface FontMetrics { + font: string; + xScale: number; + yScale: number; + baseline: number; +} +//# sourceMappingURL=terminal-font.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.d.ts.map new file mode 100644 index 00000000000..c3c99a91d21 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal-font.d.ts","sourceRoot":"","sources":["../src/terminal-font.ts"],"names":[],"mappings":"AAGA,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,IAAI,CAAC,EAAE,YAAY,EAAE,OAAO,SAAkB,GAAG,cAAc,CA2B5F;AAED,mFAAmF;AACnF,wBAAsB,QAAQ,CAAC,aAAa,EAAE,YAAY,GAAG,OAAO,CAAC,UAAU,CAAC,CA6B/E;AAED,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,MAAM,EAAE,iCAAiC,EAAE,SAAS,EAAE,MAAM,EACtF,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAcvF;AACD,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAGjE,MAAM,WAAW,cAAc;IAAG,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAA;CAAE;AACvF,MAAM,WAAW,UAAU;IAAG,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,IAAI,IAAI,CAAA;CAAE;AAClF,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.js new file mode 100644 index 00000000000..042a51904a5 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.js @@ -0,0 +1,85 @@ +const defaultFontUrl = new URL("./fonts/cascadia-mono-nf/CascadiaMonoNF.woff2", import.meta.url).href; +const genericFamilies = new Set(["monospace", "serif", "sans-serif", "system-ui"]); +/** Resolve caller-relative URLs before sending the configuration to the worker. */ +export function normalizeFont(font, baseUrl = import.meta.url) { + font ??= { family: "Cascadia Mono NF", faces: [{ url: defaultFontUrl, weight: "200 700" }] }; + if (typeof font !== "object" || Array.isArray(font) || + typeof font.family !== "string" || !font.family.trim() || font.family.length > 256 || + /[\u0000-\u001f\u007f]/u.test(font.family)) { + throw new TypeError("font.family must be a single, nonempty font family name"); + } + const faces = font.faces ?? []; + if (!Array.isArray(faces) || faces.length > 16) + throw new TypeError("font.faces must contain at most 16 font sources"); + return { + family: font.family.trim(), + faces: faces.map(face => { + if (!face || typeof face.url !== "string" || !face.url.trim()) { + throw new TypeError("Each font face requires a URL"); + } + for (const field of ["weight", "style"]) { + if (face[field] !== undefined && (typeof face[field] !== "string" || face[field].length > 64)) { + throw new TypeError(`Font face ${field} must be a CSS descriptor string`); + } + } + return { + url: new URL(face.url, baseUrl).href, + weight: face.weight ?? "400", + style: face.style ?? "normal" + }; + }) + }; +} +/** Use the rendering context's FontFaceSet; a worker cannot inherit page fonts. */ +export async function loadFont(configuration) { + const { family, faces } = normalizeFont(configuration); + const scope = globalThis; + const fontSet = "fonts" in scope && isFontSet(scope.fonts) + ? scope.fonts : globalThis.document?.fonts; + if (!fontSet || typeof FontFace !== "function") { + throw new Error("Terminal font loading requires the CSS Font Loading API in the rendering context"); + } + const generic = genericFamilies.has(family.toLowerCase()); + const cssFamily = generic ? family.toLowerCase() : JSON.stringify(family); + if (generic && faces.length) + throw new TypeError("A generic font family cannot have downloadable faces"); + const sources = faces.length ? faces : generic ? [] : [{ local: family }]; + const loaded = []; + for (const source of sources) { + try { + const face = new FontFace(family, "local" in source ? `local(${JSON.stringify(source.local)})` : `url(${JSON.stringify(source.url)})`, { weight: source.weight ?? "400", style: source.style ?? "normal" }); + loaded.push(await face.load()); + } + catch (error) { + throw new Error(`Could not load terminal font "${family}" from ${"url" in source ? source.url : "local fonts"}`, { cause: error }); + } + } + for (const face of loaded) + fontSet.add(face); + return { + family, cssFamily, + dispose() { for (const face of loaded) + fontSet.delete(face); } + }; +} +/** Fit one font-wide advance and line box to the authoritative cell dimensions. */ +export function measureFont(raster, cssFamily, attributes, scale, cellWidth, cellHeight) { + const font = `${attributes & 4 ? "italic " : ""}${attributes & 1 ? "bold " : ""}${16 * scale}px ${cssFamily}`; + raster.font = font; + raster.textBaseline = "alphabetic"; + raster.textAlign = "left"; + const metrics = raster.measureText("M"); + const lineHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; + if (!Number.isFinite(metrics.width) || metrics.width <= 0 || + !Number.isFinite(lineHeight) || lineHeight <= 0) { + throw new Error(`Invalid terminal font metrics for ${cssFamily}`); + } + const xScale = cellWidth * scale / metrics.width; + const yScale = cellHeight * scale / lineHeight; + return { font, xScale, yScale, baseline: metrics.fontBoundingBoxAscent * yScale }; +} +import { isRecord } from "./validation.js"; +function isFontSet(value) { + return isRecord(value) && typeof value.add === "function" && typeof value.delete === "function"; +} +//# sourceMappingURL=terminal-font.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.js.map new file mode 100644 index 00000000000..c9b5c4dbe32 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-font.js.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal-font.js","sourceRoot":"","sources":["../src/terminal-font.ts"],"names":[],"mappings":"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","sourcesContent":["const defaultFontUrl = new URL(\"./fonts/cascadia-mono-nf/CascadiaMonoNF.woff2\", import.meta.url).href;\nconst genericFamilies = new Set([\"monospace\", \"serif\", \"sans-serif\", \"system-ui\"]);\n\n/** Resolve caller-relative URLs before sending the configuration to the worker. */\nexport function normalizeFont(font?: TerminalFont, baseUrl = import.meta.url): NormalizedFont {\n font ??= { family: \"Cascadia Mono NF\", faces: [{ url: defaultFontUrl, weight: \"200 700\" }] };\n if (typeof font !== \"object\" || Array.isArray(font) ||\n typeof font.family !== \"string\" || !font.family.trim() || font.family.length > 256 ||\n /[\\u0000-\\u001f\\u007f]/u.test(font.family)) {\n throw new TypeError(\"font.family must be a single, nonempty font family name\");\n }\n const faces = font.faces ?? [];\n if (!Array.isArray(faces) || faces.length > 16) throw new TypeError(\"font.faces must contain at most 16 font sources\");\n return {\n family: font.family.trim(),\n faces: faces.map(face => {\n if (!face || typeof face.url !== \"string\" || !face.url.trim()) {\n throw new TypeError(\"Each font face requires a URL\");\n }\n for (const field of [\"weight\", \"style\"] as const) {\n if (face[field] !== undefined && (typeof face[field] !== \"string\" || face[field].length > 64)) {\n throw new TypeError(`Font face ${field} must be a CSS descriptor string`);\n }\n }\n return {\n url: new URL(face.url, baseUrl).href,\n weight: face.weight ?? \"400\",\n style: face.style ?? \"normal\"\n };\n })\n };\n}\n\n/** Use the rendering context's FontFaceSet; a worker cannot inherit page fonts. */\nexport async function loadFont(configuration: TerminalFont): Promise {\n const { family, faces } = normalizeFont(configuration);\n const scope: object = globalThis;\n const fontSet = \"fonts\" in scope && isFontSet(scope.fonts)\n ? scope.fonts : globalThis.document?.fonts;\n if (!fontSet || typeof FontFace !== \"function\") {\n throw new Error(\"Terminal font loading requires the CSS Font Loading API in the rendering context\");\n }\n const generic = genericFamilies.has(family.toLowerCase());\n const cssFamily = generic ? family.toLowerCase() : JSON.stringify(family);\n if (generic && faces.length) throw new TypeError(\"A generic font family cannot have downloadable faces\");\n const sources: (TerminalFontFace | { local: string; weight?: string; style?: string })[] =\n faces.length ? faces : generic ? [] : [{ local: family }];\n const loaded: FontFace[] = [];\n for (const source of sources) {\n try {\n const face = new FontFace(family,\n \"local\" in source ? `local(${JSON.stringify(source.local)})` : `url(${JSON.stringify(source.url)})`,\n { weight: source.weight ?? \"400\", style: source.style ?? \"normal\" });\n loaded.push(await face.load());\n } catch (error) {\n throw new Error(`Could not load terminal font \"${family}\" from ${\"url\" in source ? source.url : \"local fonts\"}`, { cause: error });\n }\n }\n for (const face of loaded) fontSet.add(face);\n return {\n family, cssFamily,\n dispose() { for (const face of loaded) fontSet.delete(face); }\n };\n}\n\n/** Fit one font-wide advance and line box to the authoritative cell dimensions. */\nexport function measureFont(raster: OffscreenCanvasRenderingContext2D, cssFamily: string,\n attributes: number, scale: number, cellWidth: number, cellHeight: number): FontMetrics {\n const font = `${attributes & 4 ? \"italic \" : \"\"}${attributes & 1 ? \"bold \" : \"\"}${16 * scale}px ${cssFamily}`;\n raster.font = font;\n raster.textBaseline = \"alphabetic\";\n raster.textAlign = \"left\";\n const metrics = raster.measureText(\"M\");\n const lineHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;\n if (!Number.isFinite(metrics.width) || metrics.width <= 0 ||\n !Number.isFinite(lineHeight) || lineHeight <= 0) {\n throw new Error(`Invalid terminal font metrics for ${cssFamily}`);\n }\n const xScale = cellWidth * scale / metrics.width;\n const yScale = cellHeight * scale / lineHeight;\n return { font, xScale, yScale, baseline: metrics.fontBoundingBoxAscent * yScale };\n}\nimport type { TerminalFont, TerminalFontFace } from \"./types.js\";\nimport { isRecord } from \"./validation.js\";\n\nexport interface NormalizedFont { family: string; faces: Required[] }\nexport interface LoadedFont { family: string; cssFamily: string; dispose(): void }\nexport interface FontMetrics { font: string; xScale: number; yScale: number; baseline: number }\n\nfunction isFontSet(value: unknown): value is Pick {\n return isRecord(value) && typeof value.add === \"function\" && typeof value.delete === \"function\";\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.d.ts new file mode 100644 index 00000000000..c980a37f25c --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.d.ts @@ -0,0 +1,8 @@ +export declare const MIN_FONT_SIZE = 8; +export declare const MAX_FONT_SIZE = 32; +export declare function dimensions(columns: number, rows: number): TerminalGrid; +export declare function normalizeSizing(sizing?: TerminalSizing, previousFontSize?: number): TerminalSizingState; +export declare function requestedGrid(size: TerminalSize, geometry: TerminalGeometry, sizing: TerminalSizingState): TerminalGrid | null; +export declare function fittedScale(size: TerminalSize, geometry: TerminalGeometry, isPrimary: boolean, sizing: TerminalSizingState): number; +import type { TerminalGeometry, TerminalGrid, TerminalSize, TerminalSizing, TerminalSizingState } from "./types.js"; +//# sourceMappingURL=terminal-sizing.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.d.ts.map new file mode 100644 index 00000000000..fd4c60d2532 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal-sizing.d.ts","sourceRoot":"","sources":["../src/terminal-sizing.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,aAAa,KAAK,CAAC;AAGhC,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAMtE;AAED,wBAAgB,eAAe,CAAC,MAAM,GAAE,cAAiC,EAAE,gBAAgB,SAAmB,GAAG,mBAAmB,CAWnI;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,YAAY,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,EAAE,mBAAmB,GAAG,YAAY,GAAG,IAAI,CAQ9H;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,YAAY,EAAE,QAAQ,EAAE,gBAAgB,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAKnI;AACD,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.js new file mode 100644 index 00000000000..e733886269b --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.js @@ -0,0 +1,38 @@ +export const MIN_FONT_SIZE = 8; +export const MAX_FONT_SIZE = 32; +const NATIVE_FONT_SIZE = 16; +export function dimensions(columns, rows) { + if (!Number.isInteger(columns) || columns < 20 || columns > 300 || + !Number.isInteger(rows) || rows < 10 || rows > 100) { + throw new RangeError("Requested grid must be 20-300 columns by 10-100 rows"); + } + return { columns, rows }; +} +export function normalizeSizing(sizing = { mode: "auto" }, previousFontSize = NATIVE_FONT_SIZE) { + if (!sizing || !["auto", "fixed"].includes(sizing.mode)) { + throw new TypeError("Sizing mode must be 'auto' or 'fixed'"); + } + const fontSize = sizing.fontSize ?? previousFontSize; + if (!Number.isInteger(fontSize) || fontSize < MIN_FONT_SIZE || fontSize > MAX_FONT_SIZE) { + throw new RangeError(`Font size must be an integer from ${MIN_FONT_SIZE} to ${MAX_FONT_SIZE}`); + } + return sizing.mode === "fixed" + ? { mode: "fixed", fontSize, ...dimensions(sizing.columns, sizing.rows) } + : { mode: "auto", fontSize }; +} +export function requestedGrid(size, geometry, sizing) { + if (sizing.mode === "fixed") + return { columns: sizing.columns, rows: sizing.rows }; + if (size.width <= 0 || size.height <= 0) + return null; + const scale = sizing.fontSize / NATIVE_FONT_SIZE; + return { + columns: Math.max(20, Math.min(300, Math.floor(size.width / (geometry.cellWidth * scale)))), + rows: Math.max(10, Math.min(100, Math.floor(size.height / (geometry.cellHeight * scale)))) + }; +} +export function fittedScale(size, geometry, isPrimary, sizing) { + const maximum = isPrimary && sizing.mode === "auto" ? sizing.fontSize / NATIVE_FONT_SIZE : Infinity; + return Math.max(0, Math.min(maximum, size.width / (geometry.columns * geometry.cellWidth), size.height / (geometry.rows * geometry.cellHeight))); +} +//# sourceMappingURL=terminal-sizing.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.js.map new file mode 100644 index 00000000000..69afc47cb45 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-sizing.js.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal-sizing.js","sourceRoot":"","sources":["../src/terminal-sizing.ts"],"names":[],"mappings":"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","sourcesContent":["export const MIN_FONT_SIZE = 8;\nexport const MAX_FONT_SIZE = 32;\nconst NATIVE_FONT_SIZE = 16;\n\nexport function dimensions(columns: number, rows: number): TerminalGrid {\n if (!Number.isInteger(columns) || columns < 20 || columns > 300 ||\n !Number.isInteger(rows) || rows < 10 || rows > 100) {\n throw new RangeError(\"Requested grid must be 20-300 columns by 10-100 rows\");\n }\n return { columns, rows };\n}\n\nexport function normalizeSizing(sizing: TerminalSizing = { mode: \"auto\" }, previousFontSize = NATIVE_FONT_SIZE): TerminalSizingState {\n if (!sizing || ![\"auto\", \"fixed\"].includes(sizing.mode)) {\n throw new TypeError(\"Sizing mode must be 'auto' or 'fixed'\");\n }\n const fontSize = sizing.fontSize ?? previousFontSize;\n if (!Number.isInteger(fontSize) || fontSize < MIN_FONT_SIZE || fontSize > MAX_FONT_SIZE) {\n throw new RangeError(`Font size must be an integer from ${MIN_FONT_SIZE} to ${MAX_FONT_SIZE}`);\n }\n return sizing.mode === \"fixed\"\n ? { mode: \"fixed\", fontSize, ...dimensions(sizing.columns, sizing.rows) }\n : { mode: \"auto\", fontSize };\n}\n\nexport function requestedGrid(size: TerminalSize, geometry: TerminalGeometry, sizing: TerminalSizingState): TerminalGrid | null {\n if (sizing.mode === \"fixed\") return { columns: sizing.columns, rows: sizing.rows };\n if (size.width <= 0 || size.height <= 0) return null;\n const scale = sizing.fontSize / NATIVE_FONT_SIZE;\n return {\n columns: Math.max(20, Math.min(300, Math.floor(size.width / (geometry.cellWidth * scale)))),\n rows: Math.max(10, Math.min(100, Math.floor(size.height / (geometry.cellHeight * scale))))\n };\n}\n\nexport function fittedScale(size: TerminalSize, geometry: TerminalGeometry, isPrimary: boolean, sizing: TerminalSizingState): number {\n const maximum = isPrimary && sizing.mode === \"auto\" ? sizing.fontSize / NATIVE_FONT_SIZE : Infinity;\n return Math.max(0, Math.min(maximum,\n size.width / (geometry.columns * geometry.cellWidth),\n size.height / (geometry.rows * geometry.cellHeight)));\n}\nimport type { TerminalGeometry, TerminalGrid, TerminalSize, TerminalSizing, TerminalSizingState } from \"./types.js\";\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.d.ts new file mode 100644 index 00000000000..f999c015236 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.d.ts @@ -0,0 +1,2 @@ +export declare const terminalThemeCss = "\n :host {\n \n --cp-terminal-surface: #ffffff;\n --cp-terminal-text: #242424;\n --cp-terminal-text-muted: #5c5c5c;\n --cp-terminal-border-strong: #919191;\n --cp-terminal-accent: #b11f4b;\n --cp-terminal-accent-soft: rgba(177, 31, 75, 0.08);\n --cp-terminal-danger: #dc2626;\n\n --cp-terminal-font-family: \"Segoe UI\", Aptos, Calibri, -apple-system, BlinkMacSystemFont, sans-serif;\n }\n @media (prefers-color-scheme: dark) { :host { \n --cp-terminal-surface: #292929;\n --cp-terminal-text: #dedede;\n --cp-terminal-text-muted: #919191;\n --cp-terminal-border-strong: #5f5f5f;\n --cp-terminal-accent: #fd8ea1;\n --cp-terminal-accent-soft: rgba(253, 142, 161, 0.14);\n --cp-terminal-danger: #f87171;\n } }\n :host-context([data-theme=\"light\"]) { \n --cp-terminal-surface: #ffffff;\n --cp-terminal-text: #242424;\n --cp-terminal-text-muted: #5c5c5c;\n --cp-terminal-border-strong: #919191;\n --cp-terminal-accent: #b11f4b;\n --cp-terminal-accent-soft: rgba(177, 31, 75, 0.08);\n --cp-terminal-danger: #dc2626;\n }\n :host-context([data-theme=\"dark\"]) { \n --cp-terminal-surface: #292929;\n --cp-terminal-text: #dedede;\n --cp-terminal-text-muted: #919191;\n --cp-terminal-border-strong: #5f5f5f;\n --cp-terminal-accent: #fd8ea1;\n --cp-terminal-accent-soft: rgba(253, 142, 161, 0.14);\n --cp-terminal-danger: #f87171;\n }\n .viewport {\n --cp-view-surface: var(--cp-surface, var(--cp-terminal-surface));\n --cp-view-text: var(--cp-text, var(--cp-terminal-text));\n --cp-view-text-muted: var(--cp-text-muted, var(--cp-terminal-text-muted));\n --cp-view-border-strong: var(--cp-border-strong, var(--cp-terminal-border-strong));\n --cp-view-accent: var(--cp-accent, var(--cp-terminal-accent));\n --cp-view-accent-soft: var(--cp-accent-soft, var(--cp-terminal-accent-soft));\n --cp-view-danger: var(--cp-danger, var(--cp-terminal-danger));\n --cp-view-font-family: var(--cp-font-family, var(--cp-terminal-font-family));\n }\n"; +//# sourceMappingURL=terminal-theme.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.d.ts.map new file mode 100644 index 00000000000..6bfed02c2c8 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal-theme.d.ts","sourceRoot":"","sources":["../src/terminal-theme.ts"],"names":[],"mappings":"AAqBA,eAAO,MAAM,gBAAgB,28DAkB5B,CAAC"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.js new file mode 100644 index 00000000000..142d3a039af --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.js @@ -0,0 +1,39 @@ +const light = ` + --cp-terminal-surface: #ffffff; + --cp-terminal-text: #242424; + --cp-terminal-text-muted: #5c5c5c; + --cp-terminal-border-strong: #919191; + --cp-terminal-accent: #b11f4b; + --cp-terminal-accent-soft: rgba(177, 31, 75, 0.08); + --cp-terminal-danger: #dc2626; +`; +const dark = ` + --cp-terminal-surface: #292929; + --cp-terminal-text: #dedede; + --cp-terminal-text-muted: #919191; + --cp-terminal-border-strong: #5f5f5f; + --cp-terminal-accent: #fd8ea1; + --cp-terminal-accent-soft: rgba(253, 142, 161, 0.14); + --cp-terminal-danger: #f87171; +`; +// Shared embedding tokens take precedence; defaults never overwrite inherited --cp-* colors. +export const terminalThemeCss = ` + :host { + ${light} + --cp-terminal-font-family: "Segoe UI", Aptos, Calibri, -apple-system, BlinkMacSystemFont, sans-serif; + } + @media (prefers-color-scheme: dark) { :host { ${dark} } } + :host-context([data-theme="light"]) { ${light} } + :host-context([data-theme="dark"]) { ${dark} } + .viewport { + --cp-view-surface: var(--cp-surface, var(--cp-terminal-surface)); + --cp-view-text: var(--cp-text, var(--cp-terminal-text)); + --cp-view-text-muted: var(--cp-text-muted, var(--cp-terminal-text-muted)); + --cp-view-border-strong: var(--cp-border-strong, var(--cp-terminal-border-strong)); + --cp-view-accent: var(--cp-accent, var(--cp-terminal-accent)); + --cp-view-accent-soft: var(--cp-accent-soft, var(--cp-terminal-accent-soft)); + --cp-view-danger: var(--cp-danger, var(--cp-terminal-danger)); + --cp-view-font-family: var(--cp-font-family, var(--cp-terminal-font-family)); + } +`; +//# sourceMappingURL=terminal-theme.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.js.map new file mode 100644 index 00000000000..ea0b0189538 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-theme.js.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal-theme.js","sourceRoot":"","sources":["../src/terminal-theme.ts"],"names":[],"mappings":"AAAA,MAAM,KAAK,GAAG;;;;;;;;CAQb,CAAC;AAEF,MAAM,IAAI,GAAG;;;;;;;;CAQZ,CAAC;AAEF,6FAA6F;AAC7F,MAAM,CAAC,MAAM,gBAAgB,GAAG;;MAE1B,KAAK;;;kDAGuC,IAAI;0CACZ,KAAK;yCACN,IAAI;;;;;;;;;;;CAW5C,CAAC","sourcesContent":["const light = `\n --cp-terminal-surface: #ffffff;\n --cp-terminal-text: #242424;\n --cp-terminal-text-muted: #5c5c5c;\n --cp-terminal-border-strong: #919191;\n --cp-terminal-accent: #b11f4b;\n --cp-terminal-accent-soft: rgba(177, 31, 75, 0.08);\n --cp-terminal-danger: #dc2626;\n`;\n\nconst dark = `\n --cp-terminal-surface: #292929;\n --cp-terminal-text: #dedede;\n --cp-terminal-text-muted: #919191;\n --cp-terminal-border-strong: #5f5f5f;\n --cp-terminal-accent: #fd8ea1;\n --cp-terminal-accent-soft: rgba(253, 142, 161, 0.14);\n --cp-terminal-danger: #f87171;\n`;\n\n// Shared embedding tokens take precedence; defaults never overwrite inherited --cp-* colors.\nexport const terminalThemeCss = `\n :host {\n ${light}\n --cp-terminal-font-family: \"Segoe UI\", Aptos, Calibri, -apple-system, BlinkMacSystemFont, sans-serif;\n }\n @media (prefers-color-scheme: dark) { :host { ${dark} } }\n :host-context([data-theme=\"light\"]) { ${light} }\n :host-context([data-theme=\"dark\"]) { ${dark} }\n .viewport {\n --cp-view-surface: var(--cp-surface, var(--cp-terminal-surface));\n --cp-view-text: var(--cp-text, var(--cp-terminal-text));\n --cp-view-text-muted: var(--cp-text-muted, var(--cp-terminal-text-muted));\n --cp-view-border-strong: var(--cp-border-strong, var(--cp-terminal-border-strong));\n --cp-view-accent: var(--cp-accent, var(--cp-terminal-accent));\n --cp-view-accent-soft: var(--cp-accent-soft, var(--cp-terminal-accent-soft));\n --cp-view-danger: var(--cp-danger, var(--cp-terminal-danger));\n --cp-view-font-family: var(--cp-font-family, var(--cp-terminal-font-family));\n }\n`;\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.d.ts new file mode 100644 index 00000000000..b89c1fa6091 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=terminal-worker.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.d.ts.map new file mode 100644 index 00000000000..f51558176de --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal-worker.d.ts","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js new file mode 100644 index 00000000000..b899d1085ea --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js @@ -0,0 +1,285 @@ +import { decodeFrame, screenText } from "./protocol.js"; +import { TerminalRenderer } from "./renderer.js"; +import { errorMessage } from "./validation.js"; +let renderer; +let socket; +let failed = false; +let stopped = false; +let processing = false; +let drawing = false; +let frameInFlight = false; +let scheduled = false; +let needsRender = false; +let hasBlink = false; +let lastBlink = true; +let metadata; +let cells = []; +let localRevision = 0; +let pendingFrame; +let renderPromise = Promise.resolve(); +let metricsTimer; +let blinkTimer; +let viewport; +const stats = { + revision: 0, fullFrames: 0, frames: 0, presentations: 0, + changedCells: 0, lastChangedCells: 0, discardedFrames: 0, + imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0, + bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0, + gpu: "initializing", connected: false, warnings: [], + fps: 0, receivedKBps: 0, workloadMBps: 0, + captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0, + workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0, +}; +let sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 }; +function postStatus(message, level = "info") { + self.postMessage({ type: "status", message, level }); +} +function send(message) { + if (socket?.readyState === WebSocket.OPEN) + socket.send(JSON.stringify(message)); +} +function emitStats(text) { + if (renderer && !renderer.disposed) + Object.assign(stats, renderer.metrics()); + self.postMessage({ type: "stats", stats: { ...stats }, ...(text === undefined ? {} : { text }) }); +} +function fail(error) { + if (failed || stopped) + return; + failed = true; + const message = errorMessage(error); + stats.gpu = "error"; + stats.connected = false; + clearInterval(metricsTimer); + clearInterval(blinkTimer); + socket?.close(1011, "Browser renderer failed"); + emitStats(); + postStatus(message, "error"); + renderer?.dispose(); +} +self.addEventListener("error", event => { + event.preventDefault(); + fail(event.error || new Error(event.message)); +}); +self.addEventListener("unhandledrejection", event => { + event.preventDefault(); + fail(event.reason); +}); +/** At most one state frame, one decode, and one GPU submission are outstanding. */ +function scheduleRender() { + needsRender = true; + if (scheduled || drawing || processing || failed || stopped || !metadata) + return; + scheduled = true; + self.requestAnimationFrame(() => { + scheduled = false; + if (processing || drawing || failed || stopped) + return; + renderPromise = drawFrame(); + }); +} +async function drawFrame() { + if (!needsRender || !metadata || !renderer) + return; + needsRender = false; + drawing = true; + const frame = pendingFrame; + try { + renderer.resize(metadata.columns, metadata.rows, viewport); + const blink = Math.floor(performance.now() / 600) % 2 === 0; + const result = renderer.render(cells, metadata, blink); + // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement. + await renderer.idle(); + if (failed || stopped) + return; + lastBlink = blink; + stats.presentations++; + stats.rendererCpuMs = result.cpuMs; + stats.quads = result.quads; + stats.drawCalls = result.drawCalls; + stats.warnings = renderer.canvasLimited + ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`] + : metadata.warnings; + if (frame) { + pendingFrame = undefined; + frameInFlight = false; + stats.frames++; + if (frame.full) + stats.fullFrames++; + stats.revision = frame.revision; + stats.changedCells += frame.changedCells; + stats.lastChangedCells = frame.changedCells; + stats.captureMs = metadata.stats.captureMs; + stats.workloadBytes = metadata.stats.workloadBytes; + stats.outputBatches = metadata.stats.outputBatches; + stats.serverElapsedMs = metadata.stats.elapsedMs; + stats.columns = metadata.columns; + stats.rows = metadata.rows; + stats.mouseTracking = metadata.mouseTracking; + stats.peer = metadata.peer; + stats.history = metadata.history; + const text = screenText(cells, metadata.columns, metadata.rows); + self.postMessage({ + type: "geometry", columns: metadata.columns, rows: metadata.rows, + cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight, + mouseTracking: metadata.mouseTracking, peer: metadata.peer, + history: metadata.history, revision: frame.revision, text + }); + send({ type: "ack", revision: frame.revision }); + emitStats(text); + } + } + catch (error) { + fail(error); + } + finally { + drawing = false; + if (needsRender && !processing) + scheduleRender(); + } +} +async function receiveFrame(buffer) { + if (failed || stopped || !renderer) + return; + if (frameInFlight) + throw new Error("Server sent a second state frame before acknowledgement"); + frameInFlight = true; + processing = true; + stats.bytesReceived += buffer.byteLength || 0; + try { + const frame = decodeFrame(buffer); + const next = frame.metadata; + if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision || + !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) { + stats.discardedFrames++; + frameInFlight = false; + // A discarded frame must release the server's one-in-flight gate before resync. + send({ type: "ack", revision: next.revision }); + send({ type: "resync" }); + postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`); + return; + } + await renderPromise; + if (failed || stopped) + return; + // No blink presentation may reference textures while this resource transaction is in progress. + await renderer.idle(); + await renderer.updateImages(frame.images, next.retainedImages); + if (failed || stopped) + return; + const preparationStart = performance.now(); + const nextCells = next.full + ? new Array(next.columns * next.rows) : cells.slice(); + for (const cell of frame.cells) + nextCells[cell.index] = cell; + renderer.prepareGlyphs(nextCells); + cells = nextCells; + metadata = next; + localRevision = next.revision; + pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length }; + hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) || + (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1)); + stats.preparationCpuMs = performance.now() - preparationStart; + needsRender = true; + } + finally { + processing = false; + if (needsRender) + scheduleRender(); + } +} +async function initialize(message) { + if (renderer || socket) + throw new Error("Worker is already initialized"); + if (typeof self.requestAnimationFrame !== "function") { + throw new Error("This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker"); + } + postStatus("Loading terminal font and initializing WebGPU..."); + renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font); + if (failed || stopped) { + renderer?.dispose(); + return; + } + stats.gpu = "ready"; + stats.backingScale = message.scale; + emitStats(); + postStatus("WebGPU ready. Attaching terminal view..."); + const url = new URL(message.url); + if (!["ws:", "wss:"].includes(url.protocol)) { + throw new Error("The terminal WebSocket URL must use ws: or wss:"); + } + socket = new WebSocket(url); + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", () => { + if (failed || stopped) + return; + stats.connected = true; + self.postMessage({ type: "connected" }); + postStatus("Connected · WebGPU worker · server-authoritative cells and graphics", "ready"); + emitStats(); + }); + socket.addEventListener("message", event => { + if (!(event.data instanceof ArrayBuffer)) { + fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`)); + return; + } + receiveFrame(event.data).catch(fail); + }); + socket.addEventListener("error", () => fail(new Error("WebSocket connection failed; verify the demo server is running"))); + socket.addEventListener("close", event => { + stats.connected = false; + if (!failed && !stopped) { + stats.gpu = "stopped"; + stats.fps = 0; + postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : ""}). Attach another view to reconnect.`, "error"); + emitStats(); + stopped = true; + clearInterval(metricsTimer); + clearInterval(blinkTimer); + renderer?.dispose(); + self.postMessage({ type: "disconnected" }); + } + }); + metricsTimer = setInterval(() => { + const now = performance.now(); + const seconds = (now - sample.time) / 1000; + stats.fps = (stats.presentations - sample.presentations) / seconds; + stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000; + stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000; + sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes }; + emitStats(); + }, 1000); + blinkTimer = setInterval(() => { + const blinkOn = Math.floor(performance.now() / 600) % 2 === 0; + if (hasBlink && blinkOn !== lastBlink) + scheduleRender(); + }, 100); +} +self.addEventListener("message", event => { + const message = event.data; + if (message.type === "init") { + initialize(message).catch(fail); + } + else if (message.type === "stop") { + stopped = true; + clearInterval(metricsTimer); + clearInterval(blinkTimer); + socket?.close(1000, "View detached"); + renderer?.dispose(); + self.close(); + } + else if (message.type === "viewport" && !failed && !stopped) { + if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) { + fail(new Error("Invalid mounted viewport dimensions")); + return; + } + if (viewport?.width === message.width && viewport?.height === message.height) + return; + viewport = { width: message.width, height: message.height }; + scheduleRender(); + } + else if (message.type === "command" && !failed && !stopped) { + send(message.command); + } +}); +//# sourceMappingURL=terminal-worker.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map new file mode 100644 index 00000000000..f0c88cb05ad --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map @@ -0,0 +1 @@ +{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1011, \"Browser renderer failed\");\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, text\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing WebGPU...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n postStatus(\"WebGPU ready. Attaching terminal view...\");\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(\"Connected · WebGPU worker · server-authoritative cells and graphics\", \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n socket.addEventListener(\"error\", () => fail(new Error(\"WebSocket connection failed; verify the demo server is running\")));\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"disconnected\" });\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts new file mode 100644 index 00000000000..822064d0b64 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts @@ -0,0 +1,312 @@ +/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */ +export interface TerminalGrid { + columns: number; + rows: number; +} +export interface TerminalSize { + width: number; + height: number; +} +export interface TerminalPoint { + x: number; + y: number; +} +export type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003; +export interface TerminalGeometry extends TerminalGrid { + cellWidth: number; + cellHeight: number; + mouseTracking: MouseTrackingMode; +} +export interface TerminalPeer { + id: string | null; + primaryId: string | null; + isPrimary: boolean; +} +/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */ +export type TerminalSizing = { + mode: "auto"; + fontSize?: number; +} | { + mode: "fixed"; + fontSize?: number; + columns: number; + rows: number; +}; +export type TerminalSizingState = { + mode: "auto"; + fontSize: number; +} | { + mode: "fixed"; + fontSize: number; + columns: number; + rows: number; +}; +export interface TerminalFontFace { + url: string; + weight?: string; + style?: string; +} +/** Without faces, a non-generic family must be installed locally in the worker's environment. */ +export interface TerminalFont { + family: string; + faces?: readonly TerminalFontFace[]; +} +export type TerminalBuffer = "main" | "alternate"; +export type SelectionMode = "character" | "word" | "line" | "rectangle"; +export interface SelectionRange { + row: number; + startColumn: number; + endColumn: number; +} +export type TerminalViewport = ({ + available: true; + generation: string; + buffer: TerminalBuffer; + totalRows: number; + liveTop: number; + top: number; + requestId: number; + rowIds: readonly string[]; + revision: number; +} | { + available: false; + generation?: undefined; + buffer?: undefined; + totalRows?: undefined; + liveTop?: undefined; + top?: undefined; + requestId?: undefined; + rowIds?: readonly string[]; + revision?: undefined; +}) & { + following: boolean; + pending: boolean; + followTail: boolean; + offset: number; +}; +export type TerminalSelection = ({ + status: "valid"; + text: string; + requestId: number; + revision: number; +} | { + status: "none" | "invalidated"; + text: null; + requestId: number; + revision: number; +} | { + status: "pending"; + text: null; + requestId: number; + revision?: undefined; +} | { + status: "unavailable"; + text: null; + requestId?: undefined; + revision?: undefined; +}) & { + mode: SelectionMode; + ranges: readonly Readonly[]; + canExtend?: boolean; + message: string; + active: boolean; + pending: boolean; + copying: boolean; + copyError: string; +}; +export type TerminalStatusLevel = "info" | "ready" | "error"; +/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */ +export interface TerminalStats { + revision?: number; + fullFrames?: number; + frames?: number; + presentations?: number; + changedCells?: number; + lastChangedCells?: number; + discardedFrames?: number; + imageCount?: number; + textureBytes?: number; + atlasGlyphs?: number; + atlasBytes?: number; + bytesReceived?: number; + imageUploadBytes?: number; + imagePayloadBytes?: number; + gpu?: "initializing" | "ready" | "error" | "stopped"; + connected?: boolean; + warnings?: readonly string[]; + fps?: number; + receivedKBps?: number; + workloadMBps?: number; + captureMs?: number; + rendererCpuMs?: number; + preparationCpuMs?: number; + workloadBytes?: number; + outputBatches?: number; + serverElapsedMs?: number; + quads?: number; + drawCalls?: number; + columns?: number; + rows?: number; + mouseTracking?: MouseTrackingMode; + peer?: TerminalPeer; + fontFamily?: string; + rasterScale?: number; + backingScale?: number; + backingWidth?: number; + backingHeight?: number; + atlasRebuilds?: number; + glyphUploadBytes?: number; + instanceBufferBytes?: number; +} +export interface InputModifiers { + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; +} +export type PointerButton = "left" | "middle" | "right"; +/** Input intents contain no browser event. Returning a route controls browser cancellation. */ +export type TerminalInput = ({ + type: "key"; + key: string; + code: string; + repeat: boolean; +} & InputModifiers) | ({ + type: "pointer"; + button: PointerButton; + point: Readonly; +} & InputModifiers) | ({ + type: "wheel"; + deltaX: number; + deltaY: number; + deltaMode: number; + point: Readonly | null; +} & InputModifiers) | { + type: "paste" | "text"; + text: string; +}; +export type InputRouteValue = "continue" | "consume" | "application" | "browser"; +export type TerminalActionName = "copySelection" | "pasteClipboard" | "copyOrPaste" | "clearSelection" | "scrollToLive" | "scrollLines"; +export interface CopySelectionOptions { + clear?: boolean; +} +export interface TerminalInputContext { + readonly terminal: WebTerminalHandle; + readonly selection: TerminalSelection; + readonly viewport: TerminalViewport; + readonly buffer: TerminalBuffer | null; + readonly mouseCaptured: boolean; + readonly historical: boolean; + readonly readOnly: boolean; + readonly connected: boolean; + readonly peer: TerminalPeer; +} +/** Custom actions validate their own arguments and may complete asynchronously. */ +export type InputActionHandler = (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown; +export type InputDecision = { + route: InputRouteValue; + action?: never; + args?: never; +} | { + action: string | InputActionHandler; + args?: unknown; + route?: never; +}; +export type InputInterceptor = (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined; +export type InputBinding = { + id: string; + match: (input: Readonly, context: TerminalInputContext) => boolean; + when?: (context: TerminalInputContext, input: Readonly) => boolean; + remove?: false; +} & InputDecision; +export type InputBindingOverride = InputBinding | { + id: string; + remove: true; +}; +export interface InputPolicyOptions { + inputBindings?: readonly InputBindingOverride[]; + onInput?: InputInterceptor; + actions?: Readonly>; +} +/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */ +export interface RunTerminalAction { + (action: "copySelection", args?: CopySelectionOptions, input?: TerminalInput): Promise; + (action: "pasteClipboard", args?: undefined, input?: TerminalInput): Promise; + (action: "copyOrPaste", args?: undefined, input?: TerminalInput): Promise; + (action: "clearSelection" | "scrollToLive", args?: undefined, input?: TerminalInput): Promise; + (action: "scrollLines", args: number, input?: TerminalInput): Promise; + (action: Name extends TerminalActionName ? never : Name, args?: unknown, input?: TerminalInput): Promise; + (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise; +} +export interface SelectionRectangle { + left: number; + top: number; + width: number; + height: number; +} +export interface SelectionUIState { + readonly selection: Readonly; + readonly viewport: Readonly; + readonly geometry: Readonly; + readonly canvasSize: Readonly; + readonly connected: boolean; + readonly readOnly: boolean; +} +/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */ +export interface SelectionUIDetail extends SelectionUIState { + readonly overlay: HTMLDivElement; + readonly signal: AbortSignal; + readonly runAction: RunTerminalAction; + readonly rects: readonly Readonly[]; +} +export type SelectionUIEvent = CustomEvent; +export interface WebTerminalOptions extends InputPolicyOptions { + url: string | URL; + /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */ + workerUrl?: string | URL; + signal?: AbortSignal; + scale?: number | "auto"; + font?: TerminalFont; + sizing?: TerminalSizing; + label?: string; + readOnly?: boolean; + onStatus?: (message: string, level: TerminalStatusLevel) => void; + onGeometry?: (geometry: TerminalGeometry) => void; + onSizingChange?: (sizing: TerminalSizingState) => void; + onRoleChange?: (peer: TerminalPeer) => void; + onStats?: (stats: TerminalStats, text: string | undefined) => void; + onViewportChange?: (viewport: TerminalViewport) => void; + onSelectionChange?: (selection: TerminalSelection) => void; + onInputError?: (error: Error) => void; + /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */ + onSelectionUI?: (event: SelectionUIEvent) => undefined; +} +/** Owns only the appended element and browser connection, not the server terminal. */ +export interface WebTerminalHandle { + readonly element: HTMLDivElement; + readonly geometry: TerminalGeometry; + readonly peer: TerminalPeer; + readonly connected: boolean; + readonly stats: TerminalStats; + readonly screenText: string; + readonly sizing: TerminalSizingState; + readonly inputBindings: InputBinding[]; + readonly inputContext: TerminalInputContext; + readonly viewport: TerminalViewport; + readonly selection: TerminalSelection; + runAction: RunTerminalAction; + scrollLines(delta: number): void; + scrollToLive(): void; + clearSelection(): void; + refreshSelectionUI(): void; + copySelection(options?: CopySelectionOptions): Promise; + paste(text: string): void; + pasteClipboard(): Promise; + focus(): void; + requestPrimary(): void; + resize(columns: number, rows: number): void; + setSizing(sizing: TerminalSizing): void; + resync(): void; + dispose(): void; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map new file mode 100644 index 00000000000..d317bd8da02 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js new file mode 100644 index 00000000000..718fd38ae40 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map new file mode 100644 index 00000000000..2cfc22bf143 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.d.ts new file mode 100644 index 00000000000..631c85a3212 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.d.ts @@ -0,0 +1,3 @@ +export declare function isRecord(value: unknown): value is Record; +export declare function errorMessage(error: unknown): string; +//# sourceMappingURL=validation.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.d.ts.map new file mode 100644 index 00000000000..93ca51b791c --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEnD"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.js new file mode 100644 index 00000000000..f27d691f498 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.js @@ -0,0 +1,7 @@ +export function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +export function errorMessage(error) { + return isRecord(error) && typeof error.message === "string" ? error.message : String(error); +} +//# sourceMappingURL=validation.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.js.map new file mode 100644 index 00000000000..a5b15277658 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/validation.js.map @@ -0,0 +1 @@ +{"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,QAAQ,CAAC,KAAc;IACrC,OAAO,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,OAAO,KAAK,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC9F,CAAC","sourcesContent":["export function isRecord(value: unknown): value is Record {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nexport function errorMessage(error: unknown): string {\n return isRecord(error) && typeof error.message === \"string\" ? error.message : String(error);\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts new file mode 100644 index 00000000000..b277ed2e0f7 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts @@ -0,0 +1,51 @@ +import type { CopySelectionOptions, InputActionHandler, InputBinding, TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport, WebTerminalHandle, WebTerminalOptions } from "./types.js"; +export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; +/** + * First-party HWT1 client. Owns only the element it appends, not the caller's + * container or the server terminal. The HWT1 wire and this spike API evolve together. + */ +export declare class WebTerminal implements WebTerminalHandle { + #private; + readonly element: HTMLDivElement; + /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */ + static mount(container: HTMLElement, options: WebTerminalOptions): Promise; + private constructor(); + get geometry(): TerminalGeometry; + get peer(): TerminalPeer; + get connected(): boolean; + get stats(): TerminalStats; + get screenText(): string; + get sizing(): TerminalSizingState; + get inputBindings(): InputBinding[]; + get viewport(): TerminalViewport; + get selection(): TerminalSelection; + scrollLines(delta: number): void; + scrollToLive(): void; + clearSelection(): void; + /** Re-notifies selection UI hosts after an external styling/policy change. */ + refreshSelectionUI(): void; + copySelection({ clear }?: CopySelectionOptions): Promise; + get inputContext(): TerminalInputContext; + /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */ + runAction(action: "copySelection", args?: CopySelectionOptions, input?: TerminalInput): Promise; + runAction(action: "pasteClipboard", args?: undefined, input?: TerminalInput): Promise; + runAction(action: "copyOrPaste", args?: undefined, input?: TerminalInput): Promise; + runAction(action: "clearSelection" | "scrollToLive", args?: undefined, input?: TerminalInput): Promise; + runAction(action: "scrollLines", args: number, input?: TerminalInput): Promise; + runAction(action: Name extends TerminalActionName ? never : Name, args?: unknown, input?: TerminalInput): Promise; + runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise; + /** Sends an explicit paste through the producer's mode-aware input encoder. */ + paste(text: string): void; + pasteClipboard(): Promise; + focus(): void; + /** Request HMP1 primary explicitly; peer notifications confirm the result. */ + requestPrimary(): void; + /** Request a grid; never reflow locally before the authoritative response. */ + resize(columns: number, rows: number): void; + /** Change the primary's sizing policy; applied grid dimensions still come from the server. */ + setSizing(sizing: TerminalSizing): void; + resync(): void; + /** Detach this view. The server-side shared terminal is not terminated. */ + dispose(): void; +} +//# sourceMappingURL=web-terminal.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map new file mode 100644 index 00000000000..47c1b7f199b --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js new file mode 100644 index 00000000000..a69516bbf9c --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js @@ -0,0 +1,728 @@ +import { captureMouse } from "./mouse-input.js"; +import { normalizeFont } from "./terminal-font.js"; +import { dimensions, normalizeSizing, requestedGrid, fittedScale } from "./terminal-sizing.js"; +import { HistoryState } from "./history-state.js"; +import { terminalThemeCss } from "./terminal-theme.js"; +import { InputPolicy, InputRoute, TerminalAction, inputModifiers } from "./input-policy.js"; +import { assertCommandSize } from "./protocol.js"; +import { SelectionUI } from "./selection-ui.js"; +import { errorMessage, isRecord } from "./validation.js"; +export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; +function requiredElement(root, selector, type) { + const element = root.querySelector(selector); + if (!(element instanceof type)) + throw new Error(`Missing terminal element: ${selector}`); + return element; +} +/** + * First-party HWT1 client. Owns only the element it appends, not the caller's + * container or the server terminal. The HWT1 wire and this spike API evolve together. + */ +export class WebTerminal { + element; + #options; + // DOM and worker fields are initialized by mount before a handle is returned. + #worker; + #surface; + #canvas; + #input; + #mouse; + #observer; + #listeners = new AbortController(); + #size = { width: 0, height: 0 }; + #sizing = normalizeSizing(); + #geometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 }; + #peer = { id: null, primaryId: null, isPrimary: false }; + #connected = false; + #disposed = false; + #hasGeometry = false; + #resizeTimer; + #lastRequested; + #compositionTimer; + #ready = Promise.withResolvers(); + #readyTimer; + #stats = {}; + #screenText = ""; + #history; + #highlights; + #inspection; + #inspectionError = ""; + #copySerial = 0; + #copying = false; + #policy; + #actions; + #clipboardAction = false; + #inputSerial = 0; + #selectionUI; + #selectionOverlay; + #selectionUIError = ""; + #canvasSize = { width: 0, height: 0 }; + /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */ + static async mount(container, options) { + if (!(container instanceof HTMLElement)) + throw new TypeError("A terminal container HTMLElement is required"); + if (!options?.url) + throw new TypeError("A terminal WebSocket URL is required"); + if (options.signal?.aborted) + throw options.signal.reason; + if (!window.isSecureContext || !navigator.gpu) + throw new Error("WebTerminal requires WebGPU over HTTPS or localhost"); + if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas || + !HTMLCanvasElement.prototype.transferControlToOffscreen) { + throw new Error("WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas"); + } + const terminal = new WebTerminal(options); + try { + await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]); + return terminal; + } + catch (error) { + terminal.dispose(); + throw error; + } + } + constructor(options) { + this.#options = options; + if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) && + (typeof options.workerUrl !== "string" || !options.workerUrl.trim())) + throw new TypeError("workerUrl must be a nonempty URL string or URL"); + if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== "function" || + options.onSelectionUI.constructor.name === "AsyncFunction")) + throw new TypeError("onSelectionUI must be a synchronous event handler"); + this.#policy = new InputPolicy(options); + this.#actions = new Map(Object.entries(options.actions ?? {})); + this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged()); + this.element = document.createElement("div"); + this.element.className = "hex1b-terminal"; + this.element.tabIndex = -1; + this.element.style.cssText = "width:100%;height:100%;min-width:0;min-height:0;contain:strict"; + } + get geometry() { return { ...this.#geometry }; } + get peer() { return { ...this.#peer }; } + get connected() { return this.#connected; } + get stats() { return { ...this.#stats }; } + get screenText() { return this.#screenText; } + get sizing() { return { ...this.#sizing }; } + get inputBindings() { return this.#policy.bindings; } + get viewport() { + const viewport = this.#history.viewport; + return { ...viewport, followTail: viewport.following, + offset: viewport.available ? viewport.liveTop - viewport.top : 0 }; + } + get selection() { + const selection = this.#history.selection; + return { ...selection, active: selection.status === "valid", pending: selection.status === "pending", + copying: this.#copying, copyError: this.#inspectionError }; + } + #start(container) { + if (this.#options.signal?.aborted) + throw this.#options.signal.reason; + const url = new URL(this.#options.url, location.href); + if (url.protocol === "https:") + url.protocol = "wss:"; + if (url.protocol === "http:") + url.protocol = "ws:"; + if (!["ws:", "wss:"].includes(url.protocol)) + throw new TypeError("A ws: or wss: URL is required"); + const scale = this.#options.scale === undefined || this.#options.scale === "auto" + ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale; + if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) + throw new RangeError("Backing scale must be 0.5-3 or 'auto'"); + const font = normalizeFont(this.#options.font, location.href); + this.#sizing = normalizeSizing(this.#options.sizing); + const shadow = this.element.attachShadow({ mode: "open" }); + shadow.innerHTML = ` + +
+ + + + +
+ + + +
`; + this.#surface = requiredElement(shadow, ".surface", HTMLDivElement); + this.#canvas = requiredElement(shadow, "canvas", HTMLCanvasElement); + this.#input = requiredElement(shadow, "textarea", HTMLTextAreaElement); + this.#highlights = requiredElement(shadow, ".highlights", HTMLDivElement); + this.#inspection = requiredElement(shadow, ".inspection", HTMLDivElement); + this.#selectionOverlay = document.createElement("div"); + this.#selectionOverlay.slot = "selection-ui"; + this.#selectionOverlay.className = "hex1b-selection-overlay"; + this.#selectionOverlay.style.cssText = "position:relative;width:100%;height:100%;pointer-events:none"; + this.element.append(this.#selectionOverlay); + this.#selectionUI = new SelectionUI({ + element: this.element, overlay: this.#selectionOverlay, + button: requiredElement(this.#inspection, ".copy-selection", HTMLButtonElement), signal: this.#listeners.signal, + getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry, + canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }), + runAction: this.runAction.bind(this), + onSelectionUI: this.#options.onSelectionUI, + reportError: error => { + this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : ""; + this.#selectionOverlay.hidden = !!error; + this.#renderInspectionStatus(); + if (error) + this.#options.onStatus?.(this.#selectionUIError, "error"); + } + }); + this.#selectionUI.refresh(); + this.#input.setAttribute("aria-label", this.#options.label || "Terminal input. Click outside to use page controls."); + this.#input.disabled = true; + container.append(this.element); + const inspect = (operation) => { + try { + this.#inspectionError = ""; + operation(); + } + catch (error) { + this.#inspectionError = errorMessage(error); + this.#inspectionChanged(); + } + }; + this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), { + state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly, + selection: this.selection }), + begin: (point, selection) => inspect(() => this.#history.begin(point, selection)), + extend: point => inspect(() => this.#history.extend(point)), + scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)), + end: cancelled => this.#history.endGesture(cancelled), + resolve: input => this.#resolveInput(input), + execute: (decision, input) => this.#executeInputAction(decision, input) + }); + this.#bindKeyboard(); + requiredElement(this.#inspection, ".return-live", HTMLButtonElement).addEventListener("click", () => { + this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error)); + }, { signal: this.#listeners.signal }); + requiredElement(this.#inspection, ".copy-selection", HTMLButtonElement).addEventListener("click", () => { + this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error)); + }, { signal: this.#listeners.signal }); + requiredElement(shadow, ".viewport", HTMLDivElement).addEventListener("pointerdown", event => { + if (event.composedPath().includes(this.#selectionOverlay)) + return; + if ((event.target instanceof Element && event.target.closest("button")) || + event.target === this.#canvas || event.target === this.#input) + return; + event.preventDefault(); + this.focus(); + }, { signal: this.#listeners.signal }); + this.#observer = new ResizeObserver(entries => { + const { width, height } = entries[0].contentRect; + const changed = width !== this.#size.width || height !== this.#size.height; + this.#size = { width, height }; + this.#fit(); + if (changed) + this.#queueResize(); + }); + // Observe the caller's outer box, never the fitted inner surface. + this.#observer.observe(container); + window.addEventListener("pagehide", () => this.dispose(), { signal: this.#listeners.signal }); + this.#options.signal?.addEventListener("abort", () => this.dispose(), { once: true, signal: this.#listeners.signal }); + this.#readyTimer = setTimeout(() => this.#fail(new Error("Timed out waiting for the terminal's first frame")), 30000); + this.#worker = this.#options.workerUrl === undefined + ? new Worker(new URL("./terminal-worker.js", import.meta.url), { type: "module", name: "Hex1b WebTerminal" }) + : new Worker(new URL(this.#options.workerUrl, location.href), { type: "module", name: "Hex1b WebTerminal" }); + this.#worker.addEventListener("message", (event) => this.#message(event.data)); + this.#worker.addEventListener("error", event => { + event.preventDefault(); + this.#fail(new Error(event.message || "Terminal worker failed")); + }); + this.#worker.addEventListener("messageerror", () => this.#fail(new Error("Terminal worker message could not be decoded"))); + const canvas = this.#canvas.transferControlToOffscreen(); + this.#post({ type: "init", canvas, url: url.href, scale, font }, [canvas]); + } + #message(message) { + if (this.#disposed) + return; + if (message.type === "connected") { + this.#connected = true; + this.#input.disabled = !this.#canInput(); + } + else if (message.type === "disconnected") { + this.#disconnect(); + } + else if (message.type === "status") { + if (message.level === "error") { + this.#disconnect(); + this.#ready.reject(new Error(message.message)); + } + this.#options.onStatus?.(message.message, message.level); + } + else if (message.type === "geometry") { + const first = !this.#hasGeometry; + const geometryChanged = first || ["columns", "rows", "cellWidth", "cellHeight", "mouseTracking"] + .some(field => this.#geometry[field] !== message[field]); + this.#geometry = { + columns: message.columns, rows: message.rows, + cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking + }; + this.#hasGeometry = true; + const oldPeer = this.#peer; + this.#peer = message.peer; + this.#input.disabled = !this.#canInput(); + if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) + this.focus(); + this.#mouse?.update(message.columns, message.rows, message.mouseTracking); + if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) + this.#fit(); + if (!this.#peer.isPrimary) { + clearTimeout(this.#resizeTimer); + this.#resizeTimer = undefined; + this.#lastRequested = undefined; + } + if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) + this.#queueResize(true); + if (this.#lastRequested === `${message.columns}x${message.rows}`) + this.#lastRequested = undefined; + if (Object.hasOwn(message, "history")) { + this.#screenText = message.text; + this.#history.accept(message.history, message.revision); + } + if (geometryChanged) + this.#options.onGeometry?.(this.geometry); + if (first) + this.#options.onSizingChange?.(this.sizing); + if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) { + this.#options.onRoleChange?.(this.peer); + } + } + else if (message.type === "history") { + this.#screenText = message.text; + this.#history.accept(message.history, message.revision); + } + else if (message.type === "stats") { + this.#stats = message.stats; + if (message.text !== undefined) + this.#screenText = message.text; + if (message.stats.revision > 0 && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) { + clearTimeout(this.#readyTimer); + this.#ready.resolve(this); + } + this.#options.onStats?.(this.stats, message.text); + } + } + #fit() { + const width = this.#geometry.columns * this.#geometry.cellWidth; + const height = this.#geometry.rows * this.#geometry.cellHeight; + const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing); + this.#surface.style.width = `${width * scale}px`; + this.#surface.style.height = `${height * scale}px`; + // Overlay positions use layout pixels, before any ancestor CSS transforms. + const style = getComputedStyle(this.#surface); + this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) }; + this.#selectionUI?.refresh(); + const dpr = window.devicePixelRatio || 1; + this.#post({ type: "viewport", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) }); + } + #fittedGrid() { + return requestedGrid(this.#size, this.#geometry, this.#sizing); + } + #queueResize(includeFixed = false) { + if (this.#sizing.mode === "fixed" && !includeFixed) + return; + if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) + return; + // Throttle (rather than debounce) so dragging a primary view updates peers live. + this.#resizeTimer = setTimeout(() => { + this.#resizeTimer = undefined; + const grid = this.#fittedGrid(); + if (!grid || !this.#peer.isPrimary || !this.#connected) + return; + const key = `${grid.columns}x${grid.rows}`; + if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) + return; + this.resize(grid.columns, grid.rows); + }, 50); + } + #post(message, transfer = []) { + this.#worker?.postMessage(message, transfer); + } + #send(command) { + if (!this.#connected || this.#disposed) + throw new Error("Terminal view is not connected"); + this.#post({ type: "command", command }); + } + #inputCommand(command) { + if (this.#disposed || !this.#canInput()) + return; + assertCommandSize(command); + this.#inputSerial++; + if (["input", "paste", "key"].includes(command.type) && this.viewport.available) { + this.#mouse?.cancel(); + if (this.selection.status !== "none") + this.clearSelection(); + if (!this.viewport.following || this.viewport.pending) + this.scrollToLive(); + } + this.#send(command); + } + #inspectionChanged() { + const viewport = this.viewport; + const selection = this.selection; + if (selection.status === "invalidated") + this.#mouse?.cancel(); + if (this.#highlights) { + this.#highlights.replaceChildren(...selection.ranges.map(range => { + const element = document.createElement("span"); + element.className = "highlight"; + element.setAttribute("part", "selection-highlight"); + element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`; + return element; + })); + const live = requiredElement(this.#inspection, ".return-live", HTMLButtonElement); + live.hidden = !viewport.available || (viewport.following && !viewport.pending); + live.disabled = !this.#connected; + this.#renderInspectionStatus(); + } + if (!this.#disposed) + this.#selectionUI?.refresh(); + this.#options.onViewportChange?.(viewport); + this.#options.onSelectionChange?.(selection); + } + scrollLines(delta) { this.#history.scroll(delta); } + scrollToLive() { this.#history.live(); } + clearSelection() { this.#inspectionError = ""; this.#history.clear(); } + /** Re-notifies selection UI hosts after an external styling/policy change. */ + refreshSelectionUI() { + if (this.#disposed) + throw new Error("Terminal view is disposed"); + this.#selectionUI?.refresh(true); + } + #renderInspectionStatus() { + if (!this.#inspection) + return; + const selection = this.selection; + const viewport = this.viewport; + const status = requiredElement(this.#inspection, ".inspection-message", HTMLSpanElement); + status.textContent = this.#selectionUIError || this.#inspectionError || + (selection.status === "unavailable" ? "" : selection.message) || + (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : ""); + status.hidden = !status.textContent; + status.dataset.level = this.#selectionUIError || this.#inspectionError || + selection.status === "invalidated" ? "error" : "info"; + } + #actionFailed(error) { + const failure = error instanceof Error ? error : new Error(String(error)); + this.#inspectionError = `Input action failed: ${failure.message}`; + this.#inspectionChanged(); + this.#options.onInputError?.(failure); + } + async copySelection({ clear = false } = {}) { + if (typeof clear !== "boolean") + throw new TypeError("Copy clear must be a boolean"); + const serial = ++this.#copySerial; + const selectionId = this.selection.requestId; + const generation = this.viewport.generation; + this.#inspectionError = ""; + try { + if (!this.#connected || this.#disposed) + throw new Error("Terminal view is not connected"); + if (!navigator.clipboard?.write || typeof ClipboardItem !== "function") { + throw new Error("Clipboard writing is unavailable. Use a secure browser context with clipboard permission."); + } + const text = this.#history.copy(); + this.#copying = true; + this.#inspectionChanged(); + // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously. + const item = new ClipboardItem({ "text/plain": text.then(value => new Blob([value], { type: "text/plain" })) }); + const [value] = await Promise.all([text, navigator.clipboard.write([item])]); + if (clear && serial === this.#copySerial && this.selection.status === "valid" && + this.selection.requestId === selectionId && this.viewport.generation === generation) + this.clearSelection(); + return value; + } + catch (error) { + if (serial === this.#copySerial) { + this.#history.cancelCopy(error); + this.#inspectionError = `Copy failed: ${errorMessage(error)}`; + } + throw error; + } + finally { + if (serial === this.#copySerial) + this.#copying = false; + this.#inspectionChanged(); + } + } + #canInput() { + return this.#connected && this.#hasGeometry && !this.#options.readOnly && + (this.#peer.id !== null || this.#peer.isPrimary); + } + get inputContext() { + return Object.freeze({ + terminal: this, selection: this.selection, viewport: this.viewport, + buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0, + historical: !this.viewport.following || this.viewport.pending, + readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer + }); + } + #resolveInput(input) { + try { + return this.#policy.resolve(Object.freeze(input), this.inputContext); + } + catch (error) { + this.#actionFailed(error); + return { route: InputRoute.Consume }; + } + } + #executeInputAction(decision, input) { + this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error)); + } + async runAction(action, args, input) { + return this.#performAction(action, args, input); + } + async #performAction(action, args, input) { + if (this.#disposed) + throw new Error("Terminal view is disposed"); + this.#inspectionError = ""; + if (typeof action === "function") + return action(this.inputContext, args, input); + const customAction = this.#actions.get(action); + if (customAction) + return customAction(this.inputContext, args, input); + switch (action) { + case TerminalAction.CopySelection: + if (args === undefined) + return this.copySelection(); + if (!isRecord(args)) + throw new TypeError("Copy options must be an object"); + if (args.clear !== undefined && typeof args.clear !== "boolean") + throw new TypeError("Copy clear must be a boolean"); + return this.copySelection({ clear: args.clear }); + case TerminalAction.PasteClipboard: return this.pasteClipboard(); + case TerminalAction.ClearSelection: return this.clearSelection(); + case TerminalAction.ScrollToLive: return this.scrollToLive(); + case TerminalAction.ScrollLines: + if (typeof args !== "number") + throw new RangeError("Scroll delta must be a signed 32-bit integer"); + return this.scrollLines(args); + case TerminalAction.CopyOrPaste: + if (this.#clipboardAction) + throw new Error("A clipboard action is still in progress. Try again when it finishes."); + this.#clipboardAction = true; + try { + if (this.selection.active || (this.selection.pending && this.selection.canExtend)) + return await this.copySelection({ clear: true }); + if (!this.#options.readOnly) + return await this.pasteClipboard(); + return; + } + finally { + this.#clipboardAction = false; + } + default: throw new TypeError(`Unknown terminal action: ${action}`); + } + } + /** Sends an explicit paste through the producer's mode-aware input encoder. */ + paste(text) { + if (typeof text !== "string") + throw new TypeError("Paste text must be a string"); + if (!this.#canInput()) + throw new Error("Terminal view does not accept input"); + if (text) + this.#inputCommand({ type: "paste", text }); + } + async pasteClipboard() { + if (!this.#canInput()) + throw new Error("Terminal view does not accept input"); + if (!navigator.clipboard?.readText) + throw new Error("Clipboard reading is unavailable. Use the browser's paste shortcut instead."); + const serial = this.#inputSerial; + const generation = this.viewport.generation; + const selectionId = this.selection.requestId; + const focused = document.activeElement; + const text = await navigator.clipboard.readText(); + if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation || + selectionId !== this.selection.requestId || document.activeElement !== focused) + throw new Error("Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again."); + this.paste(text); + return text; + } + #forwardInput(input) { + if (input.type === "key") { + if (input.meta) + throw new Error("Meta has no terminal key encoding; bind this shortcut to a local action instead."); + this.#inputCommand({ type: "key", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift }); + } + else if (input.type === "paste") { + if (this.#canInput()) + this.paste(input.text); + } + else if (input.type === "text") { + this.#inputCommand({ type: "input", text: input.text }); + } + } + #dispatchInput(input, event, allowApplication = true) { + const decision = this.#resolveInput(input); + // Shortcuts may run on inspection controls, but Enter/Space must still activate the control. + if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) + return; + if (decision.route === InputRoute.Browser || + (decision.route === InputRoute.Continue && input.type === "key")) + return; + event?.preventDefault(); + event?.stopPropagation(); + if (decision.action !== undefined) + this.#executeInputAction(decision, input); + else if (decision.route !== InputRoute.Consume) { + try { + this.#forwardInput(input); + } + catch (error) { + this.#actionFailed(error); + } + } + } + #bindKeyboard() { + const input = this.#input; + const options = { signal: this.#listeners.signal }; + let composing = false; + let compositionCommit = null; + this.element.addEventListener("keydown", event => { + if (event.defaultPrevented) + return; + if (event.composedPath().includes(this.#selectionOverlay)) + return; + const target = event.composedPath()[0]; + if (!this.#connected || event.isComposing || composing || event.key === "Process" || event.key === "Dead") + return; + if (event.getModifierState("AltGraph")) + return; + this.#dispatchInput({ type: "key", key: event.key, code: event.code, repeat: event.repeat, + ...inputModifiers(event) }, event, target === input || target === this.element); + }, { ...options, capture: true }); + input.addEventListener("paste", event => { + if (event.defaultPrevented || !this.#connected || !event.clipboardData) + return; + this.#dispatchInput({ type: "paste", text: event.clipboardData.getData("text/plain") }, event); + input.value = ""; + }, options); + input.addEventListener("compositionstart", () => { + composing = true; + compositionCommit = null; + clearTimeout(this.#compositionTimer); + }, options); + input.addEventListener("compositionend", event => { + composing = false; + // Accommodate browsers placing the final input before or after compositionend. + compositionCommit = typeof event.data === "string" ? event.data : input.value; + this.#compositionTimer = setTimeout(() => { + if (compositionCommit) + this.#dispatchInput({ type: "text", text: compositionCommit }); + compositionCommit = null; + input.value = ""; + }, 0); + }, options); + input.addEventListener("input", event => { + const inputEvent = event instanceof InputEvent ? event : undefined; + if (inputEvent?.isComposing || composing) + return; + clearTimeout(this.#compositionTimer); + const text = compositionCommit ?? inputEvent?.data ?? input.value; + compositionCommit = null; + if (text && inputEvent?.inputType !== "insertFromPaste") + this.#dispatchInput({ type: "text", text }, event); + input.value = ""; + }, options); + } + focus() { + if (this.#disposed) + return; + const target = this.#canInput() ? this.#input : this.element; + target.focus({ preventScroll: true }); + } + /** Request HMP1 primary explicitly; peer notifications confirm the result. */ + requestPrimary() { + if (this.#size.width <= 0 || this.#size.height <= 0) + throw new Error("Show the terminal container before taking primary"); + const grid = this.#fittedGrid(); + if (!grid) + throw new Error("Show the terminal container before taking primary"); + if (this.#peer.id === null) { + if (!this.#peer.isPrimary) + throw new Error("Waiting for the HMP1 connection"); + this.resize(grid.columns, grid.rows); + return; + } + this.#send({ type: "requestPrimary", ...grid }); + } + /** Request a grid; never reflow locally before the authoritative response. */ + resize(columns, rows) { + const grid = dimensions(columns, rows); + if (!this.#peer.isPrimary) + throw new Error("Only the primary view can request a terminal resize"); + this.#send({ type: "resize", ...grid }); + this.#lastRequested = `${columns}x${rows}`; + } + /** Change the primary's sizing policy; applied grid dimensions still come from the server. */ + setSizing(sizing) { + const next = normalizeSizing(sizing, this.#sizing.fontSize); + if (!this.#connected || this.#disposed) + throw new Error("Terminal view is not connected"); + if (!this.#peer.isPrimary) + throw new Error("Only the primary view can change terminal sizing"); + this.#sizing = next; + clearTimeout(this.#resizeTimer); + this.#resizeTimer = undefined; + this.#lastRequested = undefined; + this.#fit(); + this.#queueResize(true); + this.#options.onSizingChange?.(this.sizing); + } + resync() { this.#send({ type: "resync" }); } + #disconnect() { + this.#inputSerial++; + this.#connected = false; + if (this.#input) + this.#input.disabled = true; + this.#mouse?.update(1, 1, 0); + this.#history.disconnect(); + this.#inspectionChanged(); + clearTimeout(this.#resizeTimer); + this.#resizeTimer = undefined; + } + #fail(error) { + this.#disconnect(); + this.#ready.reject(error); + this.#options.onStatus?.(error.message, "error"); + this.#worker?.terminate(); + } + /** Detach this view. The server-side shared terminal is not terminated. */ + dispose() { + if (this.#disposed) + return; + this.#disposed = true; + this.#disconnect(); + this.#ready.reject(new DOMException("Terminal view was disposed", "AbortError")); + clearTimeout(this.#readyTimer); + clearTimeout(this.#compositionTimer); + this.#observer?.disconnect(); + this.#mouse?.dispose(); + this.#listeners.abort(); + this.#post({ type: "stop" }); + this.#worker?.terminate(); + this.element.remove(); + } +} +//# sourceMappingURL=web-terminal.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map new file mode 100644 index 00000000000..c07dfd661ee --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map @@ -0,0 +1 @@ +{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (!window.isSecureContext || !navigator.gpu) throw new Error(\"WebTerminal requires WebGPU over HTTPS or localhost\");\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input)\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"disconnected\") {\n this.#disconnect();\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#connected) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#options.readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try { return this.#policy.resolve(Object.freeze(input), this.inputContext); }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#options.readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts new file mode 100644 index 00000000000..572f6242293 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts @@ -0,0 +1,218 @@ +import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalStatusLevel } from "./types.js"; +export type SelectionText = { + status: "valid"; + text: string; +} | { + status: "none" | "invalidated"; + text: null; +}; +export type HistorySelection = SelectionText & { + requestId: number; + mode: SelectionMode; + ranges: SelectionRange[]; +}; +export interface HistoryMetadata { + generation: string; + buffer: TerminalBuffer; + totalRows: number; + liveTop: number; + top: number; + following: boolean; + requestId: number; + rowIds: string[]; + selection: HistorySelection; + copy: (SelectionText & { + requestId: number; + }) | null; +} +export interface TerminalCell { + index: number; + foreground: number; + background: number; + underlineColor: number; + attributes: number; + width: number; + underlineStyle: number; + text: string; +} +export interface ImageMetadata { + key: string; + width: number; + height: number; + byteLength: number; + format: "rgba" | "png"; +} +export interface FrameImage extends ImageMetadata { + bytes: Uint8Array; +} +export interface ImagePlacement { + key: string; + kind: "kgp" | "sixel"; + x: number; + y: number; + width: number; + height: number; + sourceX: number; + sourceY: number; + sourceWidth: number; + sourceHeight: number; + clipX: number; + clipY: number; + clipWidth: number; + clipHeight: number; + z: number; +} +export interface FrameMetadata extends TerminalGeometry { + version: 1; + full: boolean; + revision: number; + baseRevision: number; + peer: TerminalPeer; + history: HistoryMetadata | null; + defaultBackground?: number; + defaultForeground?: number; + cursor: { + visible: boolean; + x: number; + y: number; + shape: number; + }; + images: ImageMetadata[]; + retainedImages: string[]; + placements: ImagePlacement[]; + warnings: string[]; + stats: { + workloadBytes: number; + outputBatches: number; + captureMs: number; + elapsedMs: number; + }; +} +export interface TerminalFrame { + metadata: FrameMetadata; + cells: TerminalCell[]; + images: FrameImage[]; +} +export type CellPosition = { + x: number; + y: number; +} & Partial>; +export type MouseButton = PointerButton | "none" | "wheelUp" | "wheelDown" | "wheelLeft" | "wheelRight"; +export type MouseCommand = { + type: "mouse"; + action: "down" | "up" | "move" | "wheel"; + button: MouseButton; + count?: number; +} & CellPosition; +export type InputCommand = { + type: "input" | "paste"; + text: string; +} | { + type: "key"; + key: string; + ctrl: boolean; + alt: boolean; + shift: boolean; +} | MouseCommand; +export type TerminalCommand = InputCommand | { + type: "viewport"; + requestId: number; + delta?: number; + live?: boolean; + extend?: { + row: number; + column: number; + }; +} | { + type: "selection"; + action: "clear"; + requestId: number; +} | { + type: "selection"; + action: "start" | "extend"; + mode: SelectionMode; + requestId: number; + generation: string; + rowId: string; + column: number; +} | { + type: "copy"; + requestId: number; + selectionRequestId: number; + generation: string; +} | { + type: "resize" | "requestPrimary"; + columns: number; + rows: number; +} | { + type: "resync"; +} | { + type: "ack"; + revision: number; +}; +export type WorkerInputMessage = { + type: "init"; + canvas: OffscreenCanvas; + url: string; + scale: number; + font: TerminalFont; +} | ({ + type: "viewport"; +} & TerminalSize) | { + type: "command"; + command: TerminalCommand; +} | { + type: "stop"; +}; +export interface WorkerStats extends TerminalStats { + revision: number; + fullFrames: number; + frames: number; + presentations: number; + changedCells: number; + lastChangedCells: number; + discardedFrames: number; + imageCount: number; + textureBytes: number; + atlasGlyphs: number; + atlasBytes: number; + bytesReceived: number; + imageUploadBytes: number; + imagePayloadBytes: number; + gpu: "initializing" | "ready" | "error" | "stopped"; + connected: boolean; + warnings: string[]; + fps: number; + receivedKBps: number; + workloadMBps: number; + captureMs: number; + rendererCpuMs: number; + preparationCpuMs: number; + workloadBytes: number; + outputBatches: number; + serverElapsedMs: number; + history?: HistoryMetadata | null; +} +export type WorkerOutputMessage = { + type: "connected" | "disconnected"; +} | { + type: "status"; + message: string; + level: TerminalStatusLevel; +} | ({ + type: "geometry"; + peer: TerminalPeer; + history: HistoryMetadata | null; + revision: number; + text: string; +} & TerminalGeometry) | { + type: "history"; + history: HistoryMetadata | null; + revision: number; + text: string; +} | { + type: "stats"; + stats: WorkerStats; + text?: string; +}; +//# sourceMappingURL=wire-types.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map new file mode 100644 index 00000000000..8b1b51dbf8b --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js new file mode 100644 index 00000000000..8c3c20d277f --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=wire-types.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map new file mode 100644 index 00000000000..aed85a03666 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalStatusLevel } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" | \"disconnected\" }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; text: string } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json new file mode 100644 index 00000000000..6d2e7cd8b99 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hex1b/web-terminal", + "version": "0.167.0-alpha.1509.1.1f47fd9", + "description": "First-party WebGPU browser terminal for Hex1b", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/mitchdenny/hex1b.git", + "directory": "src/web-terminal" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "node scripts/clean.mjs && tsc -p tsconfig.json && node scripts/copy-assets.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test tests/*.test.mjs && tsc -p tests/types/tsconfig.json && tsc -p tests/types/tsconfig.bundler.json", + "prepack": "npm run build" + }, + "devDependencies": { + "@webgpu/types": "0.1.69", + "typescript": "5.9.3" + } +} diff --git a/src/Aspire.Dashboard/wwwroot/js/hmp1-client.js b/src/Aspire.Dashboard/wwwroot/js/hmp1-client.js deleted file mode 100644 index eb01485fd65..00000000000 --- a/src/Aspire.Dashboard/wwwroot/js/hmp1-client.js +++ /dev/null @@ -1,354 +0,0 @@ -// Hex1b Muxer Protocol (HMP) v1 client over WebSocket. Pure JS, -// no dependencies. Speaks the same wire format as -// src/Hex1b/Hmp1/Hmp1Protocol.cs in the Hex1b package the Aspire -// dashboard's terminal endpoint forwards through. Frames: -// [type:1B][length:4B LE][payload:N bytes] -// JSON payloads are camelCase to match Hmp1JsonContext.cs. -// -// This file is a port of -// samples/WebMuxerDemo/wwwroot/js/hmp1-client.js -// from the Hex1b repo (microsoft/hex1b) at version 0.147.0. -// Keep the surface in sync with that file when possible so future -// upstream changes can be replayed verbatim. The dashboard does not -// add any dashboard-specific UI hooks here — that's all in -// TerminalView.razor.js, which imports this module. - -const FrameType = Object.freeze({ - Hello: 0x01, - StateSync: 0x02, - Output: 0x03, - Input: 0x04, - Resize: 0x05, - Exit: 0x06, - RequestPrimary: 0x07, - RoleChange: 0x08, - PeerJoin: 0x09, - PeerLeave: 0x0a, - ClientHello: 0x0b, -}); - -const HEADER_SIZE = 5; - -// Hard cap on a single HMP frame payload. The wire format uses a 4-byte LE -// signed length, so the spec maximum is ~2GiB - in practice nothing legit -// emits more than a few hundred KB at a time (Output frames are flushed -// frequently, JSON control frames are small). Anything wildly larger is -// either corruption, version skew, or a malicious upstream trying to OOM -// the browser. Refuse and let the caller close the WS so the reconnect -// path takes over. -const MAX_FRAME_PAYLOAD = 16 * 1024 * 1024; - -// FrameBuffer accumulates incoming WS payloads and yields complete HMP1 -// frames. WebSocket message boundaries do NOT align with HMP1 frame -// boundaries (especially when the server batches Output frames, which -// it does aggressively), so we buffer. -class FrameBuffer { - constructor() { - this._chunks = []; - this._totalLength = 0; - } - - push(arrayBuffer) { - const view = new Uint8Array(arrayBuffer); - this._chunks.push(view); - this._totalLength += view.byteLength; - } - - *drain() { - while (true) { - const frame = this._tryReadOne(); - if (frame === null) { - return; - } - yield frame; - } - } - - _tryReadOne() { - if (this._totalLength < HEADER_SIZE) { - return null; - } - const header = this._peek(HEADER_SIZE); - const dv = new DataView(header.buffer, header.byteOffset, HEADER_SIZE); - const type = dv.getUint8(0); - const length = dv.getInt32(1, true); - // Reject negative lengths (would desync the buffer because - // `total < HEADER_SIZE`) and absurdly large lengths (would attempt to - // allocate a multi-GiB Uint8Array which either throws inside the - // `message` handler - silently wedging the client - or OOMs the tab). - if (length < 0 || length > MAX_FRAME_PAYLOAD) { - throw new Error(`HMP1 protocol error: invalid frame length ${length} (type=${type}).`); - } - const total = HEADER_SIZE + length; - if (this._totalLength < total) { - return null; - } - const payload = this._take(total).slice(HEADER_SIZE); - return { type, payload }; - } - - _peek(n) { - return this._concat(n, /* consume */ false); - } - - _take(n) { - return this._concat(n, /* consume */ true); - } - - _concat(n, consume) { - if (n === 0) { - return new Uint8Array(0); - } - const out = new Uint8Array(n); - let written = 0; - let chunkIndex = 0; - while (written < n && chunkIndex < this._chunks.length) { - const chunk = this._chunks[chunkIndex]; - const need = n - written; - const take = Math.min(need, chunk.byteLength); - out.set(chunk.subarray(0, take), written); - written += take; - chunkIndex += 1; - } - if (consume) { - // Discard fully-consumed chunks; keep the partial tail of the last one. - let consumed = n; - while (consumed > 0 && this._chunks.length > 0) { - const chunk = this._chunks[0]; - if (chunk.byteLength <= consumed) { - consumed -= chunk.byteLength; - this._chunks.shift(); - } else { - this._chunks[0] = chunk.subarray(consumed); - consumed = 0; - } - } - this._totalLength -= n; - } - return out; - } -} - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder("utf-8", { fatal: false }); - -function buildFrame(type, payload) { - const len = payload ? payload.byteLength : 0; - const out = new Uint8Array(HEADER_SIZE + len); - out[0] = type; - new DataView(out.buffer).setInt32(1, len, /* littleEndian */ true); - if (len > 0) { - out.set(payload, HEADER_SIZE); - } - return out; -} - -function buildJsonFrame(type, obj) { - return buildFrame(type, textEncoder.encode(JSON.stringify(obj))); -} - -function buildResizePayload(cols, rows) { - const out = new Uint8Array(8); - const dv = new DataView(out.buffer); - dv.setInt32(0, cols, true); - dv.setInt32(4, rows, true); - return out; -} - -function parseResize(payload) { - if (payload.byteLength < 8) { - return { cols: 0, rows: 0 }; - } - const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); - return { cols: dv.getInt32(0, true), rows: dv.getInt32(4, true) }; -} - -function parseJson(payload) { - if (payload.byteLength === 0) { - return null; - } - return JSON.parse(textDecoder.decode(payload)); -} - -// Hmp1Client connects to the dashboard's /api/terminal WebSocket -// endpoint, which forwards verbatim to the upstream Aspire.TerminalHost -// over the resource's per-replica consumer UDS. From the upstream's -// perspective this client is just another HMP v1 peer in its -// multi-head roster. Take-control / role-change / state-replay all -// flow through end-to-end without any dashboard-side translation. -// -// Event hooks (set as properties; null-tolerant, single-cast): -// onOpen() — WS open -// onScreenBytes(uint8array) — Output / StateSync bytes for the terminal -// onHello(payload) — first contact: peerId, primaryPeerId, -// width, height, peers -// onRoleChange(payload) — primary changed (carries new primaryPeerId -// and the size that comes with it) -// onPeerJoin(payload) — peer roster delta -// onPeerLeave(payload) — peer roster delta -// onResize(cols, rows) — producer broadcast new dims (echo of -// accepted Resize) -// onExit(code) — workload exited -// onClose(event) — WS closed -export class Hmp1Client { - constructor({ url, displayName, defaultRole }) { - this._url = url; - this._displayName = displayName ?? "browser"; - this._defaultRole = defaultRole ?? "secondary"; - this._buffer = new FrameBuffer(); - this._ws = null; - - this.peerId = null; - this.primaryPeerId = null; - this.width = 0; - this.height = 0; - this.peers = []; // [{ peerId, displayName }] - - this.onOpen = null; - this.onScreenBytes = null; - this.onHello = null; - this.onRoleChange = null; - this.onPeerJoin = null; - this.onPeerLeave = null; - this.onResize = null; - this.onExit = null; - this.onClose = null; - } - - get isPrimary() { - return this.peerId !== null && this.primaryPeerId === this.peerId; - } - - connect() { - const ws = new WebSocket(this._url); - ws.binaryType = "arraybuffer"; - this._ws = ws; - - ws.addEventListener("open", () => { - this._send(buildJsonFrame(FrameType.ClientHello, { - displayName: this._displayName, - defaultRole: this._defaultRole, - })); - if (this.onOpen) this.onOpen(); - }); - - ws.addEventListener("message", (ev) => { - this._buffer.push(ev.data); - try { - for (const frame of this._buffer.drain()) { - this._dispatch(frame); - } - } catch (err) { - // A protocol-level fault (invalid length prefix, malformed JSON in a - // control frame) leaves the buffer permanently desynced. Closing the - // WS triggers the dashboard's reconnect path, which resets state. - // Use code 1002 (protocol error) so server-side logs can distinguish - // this from a clean client-initiated close. - if (this.onError) { try { this.onError(err); } catch { /* ignore */ } } - try { ws.close(1002, "HMP1 protocol error"); } catch { /* ignore */ } - } - }); - - ws.addEventListener("close", (ev) => { - this._ws = null; - if (this.onClose) this.onClose(ev); - }); - - ws.addEventListener("error", () => { - // close event will fire next. - }); - } - - close() { - if (this._ws) { - try { this._ws.close(); } catch { /* ignore */ } - this._ws = null; - } - } - - sendInput(bytes) { - if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; - const buf = typeof bytes === "string" ? textEncoder.encode(bytes) : bytes; - this._send(buildFrame(FrameType.Input, buf)); - } - - // Only sent if we are primary — otherwise the server silently drops it. - // Callers may always invoke sendResize and rely on isPrimary gating. - sendResize(cols, rows) { - if (!this.isPrimary) return; - if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; - this._send(buildFrame(FrameType.Resize, buildResizePayload(cols, rows))); - } - - requestPrimary(cols, rows) { - if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; - this._send(buildJsonFrame(FrameType.RequestPrimary, { cols, rows })); - } - - _send(bytes) { - this._ws.send(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); - } - - _dispatch(frame) { - switch (frame.type) { - case FrameType.Hello: { - const p = parseJson(frame.payload); - this.peerId = p.peerId ?? null; - this.primaryPeerId = p.primaryPeerId ?? null; - this.width = p.width ?? 0; - this.height = p.height ?? 0; - this.peers = Array.isArray(p.peers) ? p.peers.slice() : []; - if (this.onHello) this.onHello(p); - break; - } - case FrameType.StateSync: - case FrameType.Output: { - if (this.onScreenBytes && frame.payload.byteLength > 0) { - this.onScreenBytes(frame.payload); - } - break; - } - case FrameType.Resize: { - const r = parseResize(frame.payload); - this.width = r.cols; - this.height = r.rows; - if (this.onResize) this.onResize(r.cols, r.rows); - break; - } - case FrameType.RoleChange: { - const p = parseJson(frame.payload); - this.primaryPeerId = p.primaryPeerId ?? null; - this.width = p.width ?? this.width; - this.height = p.height ?? this.height; - if (this.onRoleChange) this.onRoleChange(p); - break; - } - case FrameType.PeerJoin: { - const p = parseJson(frame.payload); - // Only add if not already present (defensive vs. replays). - if (!this.peers.some(x => x.peerId === p.peerId)) { - this.peers.push({ peerId: p.peerId, displayName: p.displayName }); - } - if (this.onPeerJoin) this.onPeerJoin(p); - break; - } - case FrameType.PeerLeave: { - const p = parseJson(frame.payload); - this.peers = this.peers.filter(x => x.peerId !== p.peerId); - if (this.onPeerLeave) this.onPeerLeave(p); - break; - } - case FrameType.Exit: { - let code = 0; - if (frame.payload.byteLength >= 4) { - code = new DataView(frame.payload.buffer, frame.payload.byteOffset, 4).getInt32(0, true); - } - if (this.onExit) this.onExit(code); - break; - } - default: - // Unknown frame types are ignored (forward compatibility). - break; - } - } -} diff --git a/src/Aspire.Dashboard/wwwroot/js/xterm/addon-fit.min.js b/src/Aspire.Dashboard/wwwroot/js/xterm/addon-fit.min.js deleted file mode 100644 index 9f4e48c182e..00000000000 --- a/src/Aspire.Dashboard/wwwroot/js/xterm/addon-fit.min.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Skipped minification because the original files appears to be already minified. - * Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})())); -//# sourceMappingURL=addon-fit.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.css b/src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.css deleted file mode 100644 index aced1fa464a..00000000000 --- a/src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.css +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Minified by jsDelivr using clean-css v5.3.3. - * Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative} -/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */ \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.js b/src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.js deleted file mode 100644 index 0a51bfb69f6..00000000000 --- a/src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Skipped minification because the original files appears to be already minified. - * Original file: /npm/@xterm/xterm@5.5.0/lib/xterm.js - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=document.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},3551:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585),c=i(4725);let l=t.Linkifier=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=l=s([r(1,c.IMouseService),r(2,c.IRenderService),r(3,h.IBufferService),r(4,c.ILinkProviderService)],l)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let c=-1,l=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):h(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,c=o.extended.urlId):(l=-1,c=-1)}}t(s)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(3551),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),w=i(8969),y=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O=i(779);class P extends w.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new y.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new y.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new y.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new y.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new y.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new y.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new y.EventEmitter),this._onBlur=this.register(new y.EventEmitter),this._onA11yCharEmitter=this.register(new y.EventEmitter),this._onA11yTabEmitter=this.register(new y.EventEmitter),this._onWillOpen=this.register(new y.EventEmitter),this._setup(),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(O.LinkProviderService),this._instantiationService.setService(S.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,y.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,y.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this._viewportScrollArea=this._document.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this.register((0,r.addDisposableDomListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement),this.textarea=this._document.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this.register(this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.linkifier=this.register(this._instantiationService.createInstance(n.Linkifier,this.screenElement)),this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(e))return!1;if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):this.viewport?.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure(),this.viewport?.syncScrollArea(!0)}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderDimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderDimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderDimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(i=e),s=""}}return{bufferElements:r,cursorElement:i}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(4725),o=i(844),a=i(2585);let h=t.BufferDecorationRenderer=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],h)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(4725),a=i(844),h=i(2585),c={full:0,left:0,center:0,right:0},l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=t.OverviewRulerRenderer=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement);const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,a.toDisposable)((()=>{this._canvas?.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);l.full=this._canvas.width,l.left=e,l.center=t,l.right=e,this._refreshDrawHeightConstants(),d.full=0,d.left=0,d.center=l.left,d.right=l.left+l.center}_refreshDrawHeightConstants(){c.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);c.left=t,c.center=t,c.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-c[e.position||"full"]/2),l[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+c[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,o.IRenderService),r(5,h.IOptionsService),r(6,o.ICoreBrowserService)],_)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(6052),l=i(4725),d=i(8055),_=i(8460),u=i(844),f=i(2585),v="xterm-dom-renderer-owner-",p="xterm-rows",g="xterm-fg-",m="xterm-bg-",S="xterm-focus",C="xterm-selection";let b=1,w=t.DomRenderer=class extends u.Disposable{constructor(e,t,i,s,r,a,l,d,f,g,m,S,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=l,this._charSizeService=f,this._optionsService=g,this._bufferService=m,this._coreBrowserService=S,this._themeService=w,this._terminalClass=b++,this._rowElements=[],this._selectionRenderModel=(0,c.createSelectionRenderModel)(),this.onRequestRedraw=this.register(new _.EventEmitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(p),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(C),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(v+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,u.toDisposable)((()=>{this._element.classList.remove(v+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${p} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${p} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${p} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${C} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${C} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${C} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${g}${i} { color: ${s.css}; }${this._terminalSelector} .${g}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${m}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;this._selectionRenderModel.update(this._terminal,e,t,i);const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow;if(n>=this._bufferService.rows||o<0)return;const a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${v}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=w=s([r(7,f.IInstantiationService),r(8,l.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,l.ICoreBrowserService),r(12,l.IThemeService)],w)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(w&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){I.isInvisible()?y+=o.WHITESPACE_CELL_CHAR:y+=N,w++;continue}w&&(C.textContent=y),C=this._document.createElement("span"),w=0,y=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F&&this._coreService.isCursorInitialized)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),y=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===y&&(y=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===y&&(y=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.channels.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,G)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=y:w++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&w&&(C.textContent=y),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.computeNextVariantOffset=t.createRenderDimensions=t.treatGlyphAsBackgroundColor=t.allowRescaling=t.isEmoji=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},6052:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=void 0;class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),h=Math.min(o,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}t.createSelectionRenderModel=function(){return new i}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this.register(new d(this._optionsService))}catch{this._measureStrategy=this.register(new l(e,t,this._optionsService))}this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c extends a.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class l extends c{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends c{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(844),r=i(8460),n=i(3656);class o extends s.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=new a(this._window),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this.register(new r.EventEmitter),this.onWindowChange=this._onWindowChange.event,this.register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this.register((0,r.forwardEvent)(this._screenDprMonitor.onDprChange,this._onDprChange)),this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this.register(new s.MutableDisposable),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this.register((0,s.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,n.addDisposableDomListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},779:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(844);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this.register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(6193),o=i(4725),a=i(8460),h=i(844),c=i(7226),l=i(2585);let d=t.RenderService=class extends h.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,l,d){super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new h.MutableDisposable),this._pausedResizeTask=new c.DebouncedIdleTask,this._observerDisposable=this.register(new h.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new a.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new a.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new a.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new a.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),l),this.register(this._renderDebouncer),this.register(l.onDprChange((()=>this.handleDevicePixelRatioChange()))),this.register(o.onResize((()=>this._fullRefresh()))),this.register(o.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(o.cols,o.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(o.buffer.y,o.buffer.y,!0)))),this.register(d.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(l.window,t),this.register(l.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,h.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d=s([r(2,l.IOptionsService),r(3,o.ICharSizeService),r(4,l.IDecorationService),r(5,l.IBufferService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],d)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;let i=0,s=0,r=0,n=0;var o,a,h,c,l;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function _(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=l.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=l.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=l.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1]),s=parseInt(h[2]),r=parseInt(h[3]),n=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(c||(t.rgb=c={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,h=t>>16&255,c=t>>8&255,l=e>>24&255,d=e>>16&255,_=e>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=c.relativeLuminance(e>>8),n=c.relativeLuminance(i>>8);if(_(r,n)>8));if(o_(r,c.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),h=_(r,c.relativeLuminance(o>>8));if(h_(r,c.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(l||(t.rgba=l={})),t.toPaddedHex=d,t.contrastRatio=_},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.runAndSubscribe=t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))},t.runAndSubscribe=function(e,t){return t(void 0),e((e=>t(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(1480),g=i(6242),m=i(6351),S=i(5941),C={"(":0,")":1,"*":2,"+":3,"-":1,".":2},b=131072;function w(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let E=0;class k extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>b&&(n=this._parseStack.position+b)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthb)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(h){const e=f;let t=this._activeBuffer.x-m;for(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),m>0&&f instanceof l.BufferLine&&f.copyCellsFrom(e,t,0,m,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}else if(d&&(f.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,u)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!w(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!w(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(D(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,S.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,S.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=k;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function D(e){return 0<=e&&e<256}L=s([r(0,v.IBufferService)],L)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(1480),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,h=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rescaleOverlappingGlyphs:!1,rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this.register((0,r.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);class n{static extractShouldJoin(e){return 0!=(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let h=n.extractWidth(a);n.extractShouldJoin(a)&&(h-=n.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); -//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs new file mode 100644 index 00000000000..0807a8f6330 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Tests.Shared; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.JSInterop; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +[UseCulture("en-US")] +public class TerminalViewTests : DashboardTestContext +{ + public TerminalViewTests() + { + FluentUISetupHelpers.AddCommonDashboardServices(this); + FluentUISetupHelpers.SetupFluentUIComponents(this); + } + + [Theory] + [InlineData("unsupported", nameof(Resources.ConsoleLogs.TerminalWebGpuUnsupported))] + [InlineData("mount-failed", nameof(Resources.ConsoleLogs.TerminalMountFailed))] + [InlineData("disconnected", nameof(Resources.ConsoleLogs.TerminalDisconnected))] + [InlineData("input-failed", nameof(Resources.ConsoleLogs.TerminalInputFailed))] + [InlineData("sizing-failed", nameof(Resources.ConsoleLogs.TerminalSizingFailed))] + public async Task TerminalError_DisplaysLocalizedAlert(string error, string resourceKey) + { + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + module.Setup("initTerminal", _ => true).SetResult(1); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "app")); + var loc = Services.GetRequiredService>(); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, + Generation = 1, + Error = error + })); + + Assert.Equal(loc[resourceKey].Value, cut.Find("[role=alert]").TextContent); + Assert.Equal(error == "unsupported" ? 0 : 1, cut.FindAll("fluent-button").Count); + } + + [Fact] + public void InitializationFailure_DisplaysErrorWithoutRetryLoop() + { + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var initialization = module.Setup("initTerminal", _ => true); + initialization.SetException(new JSException("Worker module unavailable")); + + var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "app")); + + cut.WaitForAssertion(() => + { + Assert.Equal(Resources.ConsoleLogs.TerminalMountFailed, cut.Find("[role=alert]").TextContent); + Assert.Single(initialization.Invocations); + }); + } + + [Theory] + [InlineData("http://localhost:8080/aspire/", "/aspire/Components/Controls/TerminalView.razor.js", "ws://localhost:8080/aspire/api/terminal?resource=app%20%26%20name&replica=2")] + [InlineData("https://dashboard.example/nested/aspire/", "/nested/aspire/Components/Controls/TerminalView.razor.js", "wss://dashboard.example/nested/aspire/api/terminal?resource=app%20%26%20name&replica=2")] + public void Initialization_PreservesPathBaseAndWebSocketScheme(string baseUri, string modulePath, string socketUrl) + { + Services.AddSingleton(new TestNavigationManager(baseUri)); + var module = JSInterop.SetupModule(modulePath); + var initialization = module.Setup("initTerminal", _ => true); + initialization.SetResult(1); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + + var cut = RenderComponent(builder => builder + .Add(p => p.ResourceName, "app & name") + .Add(p => p.ReplicaIndex, 2)); + + cut.WaitForAssertion(() => + { + var invocation = Assert.Single(initialization.Invocations); + Assert.Equal(socketUrl, invocation.Arguments[1]); + Assert.Equal(Resources.ConsoleLogs.TerminalInputLabel, invocation.Arguments[3]); + }); + } + + [Fact] + public async Task Reconnect_IgnoresOldErrorAndClearsCurrentErrorAfterSuccess() + { + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + module.Setup("initTerminal", _ => true).SetResult(1); + module.Setup("reconnectTerminal", _ => true).SetResult(2); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + var snapshots = new List(); + var cut = RenderComponent(builder => builder + .Add(p => p.ResourceName, "first") + .Add(p => p.OnToolbarStateChanged, state => snapshots.Add(state))); + + await cut.InvokeAsync(() => cut.Instance.ReconnectAsync("second", 1)); + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Error = "mount-failed" + })); + Assert.Empty(snapshots); + Assert.Empty(cut.FindAll("[role=alert]")); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 2, Error = "disconnected" + })); + Assert.Equal(Resources.ConsoleLogs.TerminalDisconnected, cut.Find("[role=alert]").TextContent); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 2, Connected = true + })); + Assert.Empty(cut.FindAll("[role=alert]")); + Assert.Collection(snapshots, + state => Assert.Equal("disconnected", state.Error), + state => Assert.True(state.Connected)); + } + + [Fact] + public async Task DisposalDuringInitialization_DisposesReturnedTerminalExactlyOnce() + { + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var initialization = module.Setup("initTerminal", _ => true); + var disposal = module.SetupVoid("disposeTerminal", _ => true); + disposal.SetVoidResult(); + var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "app")); + cut.WaitForAssertion(() => Assert.Single(initialization.Invocations)); + + var disposing = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); + Assert.False(disposing.IsCompleted); + initialization.SetResult(1); + await disposing; + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); + + var invocation = Assert.Single(disposal.Invocations); + Assert.Equal(1, invocation.Arguments[0]); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/KeyboardShortcuts.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/KeyboardShortcuts.test.mjs new file mode 100644 index 00000000000..9a8605b939f --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/KeyboardShortcuts.test.mjs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; + +const source = await readFile(new URL("../../../src/Aspire.Dashboard/wwwroot/js/app.js", import.meta.url), "utf8"); + +function element(tagName, activeElement) { + return { + tagName, children: [], + shadowRoot: activeElement ? { activeElement, children: [activeElement] } : null, + }; +} + +function shortcutsFor(activeElement) { + const listeners = new Map(); + const shortcuts = []; + const document = { + activeElement, + readyState: "loading", + body: { querySelector: () => null, classList: { remove() {} } }, + addEventListener: (type, listener) => listeners.set(type, listener), + removeEventListener: type => listeners.delete(type), + }; + const window = { document, addEventListener() {} }; + runInNewContext(source, { document, window }); + const registration = window.registerGlobalKeydownListener({ + invokeMethodAsync: (method, shortcut) => { + assert.equal(method, "OnGlobalKeyDown"); + shortcuts.push(shortcut); + }, + }); + for (const key of ["c", "r", "s", "t", "m", "?", "S", "+", "-"]) { + listeners.get("keydown")({ key }); + } + window.unregisterGlobalKeydownListener(registration); + assert.equal(listeners.has("keydown"), false); + return shortcuts; +} + +test("terminal textarea focus inside a non-Fluent shadow host suppresses dashboard shortcuts", () => { + assert.deepEqual(shortcutsFor(element("DIV", element("TEXTAREA"))), []); +}); + +test("nested shadow input focus suppresses dashboard shortcuts", () => { + assert.deepEqual(shortcutsFor(element("DIV", element("CUSTOM-EDITOR", element("INPUT")))), []); +}); + +test("native and Fluent inputs still suppress dashboard shortcuts", () => { + for (const target of [element("INPUT"), element("TEXTAREA"), element("FLUENT-TEXT-FIELD", element("INPUT"))]) { + assert.deepEqual(shortcutsFor(target), []); + } +}); + +test("dashboard shortcuts remain available outside inputs", () => { + for (const target of [element("BODY"), element("BUTTON"), element("DIV", element("BUTTON"))]) { + assert.deepEqual(shortcutsFor(target), [210, 200, 220, 230, 240, 100, 110, 330, 340]); + } +}); diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs new file mode 100644 index 00000000000..5f8a44ff5eb --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -0,0 +1,432 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import assert from "node:assert/strict"; +import { afterEach, beforeEach, mock, test } from "node:test"; +import { readFile } from "node:fs/promises"; + +const dashboard = new URL("../../../src/Aspire.Dashboard/", import.meta.url); +const assets = new URL("wwwroot/js/hex1b-web-terminal/", dashboard); +const { WebTerminal } = await import(new URL("dist/index.js", assets)); +const source = await readFile(new URL("Components/Controls/TerminalView.razor.js", dashboard), "utf8"); +// Remap the public browser asset import to its checked-in location for Node, +// without changing the adapter implementation under test. +const terminal = await import(`data:text/javascript;base64,${Buffer.from(source.replace( + '"../../js/hex1b-web-terminal/dist/index.js"', JSON.stringify(new URL("dist/index.js", assets).href) +)).toString("base64")}`); + +let attempts; +let observers; +let timers; +let frames; +let ids; +let snapshots; +let serial; +const globals = new Map(); +let originalMount; + +function setGlobal(name, value) { + globals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); +} + +beforeEach(() => { + mock.method(console, "warn", () => {}); + attempts = []; + observers = []; + timers = new Map(); + frames = new Map(); + ids = []; + snapshots = []; + serial = 0; + setGlobal("window", { isSecureContext: true }); + setGlobal("navigator", { gpu: {} }); + setGlobal("document", { activeElement: null, body: {} }); + setGlobal("requestAnimationFrame", callback => { + frames.set(++serial, callback); + return serial; + }); + setGlobal("cancelAnimationFrame", id => frames.delete(id)); + setGlobal("setTimeout", (callback, delay) => { + timers.set(++serial, { callback, delay }); + return serial; + }); + setGlobal("clearTimeout", id => timers.delete(id)); + setGlobal("ResizeObserver", class { + constructor(callback) { + this.callback = callback; + this.disconnected = false; + observers.push(this); + } + observe(element) { this.element = element; } + disconnect() { this.disconnected = true; } + }); + originalMount = WebTerminal.mount; + WebTerminal.mount = (element, options) => { + const ready = Promise.withResolvers(); + const client = { + element: { contains: value => value === client.element }, + connected: true, + peer: { id: "browser-1", primaryId: "cli-1", isPrimary: false }, + geometry: { columns: 100, rows: 30 }, + sizing: { ...options.sizing }, + sizingCalls: [], + primaryRequests: 0, + focusCalls: 0, + selectionRefreshes: 0, + disposed: false, + dispose() { this.disposed = true; }, + requestPrimary() { this.primaryRequests++; }, + setSizing(sizing) { + assert.equal(this.peer.isPrimary, true, "Sizing must wait for confirmed primary"); + this.sizing = sizing; + this.sizingCalls.push(sizing); + options.onSizingChange(sizing); + }, + focus() { this.focusCalls++; }, + refreshSelectionUI() { this.selectionRefreshes++; }, + }; + const attempt = { + element, options, client, + resolve() { ready.resolve(client); }, + reject(error = new Error("No first frame")) { ready.reject(error); }, + role(primary) { + client.peer = { ...client.peer, primaryId: primary ? client.peer.id : "cli-1", isPrimary: primary }; + options.onRoleChange(client.peer); + }, + }; + // Deliberately allow completion after abort to exercise stale async + // cleanup independently of the package's own cancellation safeguards. + attempts.push(attempt); + return ready.promise; + }; +}); + +afterEach(async () => { + for (const id of ids) { + terminal.disposeTerminal(id); + } + for (const attempt of attempts) { + attempt.reject(); + } + await settle(); + WebTerminal.mount = originalMount; + mock.restoreAll(); + for (const [name, descriptor] of globals) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + delete globalThis[name]; + } + } + globals.clear(); +}); + +function mount({ visible = true, dotNetRef } = {}) { + const element = { + clientWidth: visible ? 800 : 0, + clientHeight: visible ? 600 : 0, + contains: value => value === element, + }; + const id = terminal.initTerminal(element, "wss://dashboard/api/terminal?resource=app&replica=1", + dotNetRef ?? { invokeMethodAsync: (_name, snapshot) => snapshots.push(snapshot) }, "Localized terminal input"); + ids.push(id); + return { id, element }; +} + +async function settle() { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) { + callback(); + } + } +} + +function retry() { + assert.equal(timers.size, 1); + const [id, { callback, delay }] = timers.entries().next().value; + timers.delete(id); + callback(); + return delay; +} + +test("init returns an id while mount waits for its first connected frame", async () => { + const { id } = mount(); + assert.equal(terminal.getToolbarState(id).connected, false); + assert.equal(attempts[0].options.label, "Localized terminal input"); + assert.equal(attempts[0].options.url, "wss://dashboard/api/terminal?resource=app&replica=1"); + attempts[0].options.onStatus("Socket open", "ready"); + attempts[0].role(false); + await settle(); + assert.equal(snapshots.at(-1).connected, false); + attempts[0].resolve(); + await settle(); + assert.deepEqual(snapshots.at(-1), { + terminalId: id, generation: 1, status: "viewer", connected: true, + isPrimary: false, canTakeControl: true, sizeMode: "font", sizeKey: "auto", + fontPx: 13, fontControlsEnabled: true, sizeSelectEnabled: true, + cols: 100, rows: 30, error: null, + }); + assert.equal(attempts[0].client.primaryRequests, 0); + assert.equal(attempts[0].options.onInput, undefined); + assert.equal(attempts[0].options.readOnly, undefined); +}); + +test("unsupported WebGPU and insecure origins produce a localizable error without retrying", async () => { + navigator.gpu = undefined; + const first = mount(); + navigator.gpu = {}; + window.isSecureContext = false; + const second = mount(); + await settle(); + assert.equal(attempts.length, 0); + assert.equal(timers.size, 0); + assert.equal(terminal.getToolbarState(first.id).error, "unsupported"); + assert.equal(terminal.getToolbarState(second.id).error, "unsupported"); +}); + +test("hidden initial mounts wait for visibility without consuming the first-frame timeout", async () => { + const { id, element } = mount({ visible: false }); + assert.equal(attempts.length, 0); + element.clientWidth = 800; + element.clientHeight = 600; + terminal.refreshLayout(id); + assert.equal(attempts.length, 1); + observers[0].callback(); + assert.equal(attempts.length, 1); + attempts[0].resolve(); + await settle(); + element.clientWidth = 0; + terminal.refreshLayout(id); + element.clientWidth = 800; + terminal.refreshLayout(id); + assert.equal(attempts.length, 1); + assert.equal(attempts[0].client.selectionRefreshes, 1); + assert.equal(attempts[0].client.primaryRequests, 0); + assert.deepEqual(attempts[0].client.sizingCalls, []); +}); + +test("mount failure reports an error and retries with a fresh abortable generation", async () => { + const { id } = mount(); + attempts[0].reject(); + await settle(); + assert.equal(snapshots.at(-1).error, "mount-failed"); + assert.equal(attempts[0].options.signal.aborted, true); + assert.equal(retry(), 500); + assert.equal(terminal.getToolbarState(id).generation, 2); + assert.equal(attempts.length, 2); + attempts[1].resolve(); + await settle(); + assert.equal(snapshots.at(-1).error, null); + assert.equal(snapshots.at(-1).connected, true); +}); + +test("resource reconnect aborts pending mount and ignores late completion and callbacks", async () => { + const { id } = mount(); + assert.equal(terminal.reconnectTerminal(id, "wss://dashboard/api/terminal?resource=other&replica=2"), 2); + assert.equal(attempts[0].options.signal.aborted, true); + attempts[1].resolve(); + await settle(); + const expected = terminal.getToolbarState(id); + attempts[0].resolve(); + attempts[0].role(true); + attempts[0].options.onGeometry({ columns: 20, rows: 10 }); + attempts[0].options.onStatus("old socket closed", "error"); + await settle(); + assert.equal(attempts[0].client.disposed, true); + assert.deepEqual(terminal.getToolbarState(id), expected); + assert.equal(timers.size, 0); +}); + +test("a disconnect schedules only one retry and restores focus only if still appropriate", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + document.activeElement = attempts[0].client.element; + attempts[0].client.connected = false; + attempts[0].options.onStatus("closed", "error"); + attempts[0].options.onStatus("closed again", "error"); + assert.equal(timers.size, 1); + assert.equal(attempts[0].client.disposed, true); + retry(); + document.activeElement = document.body; + attempts[1].resolve(); + await settle(); + assert.equal(attempts[1].client.focusCalls, 1); + assert.equal(terminal.getToolbarState(id).connected, true); +}); + +test("sizing requests primary, waits for confirmation, and clamps to the public font limits", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + terminal.setFontSizeFromHost(id, 72); + assert.equal(attempts[0].client.primaryRequests, 1); + assert.deepEqual(attempts[0].client.sizingCalls, []); + assert.equal(terminal.getToolbarState(id).isPrimary, false); + attempts[0].role(true); + assert.deepEqual(attempts[0].client.sizingCalls, [{ mode: "auto", fontSize: 32 }]); + terminal.setFontSizeFromHost(id, 4); + terminal.setSizeModeFromHost(id, "132x50"); + terminal.setSizeModeFromHost(id, "not-a-preset"); + assert.deepEqual(attempts[0].client.sizingCalls, [ + { mode: "auto", fontSize: 32 }, + { mode: "auto", fontSize: 8 }, + { mode: "fixed", columns: 132, rows: 50, fontSize: 8 }, + ]); + // Geometry remains producer-authoritative; a request cannot rewrite it. + assert.equal(terminal.getToolbarState(id).cols, 100); + assert.equal(terminal.getToolbarState(id).sizeKey, "132x50"); + assert.equal(terminal.getToolbarState(id).fontControlsEnabled, false); +}); + +test("clipboard errors remain visible without discarding the mounted history", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + attempts[0].options.onInputError(new Error("Clipboard denied")); + await settle(); + assert.equal(terminal.getToolbarState(id).error, "input-failed"); + assert.equal(attempts[0].client.disposed, false); + assert.equal(timers.size, 0); +}); + +test("remote role changes authoritatively switch primary, viewer and unclaimed states", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + attempts[0].role(true); + assert.equal(terminal.getToolbarState(id).status, "primary"); + assert.equal(terminal.getToolbarState(id).canTakeControl, false); + attempts[0].role(false); + assert.equal(terminal.getToolbarState(id).status, "viewer"); + assert.equal(terminal.getToolbarState(id).isPrimary, false); + assert.equal(terminal.getToolbarState(id).canTakeControl, true); + attempts[0].options.onRoleChange({ id: "browser-1", primaryId: null, isPrimary: false }); + assert.equal(terminal.getToolbarState(id).status, "no-primary"); + assert.equal(terminal.getToolbarState(id).canTakeControl, true); + assert.equal(attempts[0].client.primaryRequests, 0); + assert.deepEqual(attempts[0].client.sizingCalls, []); +}); + +test("dispose aborts pending mount, cancels queued work and ignores later results", async () => { + const { id } = mount(); + terminal.disposeTerminal(id); + attempts[0].resolve(); + await settle(); + assert.equal(attempts[0].options.signal.aborted, true); + assert.equal(attempts[0].client.disposed, true); + assert.equal(observers[0].disconnected, true); + assert.equal(terminal.getToolbarState(id), null); + assert.equal(timers.size, 0); + assert.deepEqual(snapshots, []); +}); + +test("rejected Blazor notifications do not become unhandled rejections", async () => { + const { id } = mount({ dotNetRef: { invokeMethodAsync: () => Promise.reject(new Error("Circuit disposed")) } }); + attempts[0].resolve(); + await settle(); + terminal.refreshToolbarState(id); + await settle(); + assert.equal(terminal.getToolbarState(id).connected, true); +}); + +test("explicit reconnect cancels the automatic retry and drops a pending sizing request", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + terminal.setSizeModeFromHost(id, "80x24"); + terminal.reconnectTerminal(id, "wss://dashboard/api/terminal?resource=other"); + assert.equal(attempts[0].options.signal.aborted, true); + assert.equal(attempts[0].client.disposed, true); + attempts[0].role(true); + assert.deepEqual(attempts[0].client.sizingCalls, []); + attempts[1].reject(); + await settle(); + assert.equal(timers.size, 1); + terminal.reconnectTerminal(id, "wss://dashboard/api/terminal?resource=third"); + assert.equal(timers.size, 0); + attempts[2].resolve(); + await settle(); + assert.equal(terminal.getToolbarState(id).generation, 3); + assert.equal(terminal.getToolbarState(id).sizeKey, "auto"); +}); + +test("automatic retries are bounded and explicit reconnect resets the exhausted budget", async () => { + const { id } = mount(); + for (let i = 0; i <= 30; i++) { + attempts.at(-1).reject(); + await settle(); + if (i < 30) { + retry(); + } + } + assert.equal(attempts.length, 31); + assert.equal(timers.size, 0); + assert.equal(terminal.getToolbarState(id).error, "disconnected"); + terminal.reconnectTerminal(id, "wss://dashboard/api/terminal?resource=app"); + attempts.at(-1).reject(); + await settle(); + assert.equal(retry(), 500); +}); + +test("frontend manifest, lockfile, vendored package and backend use the exact paired version", async () => { + const manifest = JSON.parse(await readFile(new URL("package.json", dashboard), "utf8")); + const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); + const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); + const version = manifest.dependencies["@hex1b/web-terminal"]; + assert.equal(version, "0.167.0-alpha.1509.1.1f47fd9"); + assert.equal(vendored.version, version); + assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); + assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); + + // Central package rows have the form: + // + // Match the exact Include value, not Hex1b.Tool or Hex1b.McpServer; + // whitespace, attribute order and either XML quote style are allowed. + const packages = await readFile(new URL("../../Directory.Packages.props", dashboard), "utf8"); + const declarations = [...packages.matchAll(/]*\/>/g)] + .map(match => match[0]) + .filter(declaration => /\bInclude\s*=\s*["']Hex1b["']/.test(declaration)); + assert.equal(declarations.length, 1, "Expected exactly one central Hex1b library version."); + const backendVersion = declarations[0].match(/\bVersion\s*=\s*["']([^"']+)["']/); + assert.ok(backendVersion, "The paired Hex1b library must have an explicit central version."); + assert.equal(backendVersion[1], version); +}); + +test("checked-in deployment includes the worker and licensed font without npm installation", async () => { + for (const name of [ + "dist/index.js", + "dist/terminal-worker.js", + "dist/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2", + "dist/fonts/cascadia-mono-nf/LICENSE.txt", + "dist/fonts/cascadia-mono-nf/README.md", + "LICENSE", + "README.md", + ]) { + assert.ok((await readFile(new URL(name, assets))).length > 0, `Missing or empty vendored asset: ${name}`); + } +}); + +test("entry, module worker and bundled font URLs preserve PathBase and same origin", async () => { + // Inspect the emitted forms: + // import { WebTerminal, ... } from "../../js/.../dist/index.js"; + // new Worker(new URL("./terminal-worker.js", import.meta.url), ...); + // new URL("./fonts/.../CascadiaMonoNF.woff2", import.meta.url).href; + // Keeping these module-relative URLs avoids both PathBase escapes and + // blob/cross-origin worker URLs that require relaxing the dashboard CSP. + const entryReference = source.match(/from "([^"]+)"/)[1]; + const entryUrl = new URL(entryReference, "https://dashboard.example/nested/aspire/Components/Controls/TerminalView.razor.js"); + assert.equal(entryUrl.href, "https://dashboard.example/nested/aspire/js/hex1b-web-terminal/dist/index.js"); + const clientSource = await readFile(new URL("dist/web-terminal.js", assets), "utf8"); + const workerReference = clientSource.match(/new Worker\(new URL\("([^"]+)", import\.meta\.url\)/)[1]; + assert.equal(new URL(workerReference, entryUrl).href, + "https://dashboard.example/nested/aspire/js/hex1b-web-terminal/dist/terminal-worker.js"); + const fontSource = await readFile(new URL("dist/terminal-font.js", assets), "utf8"); + const fontReference = fontSource.match(/new URL\("([^"]+)", import\.meta\.url\)/)[1]; + assert.equal(new URL(fontReference, entryUrl).href, + "https://dashboard.example/nested/aspire/js/hex1b-web-terminal/dist/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2"); +}); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index 6dbb0a90562..68cda16e9e2 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -583,6 +583,7 @@ public async Task TerminalResource_ViewToggle_RenderedDisplayStylesMatchActiveVi [Fact] public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitialization() { + Services.AddLocalization(); var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); var initTerminal = module.Setup("initTerminal", _ => true); var reconnectTerminal = module.Setup("reconnectTerminal", _ => true); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestNavigationManager.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestNavigationManager.cs new file mode 100644 index 00000000000..928f0aa401a --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestNavigationManager.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +internal sealed class TestNavigationManager : NavigationManager +{ + public TestNavigationManager(string baseUri) + { + Initialize(baseUri, baseUri); + } + + protected override void NavigateToCore(string uri, bool forceLoad) + { + Uri = ToAbsoluteUri(uri).AbsoluteUri; + NotifyLocationChanged(isInterceptedLink: false); + } +} diff --git a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs new file mode 100644 index 00000000000..e969c15566d --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.IO.Pipelines; +using System.Net.WebSockets; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Terminal; +using Aspire.Dashboard.Tests.Integration; +using Aspire.Hosting; +using Hex1b; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Dashboard.Tests.Shared; + +internal sealed class TerminalTestHost : ITerminalConnectionResolver, IAsyncDisposable +{ + private readonly ConcurrentBag> _connections = []; + private readonly CancellationTokenSource _stopping = new(); + private readonly DashboardWebApplication _app; + private readonly Hex1bTerminal _producer; + + public TerminalTestHost(ITestOutputHelper output, bool requireAuthentication) + { + Workload = new Hex1bAppWorkloadAdapter(); + Presentation = new Hmp1PresentationAdapter(100, 30); + _producer = Hex1bTerminal.CreateBuilder() + .WithWorkload(Workload) + .WithPresentation(Presentation) + .WithDimensions(100, 30) + .WithScrollback(100) + .Build(); + _app = IntegrationTestHelpers.CreateDashboardWebApplication(output, + additionalConfiguration: configuration => + { + if (requireAuthentication) + { + configuration[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.BrowserToken); + configuration[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = "test-token"; + } + }, + preConfigureBuilder: builder => builder.Services.AddSingleton(this)); + } + + public Hex1bAppWorkloadAdapter Workload { get; } + public Hmp1PresentationAdapter Presentation { get; } + public int ConnectionCount => _connections.Count; + + public Task StartAsync(CancellationToken cancellationToken) => _app.StartAsync(cancellationToken); + + public async Task ConnectBrowserAsync(CancellationToken cancellationToken) + { + var frontend = new Uri(_app.FrontendSingleEndPointAccessor().GetResolvedAddress()); + var socket = new ClientWebSocket(); + socket.Options.SetRequestHeader("Origin", frontend.GetLeftPart(UriPartial.Authority)); + try + { + await socket.ConnectAsync(new UriBuilder(frontend) + { + Scheme = "ws", + Path = "/api/terminal", + Query = "resource=test&replica=0" + }.Uri, cancellationToken); + return socket; + } + catch + { + socket.Dispose(); + throw; + } + } + + public Task ConnectAsync(string resourceName, int replicaIndex, CancellationToken cancellationToken) + { + var toClient = new Pipe(); + var toServer = new Pipe(); + var server = new DuplexStream(toServer.Reader.AsStream(), toClient.Writer.AsStream()); + var client = new DuplexStream(toClient.Reader.AsStream(), toServer.Writer.AsStream()); + _connections.Add(Presentation.AddClient(server, _stopping.Token)); + return Task.FromResult(client); + } + + public async ValueTask DisposeAsync() + { + await _stopping.CancelAsync(); + await _app.DisposeAsync(); + foreach (var connection in _connections) + { + await using var handle = await connection; + } + await _producer.DisposeAsync(); + _stopping.Dispose(); + } + + private sealed class DuplexStream(Stream input, Stream output) : Stream + { + public override bool CanRead => input.CanRead; + public override bool CanWrite => output.CanWrite; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => input.Read(buffer, offset, count); + public override void Write(byte[] buffer, int offset, int count) => output.Write(buffer, offset, count); + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => input.ReadAsync(buffer, cancellationToken); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => output.WriteAsync(buffer, cancellationToken); + public override void Flush() => output.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => output.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + input.Dispose(); + output.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs new file mode 100644 index 00000000000..98264a1ec5a --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -0,0 +1,202 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Aspire.Dashboard.Tests.Shared; +using Hex1b.Input; +using Xunit; + +namespace Aspire.Dashboard.Tests.Terminal; + +public class TerminalWebSocketTests(ITestOutputHelper output) +{ + [Fact] + public async Task BrowserView_PreservesRemotePrimaryAndResizeAcrossReconnect() + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var first = await host.ConnectBrowserAsync(timeout.Token); + var initial = await ReadUntilAsync(first, frame => frame.GetProperty("peer").GetProperty("id").ValueKind == JsonValueKind.String, timeout.Token); + Assert.Equal(100, initial.GetProperty("columns").GetInt32()); + Assert.Equal(30, initial.GetProperty("rows").GetInt32()); + + // A browser resize cannot seize the remote producer's primary role. + await SendAsync(first, """{"type":"resize","columns":80,"rows":24}""", timeout.Token); + await SendAsync(first, """{"type":"requestPrimary","columns":80,"rows":24}""", timeout.Token); + var primary = await ReadUntilAsync(first, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token); + Assert.Equal(80, primary.GetProperty("columns").GetInt32()); + Assert.Equal(24, primary.GetProperty("rows").GetInt32()); + Assert.Equal(primary.GetProperty("peer").GetProperty("id").GetString(), host.Presentation.PrimaryPeerId); + + using var second = await host.ConnectBrowserAsync(timeout.Token); + var viewer = await ReadUntilAsync(second, frame => frame.GetProperty("peer").GetProperty("id").ValueKind == JsonValueKind.String, timeout.Token); + Assert.False(viewer.GetProperty("peer").GetProperty("isPrimary").GetBoolean()); + Assert.Equal(80, viewer.GetProperty("columns").GetInt32()); + + await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnect", timeout.Token); + await ReadUntilAsync(second, frame => frame.GetProperty("peer").GetProperty("primaryId").ValueKind == JsonValueKind.Null, timeout.Token); + await SendAsync(second, """{"type":"requestPrimary","columns":132,"rows":30}""", timeout.Token); + var takeover = await ReadUntilAsync(second, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token); + Assert.Equal(132, takeover.GetProperty("columns").GetInt32()); + await second.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + } + + [Fact] + public async Task BrowserView_ReassemblesFragmentedUtf8Input() + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var browser = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(browser, _ => true, timeout.Token); + + var message = Encoding.UTF8.GetBytes("{\"type\":\"input\",\"text\":\"hello \u00e9\"}"); + var split = Array.IndexOf(message, (byte)0xc3) + 1; + await browser.SendAsync(message.AsMemory(0, split), WebSocketMessageType.Text, false, timeout.Token); + await browser.SendAsync(message.AsMemory(split), WebSocketMessageType.Text, true, timeout.Token); + + var input = new StringBuilder(); + while (input.Length < "hello \u00e9".Length) + { + var inputEvent = await host.Workload.InputEvents.ReadAsync(timeout.Token); + if (inputEvent is Hex1bKeyEvent key) + { + input.Append(key.Text); + } + } + Assert.Equal("hello \u00e9", input.ToString()); + await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BrowserView_RejectsInvalidMessageTypeOrOversizedInput(bool oversized) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var browser = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(browser, _ => true, timeout.Token); + + if (oversized) + { + await browser.SendAsync(new byte[64 * 1024], WebSocketMessageType.Text, false, timeout.Token); + } + else + { + await browser.SendAsync(new byte[] { 1 }, WebSocketMessageType.Binary, true, timeout.Token); + } + + var buffer = new byte[64 * 1024]; + WebSocketReceiveResult result; + do + { + result = await browser.ReceiveAsync(buffer, timeout.Token); + } + while (result.MessageType != WebSocketMessageType.Close); + Assert.Equal(WebSocketCloseStatus.PolicyViolation, result.CloseStatus); + } + + [Theory] + [InlineData("\u001bP7;1q\"1;1;2;6#1;2;100;0;0#1BB\u001b\\")] + [InlineData("\u001b_Ga=T,f=32,s=1,v=1,i=7,p=11,C=1,q=2;/wAA/w==\u001b\\")] + public async Task BrowserView_ProjectsSixelAndKittyGraphics(string sequence) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var browser = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(browser, _ => true, timeout.Token); + + host.Workload.Write(sequence); + var graphics = await ReadUntilAsync(browser, frame => frame.GetProperty("placements").GetArrayLength() > 0, timeout.Token); + Assert.Single(graphics.GetProperty("placements").EnumerateArray()); + Assert.Single(graphics.GetProperty("images").EnumerateArray()); + await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + + using var reconnected = await host.ConnectBrowserAsync(timeout.Token); + var restored = await ReadUntilAsync(reconnected, frame => frame.GetProperty("placements").GetArrayLength() > 0, timeout.Token); + Assert.Single(restored.GetProperty("placements").EnumerateArray()); + Assert.Single(restored.GetProperty("images").EnumerateArray()); + await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + } + + [Fact] + public async Task BrowserView_RequiresAuthenticationBeforeConnectingToProducer() + { + await using var host = new TerminalTestHost(output, requireAuthentication: true); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + + await Assert.ThrowsAsync(() => host.ConnectBrowserAsync(timeout.Token)); + + Assert.Equal(0, host.ConnectionCount); + } + + [Fact] + public async Task BrowserView_ProducerDisconnectClosesBrowserWhileWaitingForAcknowledgement() + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var browser = await host.ConnectBrowserAsync(timeout.Token); + var buffer = new byte[64 * 1024]; + var initial = await browser.ReceiveAsync(buffer, timeout.Token); + Assert.Equal(WebSocketMessageType.Binary, initial.MessageType); + Assert.True(initial.EndOfMessage); + + await host.Presentation.DisposeAsync(); + + try + { + var closed = await browser.ReceiveAsync(buffer, timeout.Token); + Assert.Equal(WebSocketMessageType.Close, closed.MessageType); + } + catch (WebSocketException ex) + { + // Cancelling the server's pending ReceiveAsync can abort the socket. + // Either close path must end promptly, without waiting for the HWT ACK timeout. + Assert.Equal(WebSocketError.ConnectionClosedPrematurely, ex.WebSocketErrorCode); + } + } + + private static Task SendAsync(WebSocket socket, string message, CancellationToken cancellationToken) + { + return socket.SendAsync(Encoding.UTF8.GetBytes(message), WebSocketMessageType.Text, true, cancellationToken); + } + + private static async Task ReadUntilAsync(WebSocket socket, Func predicate, CancellationToken cancellationToken) + { + var buffer = new byte[64 * 1024]; + while (true) + { + using var message = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(buffer, cancellationToken); + Assert.Equal(WebSocketMessageType.Binary, result.MessageType); + message.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + // HWT1: four-byte magic, little-endian JSON byte length, JSON metadata, + // then binary cell/image sections. Inspect only metadata in these transport tests. + var bytes = message.ToArray(); + Assert.Equal("HWT1", Encoding.ASCII.GetString(bytes, 0, 4)); + var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4)); + using var document = JsonDocument.Parse(bytes.AsMemory(8, length)); + var frame = document.RootElement; + await SendAsync(socket, $$"""{"type":"ack","revision":{{frame.GetProperty("revision").GetUInt32()}}}""", cancellationToken); + if (predicate(frame)) + { + return frame.Clone(); + } + } + } +} diff --git a/tests/Infrastructure.Tests/WorkflowScripts/DashboardTerminalScriptTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/DashboardTerminalScriptTests.cs new file mode 100644 index 00000000000..ebe52b7a6f5 --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/DashboardTerminalScriptTests.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +public class DashboardTerminalScriptTests(ITestOutputHelper output) +{ + [Fact] + [RequiresTools(["node"])] + public async Task TerminalLifecycleAndPackageContract() + { + await RunScriptAsync("TerminalView.test.mjs"); + } + + [Fact] + [RequiresTools(["node"])] + public async Task TerminalInputDoesNotActivateDashboardShortcuts() + { + await RunScriptAsync("KeyboardShortcuts.test.mjs"); + } + + private async Task RunScriptAsync(string script) + { + using var command = new NodeCommand(output) + .WithWorkingDirectory(RepoRoot.Path) + .WithTimeout(TimeSpan.FromSeconds(60)); + var result = await command.ExecuteScriptAsync(Path.Combine(RepoRoot.Path, + "tests", "Aspire.Dashboard.Components.Tests", "JavaScript", script)); + + Assert.Equal(0, result.ExitCode); + } +} From 13644d23aa245c03c18438fb187c998caf502299 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Sep 2026 12:14:36 +1000 Subject: [PATCH 047/106] Upgrade Hex1b terminals with WebGL2 and hyperlink replay Pair Hex1b and @hex1b/web-terminal at 0.167.0-alpha.1519.1.b8be265. Enable package-owned automatic renderer selection and safe OSC 8 hyperlinks, including destinations restored across reconnect. Regenerate the complete browser distribution and update localized errors, documentation, and targeted regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 22 +- .../Components/Controls/TerminalView.razor | 5 +- .../Components/Controls/TerminalView.razor.cs | 3 +- .../Components/Controls/TerminalView.razor.js | 11 +- .../Resources/ConsoleLogs.Designer.cs | 6 - .../Resources/ConsoleLogs.resx | 5 +- .../Resources/xlf/ConsoleLogs.cs.xlf | 9 +- .../Resources/xlf/ConsoleLogs.de.xlf | 9 +- .../Resources/xlf/ConsoleLogs.es.xlf | 9 +- .../Resources/xlf/ConsoleLogs.fr.xlf | 9 +- .../Resources/xlf/ConsoleLogs.it.xlf | 9 +- .../Resources/xlf/ConsoleLogs.ja.xlf | 9 +- .../Resources/xlf/ConsoleLogs.ko.xlf | 9 +- .../Resources/xlf/ConsoleLogs.pl.xlf | 9 +- .../Resources/xlf/ConsoleLogs.pt-BR.xlf | 9 +- .../Resources/xlf/ConsoleLogs.ru.xlf | 9 +- .../Resources/xlf/ConsoleLogs.tr.xlf | 9 +- .../Resources/xlf/ConsoleLogs.zh-Hans.xlf | 9 +- .../Resources/xlf/ConsoleLogs.zh-Hant.xlf | 9 +- .../Terminal/TerminalWebSocketProxy.cs | 2 +- src/Aspire.Dashboard/package-lock.json | 8 +- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 37 +- .../wwwroot/js/hex1b-web-terminal/README.md | 62 ++- .../dist/backend-selection.d.ts | 7 + .../dist/backend-selection.d.ts.map | 1 + .../dist/backend-selection.js | 23 + .../dist/backend-selection.js.map | 1 + .../hex1b-web-terminal/dist/hyperlinks.d.ts | 10 + .../dist/hyperlinks.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/hyperlinks.js | 28 ++ .../hex1b-web-terminal/dist/hyperlinks.js.map | 1 + .../hex1b-web-terminal/dist/mouse-input.d.ts | 3 + .../dist/mouse-input.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/mouse-input.js | 71 ++- .../dist/mouse-input.js.map | 2 +- .../hex1b-web-terminal/dist/protocol.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/protocol.js | 14 + .../hex1b-web-terminal/dist/protocol.js.map | 2 +- .../dist/render-backend.d.ts | 31 ++ .../dist/render-backend.d.ts.map | 1 + .../hex1b-web-terminal/dist/render-backend.js | 5 + .../dist/render-backend.js.map | 1 + .../dist/renderer-options.d.ts | 3 + .../dist/renderer-options.d.ts.map | 1 + .../dist/renderer-options.js | 6 + .../dist/renderer-options.js.map | 1 + .../js/hex1b-web-terminal/dist/renderer.d.ts | 36 +- .../hex1b-web-terminal/dist/renderer.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/renderer.js | 208 ++------ .../hex1b-web-terminal/dist/renderer.js.map | 2 +- .../dist/terminal-worker.js | 13 +- .../dist/terminal-worker.js.map | 2 +- .../js/hex1b-web-terminal/dist/types.d.ts | 9 + .../js/hex1b-web-terminal/dist/types.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/types.js.map | 2 +- .../dist/web-terminal.d.ts.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.js | 29 +- .../dist/web-terminal.js.map | 2 +- .../dist/webgl2-backend.d.ts | 38 ++ .../dist/webgl2-backend.d.ts.map | 1 + .../hex1b-web-terminal/dist/webgl2-backend.js | 456 ++++++++++++++++++ .../dist/webgl2-backend.js.map | 1 + .../dist/webgpu-backend.d.ts | 24 + .../dist/webgpu-backend.d.ts.map | 1 + .../hex1b-web-terminal/dist/webgpu-backend.js | 234 +++++++++ .../dist/webgpu-backend.js.map | 1 + .../hex1b-web-terminal/dist/wire-types.d.ts | 11 +- .../dist/wire-types.d.ts.map | 2 +- .../hex1b-web-terminal/dist/wire-types.js.map | 2 +- .../js/hex1b-web-terminal/package.json | 4 +- .../Controls/TerminalViewTests.cs | 3 +- .../JavaScript/TerminalView.test.mjs | 22 +- .../Terminal/TerminalWebSocketTests.cs | 37 ++ 75 files changed, 1275 insertions(+), 370 deletions(-) create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.js.map diff --git a/Directory.Packages.props b/Directory.Packages.props index ebc02b0f3c9..0e477f691d6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -113,7 +113,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 1092f6aa19b..566b8506974 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -119,17 +119,31 @@ stream. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.167.0-alpha.1509.1.1f47fd9`. HWT1 is experimental state transfer +exactly `0.167.0-alpha.1519.1.b8be265`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. -This release requires a secure context (HTTPS or localhost), WebGPU, -OffscreenCanvas and module workers. Unsupported browsers display an error; -there is no xterm.js fallback. Sixel and Kitty Graphics Protocol are rendered +The dashboard uses the package's automatic renderer selection: WebGPU is +preferred, with WebGL2 used when WebGPU capabilities or device acquisition are +unavailable. WebGPU requires a secure context (HTTPS or localhost); WebGL2 can +render on ordinary HTTP. Clipboard API restrictions still apply, and renderer +selection does not relax transport security, authorization or origin checks. +Both backends require OffscreenCanvas and module workers. Initialization and +runtime rendering failures remain visible errors; there is no xterm.js fallback. +Sixel and Kitty Graphics Protocol are rendered from server-authoritative state. Historical rendering is text-only. The dashboard's independent console-log view remains available. +The package handles Ctrl/Cmd+click on authoritative OSC 8 hyperlinks in live +output and history. HMP state replay preserves link destinations across late +attachment and reconnect. It only opens absolute HTTP, HTTPS and mailto destinations +with `noopener,noreferrer`; plain clicks and drags retain selection/application +behavior. Aspire adds no custom opener or plain-text URL detection. See the +[hyperlink PR](https://github.com/mitchdenny/hex1b/pull/489), +[renderer PR](https://github.com/mitchdenny/hex1b/pull/491), and +[hyperlink replay fix](https://github.com/mitchdenny/hex1b/pull/493). + ### Console / Terminal view toggle For a terminal-enabled resource the dashboard `ConsoleLogs` page mounts diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index ab6102b0b03..ce7d1620f3f 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -6,10 +6,7 @@ {
@GetErrorMessage() - @if (_terminalError != "unsupported") - { - @Loc[nameof(Resources.ConsoleLogs.TerminalRetry)] - } + @Loc[nameof(Resources.ConsoleLogs.TerminalRetry)]
}
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index aa6afbbb0a5..94c1b8fc6bd 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -9,7 +9,7 @@ namespace Aspire.Dashboard.Components.Controls; /// -/// Renders a WebGPU terminal connected to the resource's per-replica session +/// Renders a GPU terminal connected to the resource's per-replica session /// through the dashboard's HWT1 presentation endpoint. /// public sealed partial class TerminalView : ComponentBase, IAsyncDisposable @@ -451,7 +451,6 @@ private string BuildWebSocketUrl(string resource, int replica) private string GetErrorMessage() => Loc[_terminalError switch { - "unsupported" => nameof(Resources.ConsoleLogs.TerminalWebGpuUnsupported), "disconnected" => nameof(Resources.ConsoleLogs.TerminalDisconnected), "input-failed" => nameof(Resources.ConsoleLogs.TerminalInputFailed), "sizing-failed" => nameof(Resources.ConsoleLogs.TerminalSizingFailed), diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 553b77ec99a..5e41c1a564a 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -126,12 +126,6 @@ function connectClient(state) { state.waitingForVisibility = true; return; } - if (!window.isSecureContext || !navigator.gpu) { - state.error = "unsupported"; - notifyToolbar(state); - return; - } - const controller = new AbortController(); state.controller = controller; // Return the terminal id before awaiting mount: Blazor must be able to @@ -147,6 +141,11 @@ async function mountClient(state, generation, controller) { signal: controller.signal, label: state.label, sizing: state.sizing, + // Let the package fall back to WebGL2 for unavailable WebGPU + // capabilities, including ordinary HTTP. Other initialization + // errors and runtime GPU loss must still surface as failures. + // https://github.com/mitchdenny/hex1b/pull/491 + renderer: "auto", onStatus(message, level) { if (!current() || level !== "error") { return; diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index 44ccf9c0063..5765870554e 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -57,12 +57,6 @@ public static string TerminalInputLabel { } } - public static string TerminalWebGpuUnsupported { - get { - return ResourceManager.GetString("TerminalWebGpuUnsupported", resourceCulture); - } - } - public static string TerminalMountFailed { get { return ResourceManager.GetString("TerminalMountFailed", resourceCulture); diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index 39e2c1a8f89..5ad9597f1d3 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -202,11 +202,8 @@ Interactive terminal input - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf index 203e8413b88..aad6b79b9e4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf index 3f181d17334..a85737b6181 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf index d413048ece6..73998583cba 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf index a0237cf07d2..f522be8817b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf index d4ee44d7583..9ece1640265 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf index c84d5b5b3c5..2f259c97111 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf index de9e2717265..723b7305c4f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf index bdd3de17860..803173344fd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf index a7705ebb242..004769ddc24 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf index 5ee24a4242b..4042ff643cb 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf index 6644b71dece..6b0c0dbda9a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf index e8eafd9ded8..9c15deebf53 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf index fc65be50471..6ff28bb7800 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf @@ -153,8 +153,8 @@ - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. - The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU, then reconnect to try again. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. + The terminal could not connect or initialize its renderer. Check that the resource is running and your browser supports WebGPU or WebGL2, then reconnect to try again. You can still view this resource's console logs. @@ -187,11 +187,6 @@ Increase font size - - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - This terminal requires a browser with WebGPU, using HTTPS or localhost. You can still view this resource's console logs. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index 255f1daff8f..b308bfd910e 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -181,7 +181,7 @@ internal static async Task BridgeAsync(WebSocket socket, Stream upstream, ILogge // A direct HMP1 workload preserves the producer's confirmed primary role, // geometry and graphics checkpoints. The mirror belongs to this browser; // disposing it disconnects the peer, not the AppHost-owned terminal. - // https://github.com/mitchdenny/hex1b/blob/1f47fd9a/docs/web-terminal.md + // https://github.com/mitchdenny/hex1b/blob/b8be2654/docs/web-terminal.md var presentation = new Hwt1PresentationAdapter(); await using var presentationLifetime = presentation.ConfigureAwait(false); var terminal = Hex1bTerminal.CreateBuilder() diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 29f5b5aa2d1..49d86368ea0 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1509.1.1f47fd9" + "@hex1b/web-terminal": "0.167.0-alpha.1519.1.b8be265" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.167.0-alpha.1509.1.1f47fd9", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1509.1.1f47fd9.tgz", - "integrity": "sha512-Qe+mYpSRlrOwwJ57lYQk1z1AFkdCYL6L3zkkG5X+fbdo/0hOT74fgdLWLKJE11X/CWuRB8oX/sCJJJP23qNnXw==", + "version": "0.167.0-alpha.1519.1.b8be265", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1519.1.b8be265.tgz", + "integrity": "sha512-XGQQecWHfu5Z/r4lib5wHI9MlzdhUpkDSdPqCbEDEsmCWlhtE5sK34SOozg4+gMIPXqFc0T523Cpwq8yzcQ73w==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index 3f3022be7de..b31429aa1ef 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1509.1.1f47fd9" + "@hex1b/web-terminal": "0.167.0-alpha.1519.1.b8be265" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index a9dc8a36854..9ab56fb2e88 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -14,9 +14,9 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1509.1.1f47fd9**, +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1519.1.b8be265**, paired with the Hex1b NuGet build from commit -`1f47fd9a9f8a4b0c79f3ec6e3f6f9ca8e86fc235`. The client and server use the evolving +`b8be2654e874efa394b496b1c114edcf75c52c14`. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. @@ -46,12 +46,25 @@ has its own SIL Open Font License and provenance under vendored files. `TerminalView.razor.js` imports only the public `dist/index.js` entry point, not the package's internal protocol/renderer modules. -The terminal requires WebGPU in a secure context (HTTPS or localhost), module -workers, transferable OffscreenCanvas, worker animation frames, ResizeObserver, -and CSS Font Loading. There is no Canvas2D renderer fallback. Serve JavaScript +The terminal uses `renderer: "auto"`: WebGPU is preferred, with the package's +WebGL2 compatibility backend used when WebGPU's secure context, API, adapter, +device acquisition, or presentation context is unavailable. Shader, font, +validation and unexpected initialization failures remain errors; runtime GPU +loss ends that view rather than switching renderers. `stats.renderer` and +`stats.rendererFallbackReason` expose the selection for diagnostics. See +[the renderer PR](https://github.com/mitchdenny/hex1b/pull/491). + +WebGPU requires HTTPS or localhost; WebGL2 rendering also works on ordinary +HTTP. Clipboard API permissions still require a secure context, and HTTPS/WSS +is needed to protect terminal traffic. Renderer selection does not relax the +dashboard's transport, authentication or origin protections. + +Both backends require module workers, transferable OffscreenCanvas, worker +animation frames, ResizeObserver, and CSS Font Loading. There is no Canvas2D +or xterm renderer fallback. Serve JavaScript and WOFF2 with their correct MIME types and allow same-origin workers, fonts, and `/api/terminal` WebSockets in the deployment CSP. The dashboard displays a -localized error if capability checks or mounting fail. +localized error if mounting fails. The component import and socket endpoint resolve beneath `NavigationManager.BaseUri`. The package import, worker entry, and bundled font resolve relative to their @@ -76,6 +89,16 @@ infer one from the primary identity. ### Migration boundaries +Ctrl/Cmd+click opens server-authoritative OSC 8 hyperlinks using the package's +built-in routing, including links in history and read-only views. Only absolute +HTTP, HTTPS and mailto destinations are allowed, and tabs use +`noopener,noreferrer`. Plain clicks and drags retain selection/application +behavior; Shift and Alt reserve selection gestures. Aspire does not add its own +opener, custom-scheme support, or plain-text URL detection. HMP state replay +preserves link destinations when a browser attaches or reconnects. See +[the hyperlink PR](https://github.com/mitchdenny/hex1b/pull/489) and +[the replay fix](https://github.com/mitchdenny/hex1b/pull/493). + The public API supports auto/fixed sizing, primary requests, keyboard and mouse input, paste/copy, selection, and producer-backed history. It has no terminal theme setter, search API, clear-buffer API, or title-change callback. Terminal @@ -101,4 +124,4 @@ state, PathBase asset URLs, deployment asset presence, and exact version parity between `Directory.Packages.props`, the npm manifest/lockfile, and the vendored package. Complete installed-package byte comparison belongs to the separate acquisition verification command above. Neither suite substitutes for a browser -WebGPU rendering test or multi-peer server/CLI integration tests. +WebGPU/WebGL2 rendering test or multi-peer server/CLI integration tests. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md index 2614ee367db..0221fe78dc4 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md @@ -1,6 +1,6 @@ # @hex1b/web-terminal -The first-party WebGPU browser terminal for Hex1b. It renders server-authoritative +The first-party GPU-rendered browser terminal for Hex1b. It renders server-authoritative cells and graphics in a module worker, with local input routing, producer-backed history and selection, clipboard actions, and primary/secondary view sizing. There are no runtime package dependencies. @@ -53,10 +53,42 @@ connection, not the container or server-side shared terminal. ### Browser and deployment requirements -Use HTTPS or localhost and a browser with WebGPU, module workers, transferable -OffscreenCanvas, worker animation frames, ResizeObserver, and CSS Font Loading. -Clipboard access also requires browser permission and, for relevant actions, a -user gesture. There is no Canvas2D terminal-rendering fallback. +Use a browser with WebGPU or WebGL2, module workers, transferable OffscreenCanvas, +worker animation frames, ResizeObserver, and CSS Font Loading. WebGPU requires +HTTPS or localhost; WebGL2 rendering also works on ordinary HTTP origins. +Clipboard API access still requires a secure context, browser permission and, +for relevant actions, a user gesture. There is no Canvas2D terminal-rendering fallback. +Renderer selection does not change transport security: use HTTPS/WSS to protect +terminal input and output. + +### Renderer selection + +Set the mount-time `renderer` option to `"auto"` (the default), `"webgpu"`, or +`"webgl2"`. Auto prefers WebGPU and uses WebGL2 if the secure context, API, +adapter, device acquisition, or presentation context is unavailable. Explicit +modes require that backend and report an error rather than falling back. +Shader, font, validation, and unexpected initialization errors are not +compatibility fallbacks. Runtime GPU/context loss terminates the view with an +error; it does not switch backends behind the caller's back. + +```ts +const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", + renderer: "webgl2", // Use WebGL2 even if WebGPU is available. + onStats(stats) { + console.log(stats.renderer, stats.rendererFallbackReason); + } +}); +``` + +`stats.renderer` identifies the active backend after initialization. +`stats.rendererFallbackReason` explains an automatic fallback and is absent +for explicit selections and successful WebGPU initialization. Both backends +share glyph rasterization, frame preparation, clipping, and image ordering. +WebGPU preference is not a performance guarantee; compare representative +workloads on your target browsers and devices. + +### Module and worker deployment The package contains browser ES modules, not a single bundle. For bare static hosting, copy **all of `dist/`**, preserving its directory structure, and import @@ -100,6 +132,7 @@ workers, fonts, and the intended WebSocket endpoint. | --- | --- | | `workerUrl` | Optional module-worker entry; useful when worker assets are deployed separately. | | `scale` | GPU backing scale `0.5`–`3`, or `"auto"` (default, bounded device pixel ratio). | +| `renderer` | `"auto"` (prefer WebGPU), `"webgpu"`, or `"webgl2"`; selected once per mount. | | `font` | One family and optional downloadable font faces; see below. | | `sizing` | `{ mode: "auto", fontSize?: number }` or `{ mode: "fixed", columns, rows, fontSize?: number }`. | | `readOnly` | Disable application input while retaining history inspection and selection. | @@ -172,6 +205,20 @@ actions reject if selection/input/focus changes before their asynchronous work can be applied safely. Errors are surfaced rather than silently reported as successful copies or pastes. +### Hyperlinks + +Hold Ctrl or Cmd and click an OSC 8 hyperlink to open its destination in a new +tab. Hovering shows the destination and activation hint; holding the modifier +also shows a pointer cursor. Links work in live output, scrollback, and read-only +views. Plain clicks and drags retain their existing selection/application +behavior, and explicit input-policy routes or actions take precedence. +Shift and Alt/Option continue to reserve selection gestures. + +Only absolute `http:`, `https:`, and `mailto:` destinations are activated +(`mailto:` handling depends on the browser). New tabs use `noopener,noreferrer`. +Script, data, file, relative, and custom-scheme URLs are not activated. +Plain URL text is not automatically detected; the workload must emit OSC 8. + ## Selection UI hooks `onSelectionUI` receives a typed `SelectionUIEvent`, also dispatched as the @@ -239,5 +286,6 @@ Only `dist/`, this README, the MIT license, and package metadata are shipped. A prepared tarball is self-contained and can be published with `npm publish ./hex1b-web-terminal-.tgz --ignore-scripts`; it does not need development sources or build scripts. The package name is always -`@hex1b/web-terminal`, including GitHub Packages. Registry selection is left to -the caller; no registry is pinned in `package.json`. +`@hex1b/web-terminal`. CI publishes main/release builds to npmjs; PR builds +provide the tarball as the `npm-web-terminal` workflow artifact and do not +publish it to a registry. No registry is pinned in `package.json`. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.d.ts new file mode 100644 index 00000000000..02f0242848f --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.d.ts @@ -0,0 +1,7 @@ +import type { RenderBackend } from "./render-backend.js"; +import type { TerminalRendererPreference } from "./types.js"; +export declare function createRenderBackend(canvas: OffscreenCanvas, onFatal: (error: Error) => void, preference?: TerminalRendererPreference): Promise<{ + backend: RenderBackend; + fallbackReason?: string; +}>; +//# sourceMappingURL=backend-selection.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.d.ts.map new file mode 100644 index 00000000000..4b86fdfaaf3 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"backend-selection.d.ts","sourceRoot":"","sources":["../src/backend-selection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIzD,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAE7D,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,EAChG,UAAU,GAAE,0BAAmC,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,aAAa,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAe/G"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.js new file mode 100644 index 00000000000..13e05af37b9 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.js @@ -0,0 +1,23 @@ +import { RendererUnavailableError } from "./render-backend.js"; +import { normalizeRenderer } from "./renderer-options.js"; +import { WebGpuBackend } from "./webgpu-backend.js"; +import { WebGl2Backend } from "./webgl2-backend.js"; +export async function createRenderBackend(canvas, onFatal, preference = "auto") { + const requested = normalizeRenderer(preference); + if (requested === "webgl2") + return { backend: await WebGl2Backend.create(canvas, onFatal) }; + try { + return { backend: await WebGpuBackend.create(canvas, onFatal) }; + } + catch (error) { + if (requested !== "auto" || !(error instanceof RendererUnavailableError)) + throw error; + try { + return { backend: await WebGl2Backend.create(canvas, onFatal), fallbackReason: error.message }; + } + catch (fallbackError) { + throw new AggregateError([error, fallbackError], `WebGPU unavailable (${error.message}); WebGL2 initialization failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`); + } + } +} +//# sourceMappingURL=backend-selection.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.js.map new file mode 100644 index 00000000000..37e56ea0142 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/backend-selection.js.map @@ -0,0 +1 @@ +{"version":3,"file":"backend-selection.js","sourceRoot":"","sources":["../src/backend-selection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAE/D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGpD,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,MAAuB,EAAE,OAA+B,EAChG,aAAyC,MAAM;IAC/C,MAAM,SAAS,GAAG,iBAAiB,CAAC,UAAU,CAAC,CAAC;IAChD,IAAI,SAAS,KAAK,QAAQ;QAAE,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAC5F,IAAI,CAAC;QACH,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAClE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,SAAS,KAAK,MAAM,IAAI,CAAC,CAAC,KAAK,YAAY,wBAAwB,CAAC;YAAE,MAAM,KAAK,CAAC;QACtF,IAAI,CAAC;YACH,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC;QACjG,CAAC;QAAC,OAAO,aAAa,EAAE,CAAC;YACvB,MAAM,IAAI,cAAc,CAAC,CAAC,KAAK,EAAE,aAAa,CAAC,EAC7C,uBAAuB,KAAK,CAAC,OAAO,oCAClC,aAAa,YAAY,KAAK,CAAC,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;QACxF,CAAC;IACH,CAAC;AACH,CAAC","sourcesContent":["import { RendererUnavailableError } from \"./render-backend.js\";\nimport type { RenderBackend } from \"./render-backend.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { WebGpuBackend } from \"./webgpu-backend.js\";\nimport { WebGl2Backend } from \"./webgl2-backend.js\";\nimport type { TerminalRendererPreference } from \"./types.js\";\n\nexport async function createRenderBackend(canvas: OffscreenCanvas, onFatal: (error: Error) => void,\n preference: TerminalRendererPreference = \"auto\"): Promise<{ backend: RenderBackend; fallbackReason?: string }> {\n const requested = normalizeRenderer(preference);\n if (requested === \"webgl2\") return { backend: await WebGl2Backend.create(canvas, onFatal) };\n try {\n return { backend: await WebGpuBackend.create(canvas, onFatal) };\n } catch (error) {\n if (requested !== \"auto\" || !(error instanceof RendererUnavailableError)) throw error;\n try {\n return { backend: await WebGl2Backend.create(canvas, onFatal), fallbackReason: error.message };\n } catch (fallbackError) {\n throw new AggregateError([error, fallbackError],\n `WebGPU unavailable (${error.message}); WebGL2 initialization failed: ${\n fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);\n }\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.d.ts new file mode 100644 index 00000000000..a14995dac28 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.d.ts @@ -0,0 +1,10 @@ +import type { TerminalPoint } from "./types.js"; +import type { HyperlinkRange } from "./wire-types.js"; +/** OSC 8 destinations are untrusted output, not page-relative navigation. */ +export declare function hyperlinkUri(uri: string): string | null; +export declare class Hyperlinks { + #private; + update(ranges: readonly HyperlinkRange[]): void; + at(point: TerminalPoint): string | null; +} +//# sourceMappingURL=hyperlinks.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.d.ts.map new file mode 100644 index 00000000000..2fe5440ec12 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"hyperlinks.d.ts","sourceRoot":"","sources":["../src/hyperlinks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIvD;AAED,qBAAa,UAAU;;IAGrB,MAAM,CAAC,MAAM,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI;IAa/C,EAAE,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI;CAIxC"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.js new file mode 100644 index 00000000000..df786e6b4c7 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.js @@ -0,0 +1,28 @@ +/** OSC 8 destinations are untrusted output, not page-relative navigation. */ +export function hyperlinkUri(uri) { + if (/[\u0000-\u0020\u007f]/u.test(uri) || !URL.canParse(uri)) + return null; + const url = new URL(uri); + return ["https:", "http:", "mailto:"].includes(url.protocol) ? url.href : null; +} +export class Hyperlinks { + #rows = new Map(); + update(ranges) { + this.#rows.clear(); + const destinations = new Map(); + for (const range of ranges) { + if (!destinations.has(range.uri)) + destinations.set(range.uri, hyperlinkUri(range.uri)); + const uri = destinations.get(range.uri); + if (!uri) + continue; + const row = this.#rows.get(range.row) ?? []; + row.push({ ...range, uri }); + this.#rows.set(range.row, row); + } + } + at(point) { + return this.#rows.get(point.y)?.find(range => point.x >= range.startColumn && point.x < range.endColumn)?.uri ?? null; + } +} +//# sourceMappingURL=hyperlinks.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.js.map new file mode 100644 index 00000000000..8907ef54eb7 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/hyperlinks.js.map @@ -0,0 +1 @@ +{"version":3,"file":"hyperlinks.js","sourceRoot":"","sources":["../src/hyperlinks.ts"],"names":[],"mappings":"AAGA,6EAA6E;AAC7E,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,IAAI,wBAAwB,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1E,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACzB,OAAO,CAAC,QAAQ,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACjF,CAAC;AAED,MAAM,OAAO,UAAU;IACrB,KAAK,GAAG,IAAI,GAAG,EAA4B,CAAC;IAE5C,MAAM,CAAC,MAAiC;QACtC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QACnB,MAAM,YAAY,GAAG,IAAI,GAAG,EAAyB,CAAC;QACtD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC;gBAAE,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;YACvF,MAAM,GAAG,GAAG,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACxC,IAAI,CAAC,GAAG;gBAAE,SAAS;YACnB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YAC5C,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC;YAC5B,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACjC,CAAC;IACH,CAAC;IAED,EAAE,CAAC,KAAoB;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAC3C,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,WAAW,IAAI,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,SAAS,CAAC,EAAE,GAAG,IAAI,IAAI,CAAC;IAC5E,CAAC;CACF","sourcesContent":["import type { TerminalPoint } from \"./types.js\";\nimport type { HyperlinkRange } from \"./wire-types.js\";\n\n/** OSC 8 destinations are untrusted output, not page-relative navigation. */\nexport function hyperlinkUri(uri: string): string | null {\n if (/[\\u0000-\\u0020\\u007f]/u.test(uri) || !URL.canParse(uri)) return null;\n const url = new URL(uri);\n return [\"https:\", \"http:\", \"mailto:\"].includes(url.protocol) ? url.href : null;\n}\n\nexport class Hyperlinks {\n #rows = new Map();\n\n update(ranges: readonly HyperlinkRange[]): void {\n this.#rows.clear();\n const destinations = new Map();\n for (const range of ranges) {\n if (!destinations.has(range.uri)) destinations.set(range.uri, hyperlinkUri(range.uri));\n const uri = destinations.get(range.uri);\n if (!uri) continue;\n const row = this.#rows.get(range.row) ?? [];\n row.push({ ...range, uri });\n this.#rows.set(range.row, row);\n }\n }\n\n at(point: TerminalPoint): string | null {\n return this.#rows.get(point.y)?.find(range =>\n point.x >= range.startColumn && point.x < range.endColumn)?.uri ?? null;\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts index 45950017ba1..f61592f62cc 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts @@ -14,9 +14,12 @@ interface MouseInspection { execute?: (decision: Extract, input: TerminalInput) => void; + hyperlink?: (point: TerminalPoint) => string | null; + openHyperlink?: (uri: string) => void; } export interface MouseCapture { update(columns: number, rows: number, tracking: MouseTrackingMode): void; + refresh(): void; cancel(): void; dispose(): void; } diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map index bcb2f604cab..092001a1221 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"mouse-input.d.ts","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAmB,iBAAiB,EAAiB,aAAa,EAC3F,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,KAAK,EAA6B,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAK/E,UAAU,eAAe;IACvB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAC7C,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC5F,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IACxC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3D,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,aAAa,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CACjG;AACD,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACzE,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,EAC3F,KAAK,EAAE,MAAM,IAAI,EAAE,UAAU,GAAE,eAAoB,GAAG,YAAY,CA+SnE"} \ No newline at end of file +{"version":3,"file":"mouse-input.d.ts","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAmB,iBAAiB,EAAiB,aAAa,EAC3F,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,KAAK,EAA6B,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAM/E,UAAU,eAAe;IACvB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAC7C,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC5F,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IACxC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3D,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,aAAa,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAChG,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,MAAM,GAAG,IAAI,CAAC;IACpD,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AACD,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACzE,OAAO,IAAI,IAAI,CAAC;IAChB,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,EAC3F,KAAK,EAAE,MAAM,IAAI,EAAE,UAAU,GAAE,eAAoB,GAAG,YAAY,CA2WnE"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js index d9d77d68133..3be3d15c607 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js @@ -24,10 +24,27 @@ export function captureMouse(canvas, send, focus, inspection = {}) { let completedClick; let routedGesture; let contextMenuRoute; + let hyperlinkClick; + let hoverEvent; + let hoverModifiers; + const originalTitle = canvas.title; + const originalCursor = canvas.style.cursor; const state = () => ({ tracking, ...inspection.state?.() }); function point(event, clamp = false) { return cellPoint(event, canvas.getBoundingClientRect(), columns, rows, clamp); } + function refreshHover(modifiers = hoverModifiers) { + if (!inspection.hyperlink) + return; + hoverModifiers = modifiers; + const position = hoverEvent && point(hoverEvent); + const uri = position ? inspection.hyperlink(position) : null; + canvas.title = uri ? `${uri}\nCtrl/Cmd+click to open link` : originalTitle; + canvas.style.cursor = uri && modifiers && (modifiers.ctrlKey || modifiers.metaKey) && + !modifiers.altKey && !modifiers.shiftKey ? "pointer" : originalCursor; + if (hyperlinkClick && hyperlinkClick.uri !== uri) + hyperlinkClick.dragged = true; + } function updateAutoScroll() { clearTimeout(autoScroll); autoScroll = undefined; @@ -83,6 +100,7 @@ export function captureMouse(canvas, send, focus, inspection = {}) { pointerEvent = undefined; selectionClick = undefined; completedClick = undefined; + hyperlinkClick = undefined; gesture.end(); routedGesture = undefined; if (report) @@ -115,6 +133,8 @@ export function captureMouse(canvas, send, focus, inspection = {}) { if (!position) return; if (pointerId !== null) { + if (hyperlinkClick) + hyperlinkClick.dragged = true; if (routedGesture !== InputRoute.Browser) event.preventDefault(); if ((gesture.owner === "app" || routedGesture === InputRoute.Application) && pointerId === event.pointerId) @@ -125,8 +145,20 @@ export function captureMouse(canvas, send, focus, inspection = {}) { const input = { type: "pointer", button: pointerButtons[event.button], point: Object.freeze({ ...position }), ...inputModifiers(event) }; const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue }; - const route = decision.action !== undefined ? InputRoute.Consume : decision.route; + let route = decision.action !== undefined ? InputRoute.Consume : decision.route; + if (route === InputRoute.Continue && event.button === 0 && (event.ctrlKey || event.metaKey) && + !event.altKey && !event.shiftKey) { + const uri = inspection.hyperlink?.(position); + if (uri) { + hyperlinkClick = { uri, x: event.clientX, y: event.clientY, dragged: false }; + route = InputRoute.Consume; + } + } contextMenuRoute = event.button === 2 ? route : undefined; + if (hyperlinkClick) + contextMenuRoute = InputRoute.Consume; + hoverEvent = event; + refreshHover(event); if (route !== InputRoute.Continue) { completedClick = selectionClick = undefined; click.count = 0; @@ -186,6 +218,10 @@ export function captureMouse(canvas, send, focus, inspection = {}) { return; if (pointerId !== null && pointerId !== event.pointerId) return; + hoverEvent = event; + refreshHover(event); + if (hyperlinkClick && Math.hypot(event.clientX - hyperlinkClick.x, event.clientY - hyperlinkClick.y) >= 4) + hyperlinkClick.dragged = true; const position = point(event, pointerId !== null); if (!position) return; @@ -224,6 +260,21 @@ export function captureMouse(canvas, send, focus, inspection = {}) { canvas.addEventListener("pointerup", event => { if (pointerId !== event.pointerId) return; + if (hyperlinkClick) { + event.preventDefault(); + const link = hyperlinkClick; + const position = point(event); + const activate = event.button === 0 && event.buttons === 0 && !link.dragged && + Math.hypot(event.clientX - link.x, event.clientY - link.y) < 4 && + position && inspection.hyperlink?.(position) === link.uri; + if (event.buttons === 0) + cancel(false, false); + else + link.dragged = true; + if (activate) + inspection.openHyperlink?.(link.uri); + return; + } const position = point(event, true) ?? lastPoint; if (routedGesture) { if (routedGesture === InputRoute.Application && position) @@ -262,11 +313,21 @@ export function captureMouse(canvas, send, focus, inspection = {}) { inspection.begin?.(completed.point, { mode, extend: false }); }, options); canvas.addEventListener("pointercancel", () => cancel(), options); + canvas.addEventListener("pointerleave", () => { + hoverEvent = undefined; + refreshHover(); + }, options); canvas.addEventListener("lostpointercapture", () => { if (pointerId !== null) cancel(); }, options); - window.addEventListener("blur", () => cancel(), options); + window.addEventListener("blur", () => { + cancel(); + hoverEvent = undefined; + refreshHover(); + }, options); + window.addEventListener("keydown", event => refreshHover(event), { ...options, capture: true }); + window.addEventListener("keyup", event => refreshHover(event), { ...options, capture: true }); canvas.addEventListener("contextmenu", event => { if (contextMenuRoute !== undefined && contextMenuRoute !== InputRoute.Continue) { const route = contextMenuRoute; @@ -279,6 +340,8 @@ export function captureMouse(canvas, send, focus, inspection = {}) { event.preventDefault(); }, options); canvas.addEventListener("wheel", event => { + if (hyperlinkClick) + hyperlinkClick.dragged = true; const input = { type: "wheel", deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, point: point(event), ...inputModifiers(event) }; const decision = pointerId === null @@ -345,11 +408,15 @@ export function captureMouse(canvas, send, focus, inspection = {}) { rows = nextRows; tracking = nextTracking; } + refreshHover(); }, + refresh() { refreshHover(); }, cancel() { cancel(); }, dispose() { cancel(false); listeners.abort(); + canvas.title = originalTitle; + canvas.style.cursor = originalCursor; } }; } diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map index 0edeef538ab..325951e19f9 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map @@ -1 +1 @@ -{"version":3,"file":"mouse-input.js","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"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","sourcesContent":["import { cellPoint, WheelAccumulator, SelectionGesture } from \"./selection-input.js\";\nimport { InputRoute, inputModifiers } from \"./input-policy.js\";\nimport type { GestureState } from \"./selection-input.js\";\nimport type { InputDecision, InputRouteValue, MouseTrackingMode, PointerButton, SelectionMode,\n TerminalInput, TerminalPoint } from \"./types.js\";\nimport type { CellPosition, MouseButton, MouseCommand } from \"./wire-types.js\";\n\nconst buttons: readonly (readonly [PointerButton, number])[] = [[\"left\", 1], [\"middle\", 4], [\"right\", 2]];\nconst pointerButtons: readonly PointerButton[] = [\"left\", \"middle\", \"right\"];\ninterface SelectionClick { point: TerminalPoint; mode: SelectionMode; extend: boolean; dragged: boolean }\ninterface MouseInspection {\n state?: () => Omit;\n begin?: (point: TerminalPoint, selection: { mode: SelectionMode; extend: boolean }) => void;\n extend?: (point: TerminalPoint) => void;\n scroll?: (delta: number, endpoint?: TerminalPoint) => void;\n end?: (cancelled: boolean) => void;\n resolve?: (input: TerminalInput) => InputDecision;\n execute?: (decision: Extract, input: TerminalInput) => void;\n}\nexport interface MouseCapture {\n update(columns: number, rows: number, tracking: MouseTrackingMode): void;\n cancel(): void;\n dispose(): void;\n}\n\n/** Capture input intent only; the server chooses and encodes the mouse protocol. */\nexport function captureMouse(canvas: HTMLCanvasElement, send: (command: MouseCommand) => void,\n focus: () => void, inspection: MouseInspection = {}): MouseCapture {\n const listeners = new AbortController();\n const options = { signal: listeners.signal };\n let columns = 1, rows = 1;\n let tracking: MouseTrackingMode = 0;\n let pointerId: number | null = null;\n const pressed = new Set();\n let lastPoint: CellPosition | undefined;\n let pendingMove: MouseCommand | undefined;\n let lastMove: string | undefined;\n let scheduled: number | undefined;\n const wheel = new WheelAccumulator();\n const gesture = new SelectionGesture();\n let wheelOwner: \"local\" | \"app\" | undefined;\n let pointerEvent: PointerEvent | undefined;\n let autoScroll: ReturnType | undefined;\n let click = { count: 0, time: 0, x: -1, y: -1 };\n let selectionClick: SelectionClick | undefined;\n let completedClick: SelectionClick | undefined;\n let routedGesture: InputRouteValue | undefined;\n let contextMenuRoute: InputRouteValue | undefined;\n const state = () => ({ tracking, ...inspection.state?.() });\n\n function point(event: MouseEvent, clamp = false): CellPosition | null {\n return cellPoint(event, canvas.getBoundingClientRect(), columns, rows, clamp);\n }\n\n function updateAutoScroll() {\n clearTimeout(autoScroll);\n autoScroll = undefined;\n if (gesture.owner !== \"local\" || !pointerEvent) return;\n const bounds = canvas.getBoundingClientRect();\n const distance = pointerEvent.clientY < bounds.top ? pointerEvent.clientY - bounds.top\n : pointerEvent.clientY >= bounds.bottom ? pointerEvent.clientY - bounds.bottom + 1 : 0;\n if (!distance) return;\n autoScroll = setTimeout(() => {\n autoScroll = undefined;\n if (!pointerEvent) return;\n const position = point(pointerEvent, true);\n if (gesture.owner === \"local\" && position) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n const lines = Math.sign(distance) * Math.min(8, Math.max(1, Math.ceil(Math.abs(distance) / (bounds.height / rows))));\n inspection.scroll?.(lines, gesture.scrollPoint(position));\n updateAutoScroll();\n }\n }, 60);\n }\n\n function flushMove() {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n if (pendingMove) send(pendingMove);\n pendingMove = undefined;\n }\n\n function changeButtons(event: PointerEvent, position: CellPosition): void {\n for (const [button, mask] of buttons) {\n const down = (event.buttons & mask) !== 0;\n if (down === pressed.has(button)) continue;\n flushMove();\n if (down) pressed.add(button);\n else pressed.delete(button);\n if (down || tracking !== 9)\n send({ type: \"mouse\", action: down ? \"down\" : \"up\", button, ...position });\n lastMove = undefined;\n }\n }\n\n function cancel(report = true, cancelled = true) {\n inspection.end?.(cancelled);\n clearTimeout(autoScroll);\n autoScroll = undefined;\n pointerEvent = undefined;\n selectionClick = undefined;\n completedClick = undefined;\n gesture.end();\n routedGesture = undefined;\n if (report) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (report && tracking !== 9 && lastPoint) {\n for (const button of pressed)\n send({ type: \"mouse\", action: \"up\", button, ...lastPoint });\n }\n pressed.clear();\n const captured = pointerId;\n pointerId = null;\n if (captured !== null && canvas.hasPointerCapture(captured))\n canvas.releasePointerCapture(captured);\n lastMove = undefined;\n wheel.reset();\n wheelOwner = undefined;\n }\n\n canvas.addEventListener(\"pointerdown\", event => {\n if (event.defaultPrevented && pointerId === null) return;\n if (event.pointerType !== \"mouse\" || ![0, 1, 2].includes(event.button)) return;\n const position = point(event);\n if (!position) return;\n if (pointerId !== null) {\n if (routedGesture !== InputRoute.Browser) event.preventDefault();\n if ((gesture.owner === \"app\" || routedGesture === InputRoute.Application) && pointerId === event.pointerId)\n changeButtons(event, position);\n return;\n }\n focus();\n const input: TerminalInput = { type: \"pointer\", button: pointerButtons[event.button],\n point: Object.freeze({ ...position }), ...inputModifiers(event) };\n const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue };\n const route = decision.action !== undefined ? InputRoute.Consume : decision.route;\n contextMenuRoute = event.button === 2 ? route : undefined;\n if (route !== InputRoute.Continue) {\n completedClick = selectionClick = undefined;\n click.count = 0;\n routedGesture = route;\n pointerId = event.pointerId;\n lastPoint = position;\n canvas.setPointerCapture(pointerId);\n if (route !== InputRoute.Browser) event.preventDefault();\n if (route === InputRoute.Application) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (route === InputRoute.Application) changeButtons(event, position);\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Keep focus on the hidden keyboard input instead of the canvas.\n event.preventDefault();\n if (event.metaKey) return;\n const now = performance.now();\n click.count = now - click.time < 500 && position.x === click.x && position.y === click.y\n ? click.count % 3 + 1 : 1;\n click = { count: click.count, time: now, x: position.x, y: position.y };\n const start = gesture.begin({ button: event.button, shiftKey: event.shiftKey, altKey: event.altKey,\n detail: event.detail || click.count }, position, state());\n if (!start) return;\n pointerId = event.pointerId;\n pointerEvent = event;\n lastPoint = position;\n completedClick = undefined;\n canvas.setPointerCapture(pointerId);\n if (start.owner === \"local\") {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n lastMove = undefined;\n selectionClick = { point: position, mode: start.mode, extend: start.extend, dragged: false };\n inspection.begin?.(position, start);\n }\n else changeButtons(event, position);\n }, options);\n\n canvas.addEventListener(\"pointermove\", event => {\n if (event.pointerType !== \"mouse\") return;\n if (pointerId === null && event.buttons) return;\n if (pointerId !== null && pointerId !== event.pointerId) return;\n const position = point(event, pointerId !== null);\n if (!position) return;\n lastPoint = position;\n if (routedGesture && routedGesture !== InputRoute.Application) return;\n if (gesture.owner === \"local\") {\n pointerEvent = event;\n const previous = gesture.endpoint;\n const endpoint = gesture.move(position);\n if (endpoint && previous && (endpoint.x !== previous.x || endpoint.y !== previous.y)) {\n click.count = 0;\n if (selectionClick) selectionClick.dragged = true;\n inspection.extend?.(endpoint);\n }\n updateAutoScroll();\n return;\n }\n if (!tracking || (!routedGesture && gesture.owner === null && (state().historical || state().readOnly || event.shiftKey))) return;\n if (pointerId === event.pointerId) changeButtons(event, position);\n if (event.metaKey || (tracking !== 1003 && !(tracking === 1002 && pressed.size))) return;\n const button = buttons.find(([name]) => pressed.has(name))?.[0] ?? \"none\";\n const move: MouseCommand = { type: \"mouse\", action: \"move\", button, ...position };\n const key = JSON.stringify(move);\n if (key === lastMove) return;\n lastMove = key;\n pendingMove = move;\n if (scheduled === undefined) scheduled = requestAnimationFrame(flushMove);\n }, options);\n\n canvas.addEventListener(\"pointerup\", event => {\n if (pointerId !== event.pointerId) return;\n const position = point(event, true) ?? lastPoint;\n if (routedGesture) {\n if (routedGesture === InputRoute.Application && position) changeButtons(event, position);\n if (event.buttons === 0) cancel(routedGesture === InputRoute.Application);\n return;\n }\n if (gesture.owner === \"local\") {\n if (position && gesture.endpoint && (position.x !== gesture.endpoint.x || position.y !== gesture.endpoint.y)) {\n if (selectionClick) selectionClick.dragged = true;\n const endpoint = gesture.move(position);\n if (endpoint) inspection.extend?.(endpoint);\n }\n const completed = selectionClick;\n cancel(false, false);\n completedClick = completed;\n return;\n }\n if (position) changeButtons(event, position);\n if (!pressed.size) cancel();\n }, options);\n canvas.addEventListener(\"click\", event => {\n const completed = completedClick;\n completedClick = undefined;\n if (!completed || completed.dragged || !Number.isInteger(event.detail) || event.detail < 1) return;\n click.count = event.detail;\n // Some browsers expose native multiclick counts only on click, not pointerdown.\n const mode = event.detail >= 3 ? \"line\" : event.detail === 2 ? \"word\" : \"character\";\n if (!completed.extend && completed.mode !== \"rectangle\" && mode !== completed.mode)\n inspection.begin?.(completed.point, { mode, extend: false });\n }, options);\n canvas.addEventListener(\"pointercancel\", () => cancel(), options);\n canvas.addEventListener(\"lostpointercapture\", () => {\n if (pointerId !== null) cancel();\n }, options);\n window.addEventListener(\"blur\", () => cancel(), options);\n canvas.addEventListener(\"contextmenu\", event => {\n if (contextMenuRoute !== undefined && contextMenuRoute !== InputRoute.Continue) {\n const route = contextMenuRoute;\n contextMenuRoute = undefined;\n if (route !== InputRoute.Browser) event.preventDefault();\n return;\n }\n if (tracking || gesture.owner === \"local\") event.preventDefault();\n }, options);\n canvas.addEventListener(\"wheel\", event => {\n const input: TerminalInput = { type: \"wheel\", deltaX: event.deltaX, deltaY: event.deltaY,\n deltaMode: event.deltaMode, point: point(event), ...inputModifiers(event) };\n const decision: InputDecision = pointerId === null\n ? inspection.resolve?.(input) ?? { route: InputRoute.Continue }\n : { route: routedGesture ?? InputRoute.Continue };\n if (decision.route === InputRoute.Browser) return;\n if (decision.action !== undefined || decision.route === InputRoute.Consume) {\n event.preventDefault();\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Ctrl+wheel (including trackpad pinch) remains browser zoom.\n if (decision.route !== InputRoute.Application && (event.ctrlKey || event.metaKey)) return;\n const owner = decision.route === InputRoute.Application ? \"app\" : gesture.wheelOwner(event, state());\n if (owner === \"app\" && tracking === 9) return;\n const position = point(gesture.owner === \"local\" && pointerEvent ? pointerEvent : event, gesture.owner === \"local\");\n if (!position) return;\n event.preventDefault();\n flushMove();\n const bounds = canvas.getBoundingClientRect();\n if (wheelOwner !== owner) wheel.reset();\n wheelOwner = owner;\n const { x: horizontal, y: vertical } = wheel.take(event, bounds, rows);\n if (owner === \"local\") {\n if (vertical) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n inspection.scroll?.(vertical, gesture.scrollPoint(position));\n }\n return;\n }\n const wheelDirections: [number, MouseButton, MouseButton][] = [\n [vertical, \"wheelUp\", \"wheelDown\"],\n [horizontal, \"wheelLeft\", \"wheelRight\"]\n ];\n for (const [steps, negative, positive] of wheelDirections) {\n if (steps) send({\n type: \"mouse\", action: \"wheel\", button: steps < 0 ? negative : positive,\n count: Math.min(32, Math.abs(steps)), ...position\n });\n }\n }, { ...options, passive: false });\n\n return {\n update(nextColumns, nextRows, nextTracking) {\n if (columns !== nextColumns || rows !== nextRows || tracking !== nextTracking) {\n if (lastPoint) {\n lastPoint = { ...lastPoint,\n x: Math.min(lastPoint.x, nextColumns - 1),\n y: Math.min(lastPoint.y, nextRows - 1)\n };\n }\n // Application mode changes do not transfer ownership mid-gesture.\n if (columns !== nextColumns || rows !== nextRows) cancel(nextTracking !== 0);\n columns = nextColumns;\n rows = nextRows;\n tracking = nextTracking;\n }\n },\n cancel() { cancel(); },\n dispose() {\n cancel(false);\n listeners.abort();\n }\n };\n}\n"]} \ No newline at end of file +{"version":3,"file":"mouse-input.js","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"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","sourcesContent":["import { cellPoint, WheelAccumulator, SelectionGesture } from \"./selection-input.js\";\nimport { InputRoute, inputModifiers } from \"./input-policy.js\";\nimport type { GestureState } from \"./selection-input.js\";\nimport type { InputDecision, InputRouteValue, MouseTrackingMode, PointerButton, SelectionMode,\n TerminalInput, TerminalPoint } from \"./types.js\";\nimport type { CellPosition, MouseButton, MouseCommand } from \"./wire-types.js\";\n\nconst buttons: readonly (readonly [PointerButton, number])[] = [[\"left\", 1], [\"middle\", 4], [\"right\", 2]];\nconst pointerButtons: readonly PointerButton[] = [\"left\", \"middle\", \"right\"];\ninterface SelectionClick { point: TerminalPoint; mode: SelectionMode; extend: boolean; dragged: boolean }\ninterface HyperlinkClick { uri: string; x: number; y: number; dragged: boolean }\ninterface MouseInspection {\n state?: () => Omit;\n begin?: (point: TerminalPoint, selection: { mode: SelectionMode; extend: boolean }) => void;\n extend?: (point: TerminalPoint) => void;\n scroll?: (delta: number, endpoint?: TerminalPoint) => void;\n end?: (cancelled: boolean) => void;\n resolve?: (input: TerminalInput) => InputDecision;\n execute?: (decision: Extract, input: TerminalInput) => void;\n hyperlink?: (point: TerminalPoint) => string | null;\n openHyperlink?: (uri: string) => void;\n}\nexport interface MouseCapture {\n update(columns: number, rows: number, tracking: MouseTrackingMode): void;\n refresh(): void;\n cancel(): void;\n dispose(): void;\n}\n\n/** Capture input intent only; the server chooses and encodes the mouse protocol. */\nexport function captureMouse(canvas: HTMLCanvasElement, send: (command: MouseCommand) => void,\n focus: () => void, inspection: MouseInspection = {}): MouseCapture {\n const listeners = new AbortController();\n const options = { signal: listeners.signal };\n let columns = 1, rows = 1;\n let tracking: MouseTrackingMode = 0;\n let pointerId: number | null = null;\n const pressed = new Set();\n let lastPoint: CellPosition | undefined;\n let pendingMove: MouseCommand | undefined;\n let lastMove: string | undefined;\n let scheduled: number | undefined;\n const wheel = new WheelAccumulator();\n const gesture = new SelectionGesture();\n let wheelOwner: \"local\" | \"app\" | undefined;\n let pointerEvent: PointerEvent | undefined;\n let autoScroll: ReturnType | undefined;\n let click = { count: 0, time: 0, x: -1, y: -1 };\n let selectionClick: SelectionClick | undefined;\n let completedClick: SelectionClick | undefined;\n let routedGesture: InputRouteValue | undefined;\n let contextMenuRoute: InputRouteValue | undefined;\n let hyperlinkClick: HyperlinkClick | undefined;\n let hoverEvent: PointerEvent | undefined;\n let hoverModifiers: Pick | undefined;\n const originalTitle = canvas.title;\n const originalCursor = canvas.style.cursor;\n const state = () => ({ tracking, ...inspection.state?.() });\n\n function point(event: MouseEvent, clamp = false): CellPosition | null {\n return cellPoint(event, canvas.getBoundingClientRect(), columns, rows, clamp);\n }\n\n function refreshHover(modifiers = hoverModifiers) {\n if (!inspection.hyperlink) return;\n hoverModifiers = modifiers;\n const position = hoverEvent && point(hoverEvent);\n const uri = position ? inspection.hyperlink(position) : null;\n canvas.title = uri ? `${uri}\\nCtrl/Cmd+click to open link` : originalTitle;\n canvas.style.cursor = uri && modifiers && (modifiers.ctrlKey || modifiers.metaKey) &&\n !modifiers.altKey && !modifiers.shiftKey ? \"pointer\" : originalCursor;\n if (hyperlinkClick && hyperlinkClick.uri !== uri) hyperlinkClick.dragged = true;\n }\n\n function updateAutoScroll() {\n clearTimeout(autoScroll);\n autoScroll = undefined;\n if (gesture.owner !== \"local\" || !pointerEvent) return;\n const bounds = canvas.getBoundingClientRect();\n const distance = pointerEvent.clientY < bounds.top ? pointerEvent.clientY - bounds.top\n : pointerEvent.clientY >= bounds.bottom ? pointerEvent.clientY - bounds.bottom + 1 : 0;\n if (!distance) return;\n autoScroll = setTimeout(() => {\n autoScroll = undefined;\n if (!pointerEvent) return;\n const position = point(pointerEvent, true);\n if (gesture.owner === \"local\" && position) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n const lines = Math.sign(distance) * Math.min(8, Math.max(1, Math.ceil(Math.abs(distance) / (bounds.height / rows))));\n inspection.scroll?.(lines, gesture.scrollPoint(position));\n updateAutoScroll();\n }\n }, 60);\n }\n\n function flushMove() {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n if (pendingMove) send(pendingMove);\n pendingMove = undefined;\n }\n\n function changeButtons(event: PointerEvent, position: CellPosition): void {\n for (const [button, mask] of buttons) {\n const down = (event.buttons & mask) !== 0;\n if (down === pressed.has(button)) continue;\n flushMove();\n if (down) pressed.add(button);\n else pressed.delete(button);\n if (down || tracking !== 9)\n send({ type: \"mouse\", action: down ? \"down\" : \"up\", button, ...position });\n lastMove = undefined;\n }\n }\n\n function cancel(report = true, cancelled = true) {\n inspection.end?.(cancelled);\n clearTimeout(autoScroll);\n autoScroll = undefined;\n pointerEvent = undefined;\n selectionClick = undefined;\n completedClick = undefined;\n hyperlinkClick = undefined;\n gesture.end();\n routedGesture = undefined;\n if (report) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (report && tracking !== 9 && lastPoint) {\n for (const button of pressed)\n send({ type: \"mouse\", action: \"up\", button, ...lastPoint });\n }\n pressed.clear();\n const captured = pointerId;\n pointerId = null;\n if (captured !== null && canvas.hasPointerCapture(captured))\n canvas.releasePointerCapture(captured);\n lastMove = undefined;\n wheel.reset();\n wheelOwner = undefined;\n }\n\n canvas.addEventListener(\"pointerdown\", event => {\n if (event.defaultPrevented && pointerId === null) return;\n if (event.pointerType !== \"mouse\" || ![0, 1, 2].includes(event.button)) return;\n const position = point(event);\n if (!position) return;\n if (pointerId !== null) {\n if (hyperlinkClick) hyperlinkClick.dragged = true;\n if (routedGesture !== InputRoute.Browser) event.preventDefault();\n if ((gesture.owner === \"app\" || routedGesture === InputRoute.Application) && pointerId === event.pointerId)\n changeButtons(event, position);\n return;\n }\n focus();\n const input: TerminalInput = { type: \"pointer\", button: pointerButtons[event.button],\n point: Object.freeze({ ...position }), ...inputModifiers(event) };\n const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue };\n let route = decision.action !== undefined ? InputRoute.Consume : decision.route;\n if (route === InputRoute.Continue && event.button === 0 && (event.ctrlKey || event.metaKey) &&\n !event.altKey && !event.shiftKey) {\n const uri = inspection.hyperlink?.(position);\n if (uri) {\n hyperlinkClick = { uri, x: event.clientX, y: event.clientY, dragged: false };\n route = InputRoute.Consume;\n }\n }\n contextMenuRoute = event.button === 2 ? route : undefined;\n if (hyperlinkClick) contextMenuRoute = InputRoute.Consume;\n hoverEvent = event;\n refreshHover(event);\n if (route !== InputRoute.Continue) {\n completedClick = selectionClick = undefined;\n click.count = 0;\n routedGesture = route;\n pointerId = event.pointerId;\n lastPoint = position;\n canvas.setPointerCapture(pointerId);\n if (route !== InputRoute.Browser) event.preventDefault();\n if (route === InputRoute.Application) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (route === InputRoute.Application) changeButtons(event, position);\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Keep focus on the hidden keyboard input instead of the canvas.\n event.preventDefault();\n if (event.metaKey) return;\n const now = performance.now();\n click.count = now - click.time < 500 && position.x === click.x && position.y === click.y\n ? click.count % 3 + 1 : 1;\n click = { count: click.count, time: now, x: position.x, y: position.y };\n const start = gesture.begin({ button: event.button, shiftKey: event.shiftKey, altKey: event.altKey,\n detail: event.detail || click.count }, position, state());\n if (!start) return;\n pointerId = event.pointerId;\n pointerEvent = event;\n lastPoint = position;\n completedClick = undefined;\n canvas.setPointerCapture(pointerId);\n if (start.owner === \"local\") {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n lastMove = undefined;\n selectionClick = { point: position, mode: start.mode, extend: start.extend, dragged: false };\n inspection.begin?.(position, start);\n }\n else changeButtons(event, position);\n }, options);\n\n canvas.addEventListener(\"pointermove\", event => {\n if (event.pointerType !== \"mouse\") return;\n if (pointerId === null && event.buttons) return;\n if (pointerId !== null && pointerId !== event.pointerId) return;\n hoverEvent = event;\n refreshHover(event);\n if (hyperlinkClick && Math.hypot(event.clientX - hyperlinkClick.x, event.clientY - hyperlinkClick.y) >= 4)\n hyperlinkClick.dragged = true;\n const position = point(event, pointerId !== null);\n if (!position) return;\n lastPoint = position;\n if (routedGesture && routedGesture !== InputRoute.Application) return;\n if (gesture.owner === \"local\") {\n pointerEvent = event;\n const previous = gesture.endpoint;\n const endpoint = gesture.move(position);\n if (endpoint && previous && (endpoint.x !== previous.x || endpoint.y !== previous.y)) {\n click.count = 0;\n if (selectionClick) selectionClick.dragged = true;\n inspection.extend?.(endpoint);\n }\n updateAutoScroll();\n return;\n }\n if (!tracking || (!routedGesture && gesture.owner === null && (state().historical || state().readOnly || event.shiftKey))) return;\n if (pointerId === event.pointerId) changeButtons(event, position);\n if (event.metaKey || (tracking !== 1003 && !(tracking === 1002 && pressed.size))) return;\n const button = buttons.find(([name]) => pressed.has(name))?.[0] ?? \"none\";\n const move: MouseCommand = { type: \"mouse\", action: \"move\", button, ...position };\n const key = JSON.stringify(move);\n if (key === lastMove) return;\n lastMove = key;\n pendingMove = move;\n if (scheduled === undefined) scheduled = requestAnimationFrame(flushMove);\n }, options);\n\n canvas.addEventListener(\"pointerup\", event => {\n if (pointerId !== event.pointerId) return;\n if (hyperlinkClick) {\n event.preventDefault();\n const link = hyperlinkClick;\n const position = point(event);\n const activate = event.button === 0 && event.buttons === 0 && !link.dragged &&\n Math.hypot(event.clientX - link.x, event.clientY - link.y) < 4 &&\n position && inspection.hyperlink?.(position) === link.uri;\n if (event.buttons === 0) cancel(false, false);\n else link.dragged = true;\n if (activate) inspection.openHyperlink?.(link.uri);\n return;\n }\n const position = point(event, true) ?? lastPoint;\n if (routedGesture) {\n if (routedGesture === InputRoute.Application && position) changeButtons(event, position);\n if (event.buttons === 0) cancel(routedGesture === InputRoute.Application);\n return;\n }\n if (gesture.owner === \"local\") {\n if (position && gesture.endpoint && (position.x !== gesture.endpoint.x || position.y !== gesture.endpoint.y)) {\n if (selectionClick) selectionClick.dragged = true;\n const endpoint = gesture.move(position);\n if (endpoint) inspection.extend?.(endpoint);\n }\n const completed = selectionClick;\n cancel(false, false);\n completedClick = completed;\n return;\n }\n if (position) changeButtons(event, position);\n if (!pressed.size) cancel();\n }, options);\n canvas.addEventListener(\"click\", event => {\n const completed = completedClick;\n completedClick = undefined;\n if (!completed || completed.dragged || !Number.isInteger(event.detail) || event.detail < 1) return;\n click.count = event.detail;\n // Some browsers expose native multiclick counts only on click, not pointerdown.\n const mode = event.detail >= 3 ? \"line\" : event.detail === 2 ? \"word\" : \"character\";\n if (!completed.extend && completed.mode !== \"rectangle\" && mode !== completed.mode)\n inspection.begin?.(completed.point, { mode, extend: false });\n }, options);\n canvas.addEventListener(\"pointercancel\", () => cancel(), options);\n canvas.addEventListener(\"pointerleave\", () => {\n hoverEvent = undefined;\n refreshHover();\n }, options);\n canvas.addEventListener(\"lostpointercapture\", () => {\n if (pointerId !== null) cancel();\n }, options);\n window.addEventListener(\"blur\", () => {\n cancel();\n hoverEvent = undefined;\n refreshHover();\n }, options);\n window.addEventListener(\"keydown\", event => refreshHover(event), { ...options, capture: true });\n window.addEventListener(\"keyup\", event => refreshHover(event), { ...options, capture: true });\n canvas.addEventListener(\"contextmenu\", event => {\n if (contextMenuRoute !== undefined && contextMenuRoute !== InputRoute.Continue) {\n const route = contextMenuRoute;\n contextMenuRoute = undefined;\n if (route !== InputRoute.Browser) event.preventDefault();\n return;\n }\n if (tracking || gesture.owner === \"local\") event.preventDefault();\n }, options);\n canvas.addEventListener(\"wheel\", event => {\n if (hyperlinkClick) hyperlinkClick.dragged = true;\n const input: TerminalInput = { type: \"wheel\", deltaX: event.deltaX, deltaY: event.deltaY,\n deltaMode: event.deltaMode, point: point(event), ...inputModifiers(event) };\n const decision: InputDecision = pointerId === null\n ? inspection.resolve?.(input) ?? { route: InputRoute.Continue }\n : { route: routedGesture ?? InputRoute.Continue };\n if (decision.route === InputRoute.Browser) return;\n if (decision.action !== undefined || decision.route === InputRoute.Consume) {\n event.preventDefault();\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Ctrl+wheel (including trackpad pinch) remains browser zoom.\n if (decision.route !== InputRoute.Application && (event.ctrlKey || event.metaKey)) return;\n const owner = decision.route === InputRoute.Application ? \"app\" : gesture.wheelOwner(event, state());\n if (owner === \"app\" && tracking === 9) return;\n const position = point(gesture.owner === \"local\" && pointerEvent ? pointerEvent : event, gesture.owner === \"local\");\n if (!position) return;\n event.preventDefault();\n flushMove();\n const bounds = canvas.getBoundingClientRect();\n if (wheelOwner !== owner) wheel.reset();\n wheelOwner = owner;\n const { x: horizontal, y: vertical } = wheel.take(event, bounds, rows);\n if (owner === \"local\") {\n if (vertical) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n inspection.scroll?.(vertical, gesture.scrollPoint(position));\n }\n return;\n }\n const wheelDirections: [number, MouseButton, MouseButton][] = [\n [vertical, \"wheelUp\", \"wheelDown\"],\n [horizontal, \"wheelLeft\", \"wheelRight\"]\n ];\n for (const [steps, negative, positive] of wheelDirections) {\n if (steps) send({\n type: \"mouse\", action: \"wheel\", button: steps < 0 ? negative : positive,\n count: Math.min(32, Math.abs(steps)), ...position\n });\n }\n }, { ...options, passive: false });\n\n return {\n update(nextColumns, nextRows, nextTracking) {\n if (columns !== nextColumns || rows !== nextRows || tracking !== nextTracking) {\n if (lastPoint) {\n lastPoint = { ...lastPoint,\n x: Math.min(lastPoint.x, nextColumns - 1),\n y: Math.min(lastPoint.y, nextRows - 1)\n };\n }\n // Application mode changes do not transfer ownership mid-gesture.\n if (columns !== nextColumns || rows !== nextRows) cancel(nextTracking !== 0);\n columns = nextColumns;\n rows = nextRows;\n tracking = nextTracking;\n }\n refreshHover();\n },\n refresh() { refreshHover(); },\n cancel() { cancel(); },\n dispose() {\n cancel(false);\n listeners.abort();\n canvas.title = originalTitle;\n canvas.style.cursor = originalCursor;\n }\n };\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map index 844e78e1204..94b53346fd5 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,eAAe,EAAiB,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIlH,eAAO,MAAM,MAAM;;;;;;;;EAQjB,CAAC;AAKH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGxD;AAyBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,GAAG,IAAI,CAqC1H;AAmGD,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,CAiD1D;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAY9G"} \ No newline at end of file +{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,eAAe,EAAiB,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIlH,eAAO,MAAM,MAAM;;;;;;;;EAQjB,CAAC;AAKH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGxD;AAyBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,GAAG,IAAI,CAqC1H;AA+GD,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,CAiD1D;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAY9G"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js index 48fbe71d38a..f24b438eb4c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js @@ -116,6 +116,20 @@ function validateMetadata(metadata) { } integer(columns * rows, "cell count", 1, LIMITS.cells); validateHistory(metadata.history, columns, rows); + array(metadata.hyperlinks, "hyperlinks", columns * rows); + let previousLinkEnd = 0; + for (const link of metadata.hyperlinks) { + if (!isRecord(link)) + throw new Error("Invalid hyperlink"); + const row = integer(link.row, "hyperlink row", 0, rows - 1); + const start = integer(link.startColumn, "hyperlink start column", 0, columns - 1); + const end = integer(link.endColumn, "hyperlink end column", start + 1, columns); + if (row * columns + start < previousLinkEnd) + throw new Error("Unordered or overlapping hyperlinks"); + previousLinkEnd = row * columns + end; + if (typeof link.uri !== "string" || !link.uri.length || link.uri.length > LIMITS.metadataBytes) + throw new Error("Invalid hyperlink URI"); + } if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) { throw new Error("This spike requires server geometry of 10 × 20 logical pixels"); } diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map index c5d7c1b4ee5..fa04673c658 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map @@ -1 +1 @@ -{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FrameMetadata, HistoryMetadata, SelectionText, TerminalCell, TerminalFrame } from \"./wire-types.js\";\nimport { isRecord } from \"./validation.js\";\n\n// Binary validation is deliberately independent of the GPU and the transport.\nexport const LIMITS = Object.freeze({\n commandBytes: 64 * 1024,\n frameBytes: 96 * 1024 * 1024,\n metadataBytes: 8 * 1024 * 1024,\n cells: 262144,\n images: 4096,\n placements: 16384,\n textureBytes: 256 * 1024 * 1024,\n});\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\nconst utf8Encoder = new TextEncoder();\n\nexport function assertCommandSize(command: unknown): void {\n if (utf8Encoder.encode(JSON.stringify(command)).byteLength > LIMITS.commandBytes)\n throw new RangeError(\"Terminal input exceeds the 64 KiB message limit. Send a smaller amount of text.\");\n}\n\nfunction integer(value: unknown, name: string, min = 0, max = Number.MAX_SAFE_INTEGER): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max) {\n throw new Error(`Invalid ${name}: ${String(value)}`);\n }\n return value;\n}\n\nfunction array(value: unknown, name: string, limit: number): asserts value is unknown[] {\n if (!Array.isArray(value) || value.length > limit) throw new Error(`Invalid ${name}`);\n}\n\nfunction key(value: unknown): asserts value is string {\n if (typeof value !== \"string\" || !value.length || value.length > 1024) {\n throw new Error(\"Invalid image key\");\n }\n}\n\nfunction rowId(value: unknown, name: string): asserts value is string {\n if (typeof value !== \"string\" || !/^[1-9][0-9]{0,18}$/u.test(value) || BigInt(value) > 9223372036854775807n) {\n throw new Error(`Invalid ${name}`);\n }\n}\n\nexport function validateHistory(history: unknown, columns: number, rows: number): asserts history is HistoryMetadata | null {\n if (history === null) return;\n if (!isRecord(history)) throw new Error(\"Missing history metadata\");\n rowId(history.generation, \"history generation\");\n if (history.buffer !== \"main\" && history.buffer !== \"alternate\") throw new Error(\"Invalid history buffer\");\n const totalRows = integer(history.totalRows, \"history total rows\", rows, 2147483647);\n const liveTop = integer(history.liveTop, \"history live top\", 0, totalRows - rows);\n if (liveTop !== totalRows - rows) throw new Error(\"Inconsistent history extent\");\n integer(history.top, \"history top\", 0, liveTop);\n if (typeof history.following !== \"boolean\" || (history.following && history.top !== history.liveTop)) {\n throw new Error(\"Invalid history following state\");\n }\n integer(history.requestId, \"viewport request id\");\n array(history.rowIds, \"viewport row ids\", rows);\n if (history.rowIds.length !== rows || new Set(history.rowIds).size !== rows) throw new Error(\"Invalid viewport row ids\");\n for (const id of history.rowIds) rowId(id, \"viewport row id\");\n const selection = history.selection;\n if (!isRecord(selection) || typeof selection.status !== \"string\" ||\n ![\"none\", \"valid\", \"invalidated\"].includes(selection.status)) throw new Error(\"Invalid selection status\");\n integer(selection.requestId, \"selection request id\");\n if (typeof selection.mode !== \"string\" || ![\"character\", \"word\", \"line\", \"rectangle\"].includes(selection.mode)) throw new Error(\"Invalid selection mode\");\n array(selection.ranges, \"selection ranges\", rows);\n let previousRow = -1;\n for (const range of selection.ranges) {\n if (!isRecord(range)) throw new Error(\"Invalid selection range\");\n const row = integer(range.row, \"selection range row\", previousRow + 1, rows - 1);\n const startColumn = integer(range.startColumn, \"selection start column\", 0, columns - 1);\n integer(range.endColumn, \"selection end column\", startColumn + 1, columns);\n previousRow = row;\n }\n validateSelectionText(selection);\n if (selection.status !== \"valid\" && selection.ranges.length) throw new Error(\"Inactive selection has highlight ranges\");\n if (history.copy !== null) {\n if (!isRecord(history.copy)) throw new Error(\"Missing copy metadata\");\n integer(history.copy.requestId, \"copy request id\", 1);\n validateSelectionText(history.copy);\n }\n}\n\nfunction validateSelectionText(selection: Record): asserts selection is Record & SelectionText {\n if (typeof selection.status !== \"string\" || ![\"none\", \"valid\", \"invalidated\"].includes(selection.status) ||\n (selection.status === \"valid\"\n ? typeof selection.text !== \"string\" || selection.text.length > LIMITS.metadataBytes\n : selection.text !== null)) {\n throw new Error(\"Invalid selection text\");\n }\n}\n\nfunction validateMetadata(metadata: unknown): asserts metadata is FrameMetadata {\n if (!isRecord(metadata) || metadata.version !== 1 || typeof metadata.full !== \"boolean\") {\n throw new Error(\"Unsupported frame metadata version\");\n }\n integer(metadata.revision, \"revision\", 1);\n integer(metadata.baseRevision, \"base revision\");\n const columns = integer(metadata.columns, \"columns\", 1, 1024);\n const rows = integer(metadata.rows, \"rows\", 1, 512);\n if (typeof metadata.mouseTracking !== \"number\" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) {\n throw new Error(\"Unsupported mouse tracking mode\");\n }\n if (!isRecord(metadata.peer) || typeof metadata.peer.isPrimary !== \"boolean\") throw new Error(\"Invalid peer state\");\n for (const field of [\"id\", \"primaryId\"]) {\n const id = metadata.peer[field];\n if (id !== null && (typeof id !== \"string\" || !id.length || id.length > 256)) {\n throw new Error(`Invalid peer ${field}`);\n }\n }\n if (metadata.peer.id !== null && metadata.peer.isPrimary !== (metadata.peer.id === metadata.peer.primaryId)) {\n throw new Error(\"Inconsistent primary peer state\");\n }\n integer(columns * rows, \"cell count\", 1, LIMITS.cells);\n validateHistory(metadata.history, columns, rows);\n if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) {\n throw new Error(\"This spike requires server geometry of 10 × 20 logical pixels\");\n }\n for (const field of [\"defaultBackground\", \"defaultForeground\"]) {\n if (metadata[field] !== undefined) integer(metadata[field], field, 0, 0xffffffff);\n }\n if (!isRecord(metadata.cursor) || typeof metadata.cursor.visible !== \"boolean\") throw new Error(\"Invalid cursor\");\n integer(metadata.cursor.x, \"cursor x\", -1, 1024);\n integer(metadata.cursor.y, \"cursor y\", -1, 512);\n const shapes = [\"Default\", \"BlinkingBlock\", \"SteadyBlock\", \"BlinkingUnderline\", \"SteadyUnderline\", \"BlinkingBar\", \"SteadyBar\"];\n if (typeof metadata.cursor.shape === \"string\") metadata.cursor.shape = shapes.indexOf(metadata.cursor.shape);\n integer(metadata.cursor.shape, \"cursor shape\", 0, 6);\n array(metadata.images, \"images\", LIMITS.images);\n array(metadata.retainedImages, \"retained image keys\", LIMITS.images);\n array(metadata.placements, \"placements\", LIMITS.placements);\n array(metadata.warnings, \"warnings\", 256);\n if (metadata.warnings.some(w => typeof w !== \"string\")) throw new Error(\"Invalid warning\");\n if (!isRecord(metadata.stats)) throw new Error(\"Invalid server metrics\");\n for (const field of [\"workloadBytes\", \"outputBatches\", \"captureMs\", \"elapsedMs\"]) {\n const metric = metadata.stats[field];\n if (typeof metric !== \"number\" || !Number.isFinite(metric) || metric < 0) {\n throw new Error(`Invalid server metric ${field}`);\n }\n }\n const retained = new Set();\n for (const imageKey of metadata.retainedImages) {\n key(imageKey);\n if (retained.has(imageKey)) throw new Error(\"Duplicate retained image key\");\n retained.add(imageKey);\n }\n const imageKeys = new Set();\n let decodedImageBytes = 0;\n for (const image of metadata.images) {\n if (!isRecord(image)) throw new Error(\"Invalid image\");\n key(image.key);\n if (imageKeys.has(image.key) || !retained.has(image.key)) throw new Error(\"Inconsistent new image keys\");\n imageKeys.add(image.key);\n const width = integer(image.width, \"image width\", 1, 16384);\n const height = integer(image.height, \"image height\", 1, 16384);\n const byteLength = integer(image.byteLength, \"image byte length\", 1, LIMITS.frameBytes);\n if (image.format !== \"rgba\" && image.format !== \"png\") throw new Error(\"Unsupported image format\");\n if (image.format === \"rgba\" && byteLength !== width * height * 4) {\n throw new Error(\"RGBA image size mismatch\");\n }\n decodedImageBytes += width * height * 4;\n if (decodedImageBytes > LIMITS.textureBytes) throw new Error(\"New images exceed decoded texture budget\");\n }\n for (const placement of metadata.placements) {\n if (!isRecord(placement)) throw new Error(\"Invalid placement\");\n key(placement.key);\n if (!retained.has(placement.key)) throw new Error(\"Placement references an unretained image\");\n if (placement.kind !== \"kgp\" && placement.kind !== \"sixel\") throw new Error(\"Invalid placement kind\");\n for (const field of [\"x\", \"y\", \"width\", \"height\", \"sourceX\", \"sourceY\", \"sourceWidth\", \"sourceHeight\", \"clipX\", \"clipY\", \"clipWidth\", \"clipHeight\", \"z\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || !Number.isFinite(coordinate) || Math.abs(coordinate) > 2147483648) {\n throw new Error(`Invalid placement ${field}`);\n }\n }\n for (const field of [\"width\", \"height\", \"sourceWidth\", \"sourceHeight\", \"clipWidth\", \"clipHeight\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || coordinate < 0) throw new Error(`Negative placement ${field}`);\n }\n }\n}\n\n/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */\nexport function decodeFrame(buffer: unknown): TerminalFrame {\n if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.frameBytes) {\n throw new Error(\"Invalid or oversized binary frame\");\n }\n const view = new DataView(buffer);\n let offset = 0;\n const requireBytes = (count: number) => {\n if (count < 0 || count > view.byteLength - offset) throw new Error(\"Truncated HWT1 frame\");\n };\n const u32 = () => { requireBytes(4); const n = view.getUint32(offset, true); offset += 4; return n; };\n if (u32() !== 0x31545748) throw new Error(\"Unsupported frame magic (expected HWT1)\");\n const metadataLength = integer(u32(), \"metadata length\", 2, LIMITS.metadataBytes);\n requireBytes(metadataLength);\n const metadata: unknown = JSON.parse(utf8.decode(new Uint8Array(buffer, offset, metadataLength)));\n offset += metadataLength;\n validateMetadata(metadata);\n const cellCount = metadata.columns * metadata.rows;\n const changedCount = integer(u32(), \"changed cell count\", 0, cellCount);\n if (changedCount > Math.floor((view.byteLength - offset) / 22)) throw new Error(\"Truncated cell records\");\n if (metadata.full && changedCount !== cellCount) throw new Error(\"Incomplete full frame\");\n const cells = [];\n const seen = new Set();\n for (let i = 0; i < changedCount; i++) {\n requireBytes(22);\n const index = u32();\n if (index >= cellCount || seen.has(index)) throw new Error(\"Invalid or duplicate cell index\");\n seen.add(index);\n const foreground = u32();\n const background = u32();\n const underlineColor = u32();\n const attributes = view.getUint16(offset, true);\n const width = view.getUint8(offset + 2);\n const underlineStyle = view.getUint8(offset + 3);\n const textLength = view.getUint16(offset + 4, true);\n offset += 6;\n if (underlineStyle > 5) throw new Error(\"Unsupported underline style\");\n requireBytes(textLength);\n const text = utf8.decode(new Uint8Array(buffer, offset, textLength));\n offset += textLength;\n cells.push({ index, foreground, background, underlineColor, attributes, width, underlineStyle, text });\n }\n const imageBytes = metadata.images.reduce((total, image) => total + image.byteLength, 0);\n if (imageBytes !== view.byteLength - offset) throw new Error(\"Image payload length mismatch\");\n const images = metadata.images.map(image => {\n const bytes = new Uint8Array(buffer, offset, image.byteLength);\n offset += image.byteLength;\n return { ...image, bytes };\n });\n return { metadata, cells, images };\n}\n\n/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */\nexport function screenText(cells: readonly (TerminalCell | undefined)[], columns: number, rows: number): string {\n const lines = [];\n for (let y = 0; y < rows; y++) {\n let line = \"\";\n for (let x = 0; x < columns; x++) {\n const cell = cells[y * columns + x];\n if (!cell || cell.width === 0) continue;\n line += cell.attributes & 64 ? \" \".repeat(cell.width) : (cell.text || \" \");\n }\n lines.push(line.replace(/ +$/u, \"\"));\n }\n return lines.join(\"\\n\");\n}\n"]} \ No newline at end of file +{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FrameMetadata, HistoryMetadata, SelectionText, TerminalCell, TerminalFrame } from \"./wire-types.js\";\nimport { isRecord } from \"./validation.js\";\n\n// Binary validation is deliberately independent of the GPU and the transport.\nexport const LIMITS = Object.freeze({\n commandBytes: 64 * 1024,\n frameBytes: 96 * 1024 * 1024,\n metadataBytes: 8 * 1024 * 1024,\n cells: 262144,\n images: 4096,\n placements: 16384,\n textureBytes: 256 * 1024 * 1024,\n});\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\nconst utf8Encoder = new TextEncoder();\n\nexport function assertCommandSize(command: unknown): void {\n if (utf8Encoder.encode(JSON.stringify(command)).byteLength > LIMITS.commandBytes)\n throw new RangeError(\"Terminal input exceeds the 64 KiB message limit. Send a smaller amount of text.\");\n}\n\nfunction integer(value: unknown, name: string, min = 0, max = Number.MAX_SAFE_INTEGER): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max) {\n throw new Error(`Invalid ${name}: ${String(value)}`);\n }\n return value;\n}\n\nfunction array(value: unknown, name: string, limit: number): asserts value is unknown[] {\n if (!Array.isArray(value) || value.length > limit) throw new Error(`Invalid ${name}`);\n}\n\nfunction key(value: unknown): asserts value is string {\n if (typeof value !== \"string\" || !value.length || value.length > 1024) {\n throw new Error(\"Invalid image key\");\n }\n}\n\nfunction rowId(value: unknown, name: string): asserts value is string {\n if (typeof value !== \"string\" || !/^[1-9][0-9]{0,18}$/u.test(value) || BigInt(value) > 9223372036854775807n) {\n throw new Error(`Invalid ${name}`);\n }\n}\n\nexport function validateHistory(history: unknown, columns: number, rows: number): asserts history is HistoryMetadata | null {\n if (history === null) return;\n if (!isRecord(history)) throw new Error(\"Missing history metadata\");\n rowId(history.generation, \"history generation\");\n if (history.buffer !== \"main\" && history.buffer !== \"alternate\") throw new Error(\"Invalid history buffer\");\n const totalRows = integer(history.totalRows, \"history total rows\", rows, 2147483647);\n const liveTop = integer(history.liveTop, \"history live top\", 0, totalRows - rows);\n if (liveTop !== totalRows - rows) throw new Error(\"Inconsistent history extent\");\n integer(history.top, \"history top\", 0, liveTop);\n if (typeof history.following !== \"boolean\" || (history.following && history.top !== history.liveTop)) {\n throw new Error(\"Invalid history following state\");\n }\n integer(history.requestId, \"viewport request id\");\n array(history.rowIds, \"viewport row ids\", rows);\n if (history.rowIds.length !== rows || new Set(history.rowIds).size !== rows) throw new Error(\"Invalid viewport row ids\");\n for (const id of history.rowIds) rowId(id, \"viewport row id\");\n const selection = history.selection;\n if (!isRecord(selection) || typeof selection.status !== \"string\" ||\n ![\"none\", \"valid\", \"invalidated\"].includes(selection.status)) throw new Error(\"Invalid selection status\");\n integer(selection.requestId, \"selection request id\");\n if (typeof selection.mode !== \"string\" || ![\"character\", \"word\", \"line\", \"rectangle\"].includes(selection.mode)) throw new Error(\"Invalid selection mode\");\n array(selection.ranges, \"selection ranges\", rows);\n let previousRow = -1;\n for (const range of selection.ranges) {\n if (!isRecord(range)) throw new Error(\"Invalid selection range\");\n const row = integer(range.row, \"selection range row\", previousRow + 1, rows - 1);\n const startColumn = integer(range.startColumn, \"selection start column\", 0, columns - 1);\n integer(range.endColumn, \"selection end column\", startColumn + 1, columns);\n previousRow = row;\n }\n validateSelectionText(selection);\n if (selection.status !== \"valid\" && selection.ranges.length) throw new Error(\"Inactive selection has highlight ranges\");\n if (history.copy !== null) {\n if (!isRecord(history.copy)) throw new Error(\"Missing copy metadata\");\n integer(history.copy.requestId, \"copy request id\", 1);\n validateSelectionText(history.copy);\n }\n}\n\nfunction validateSelectionText(selection: Record): asserts selection is Record & SelectionText {\n if (typeof selection.status !== \"string\" || ![\"none\", \"valid\", \"invalidated\"].includes(selection.status) ||\n (selection.status === \"valid\"\n ? typeof selection.text !== \"string\" || selection.text.length > LIMITS.metadataBytes\n : selection.text !== null)) {\n throw new Error(\"Invalid selection text\");\n }\n}\n\nfunction validateMetadata(metadata: unknown): asserts metadata is FrameMetadata {\n if (!isRecord(metadata) || metadata.version !== 1 || typeof metadata.full !== \"boolean\") {\n throw new Error(\"Unsupported frame metadata version\");\n }\n integer(metadata.revision, \"revision\", 1);\n integer(metadata.baseRevision, \"base revision\");\n const columns = integer(metadata.columns, \"columns\", 1, 1024);\n const rows = integer(metadata.rows, \"rows\", 1, 512);\n if (typeof metadata.mouseTracking !== \"number\" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) {\n throw new Error(\"Unsupported mouse tracking mode\");\n }\n if (!isRecord(metadata.peer) || typeof metadata.peer.isPrimary !== \"boolean\") throw new Error(\"Invalid peer state\");\n for (const field of [\"id\", \"primaryId\"]) {\n const id = metadata.peer[field];\n if (id !== null && (typeof id !== \"string\" || !id.length || id.length > 256)) {\n throw new Error(`Invalid peer ${field}`);\n }\n }\n if (metadata.peer.id !== null && metadata.peer.isPrimary !== (metadata.peer.id === metadata.peer.primaryId)) {\n throw new Error(\"Inconsistent primary peer state\");\n }\n integer(columns * rows, \"cell count\", 1, LIMITS.cells);\n validateHistory(metadata.history, columns, rows);\n array(metadata.hyperlinks, \"hyperlinks\", columns * rows);\n let previousLinkEnd = 0;\n for (const link of metadata.hyperlinks) {\n if (!isRecord(link)) throw new Error(\"Invalid hyperlink\");\n const row = integer(link.row, \"hyperlink row\", 0, rows - 1);\n const start = integer(link.startColumn, \"hyperlink start column\", 0, columns - 1);\n const end = integer(link.endColumn, \"hyperlink end column\", start + 1, columns);\n if (row * columns + start < previousLinkEnd) throw new Error(\"Unordered or overlapping hyperlinks\");\n previousLinkEnd = row * columns + end;\n if (typeof link.uri !== \"string\" || !link.uri.length || link.uri.length > LIMITS.metadataBytes)\n throw new Error(\"Invalid hyperlink URI\");\n }\n if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) {\n throw new Error(\"This spike requires server geometry of 10 × 20 logical pixels\");\n }\n for (const field of [\"defaultBackground\", \"defaultForeground\"]) {\n if (metadata[field] !== undefined) integer(metadata[field], field, 0, 0xffffffff);\n }\n if (!isRecord(metadata.cursor) || typeof metadata.cursor.visible !== \"boolean\") throw new Error(\"Invalid cursor\");\n integer(metadata.cursor.x, \"cursor x\", -1, 1024);\n integer(metadata.cursor.y, \"cursor y\", -1, 512);\n const shapes = [\"Default\", \"BlinkingBlock\", \"SteadyBlock\", \"BlinkingUnderline\", \"SteadyUnderline\", \"BlinkingBar\", \"SteadyBar\"];\n if (typeof metadata.cursor.shape === \"string\") metadata.cursor.shape = shapes.indexOf(metadata.cursor.shape);\n integer(metadata.cursor.shape, \"cursor shape\", 0, 6);\n array(metadata.images, \"images\", LIMITS.images);\n array(metadata.retainedImages, \"retained image keys\", LIMITS.images);\n array(metadata.placements, \"placements\", LIMITS.placements);\n array(metadata.warnings, \"warnings\", 256);\n if (metadata.warnings.some(w => typeof w !== \"string\")) throw new Error(\"Invalid warning\");\n if (!isRecord(metadata.stats)) throw new Error(\"Invalid server metrics\");\n for (const field of [\"workloadBytes\", \"outputBatches\", \"captureMs\", \"elapsedMs\"]) {\n const metric = metadata.stats[field];\n if (typeof metric !== \"number\" || !Number.isFinite(metric) || metric < 0) {\n throw new Error(`Invalid server metric ${field}`);\n }\n }\n const retained = new Set();\n for (const imageKey of metadata.retainedImages) {\n key(imageKey);\n if (retained.has(imageKey)) throw new Error(\"Duplicate retained image key\");\n retained.add(imageKey);\n }\n const imageKeys = new Set();\n let decodedImageBytes = 0;\n for (const image of metadata.images) {\n if (!isRecord(image)) throw new Error(\"Invalid image\");\n key(image.key);\n if (imageKeys.has(image.key) || !retained.has(image.key)) throw new Error(\"Inconsistent new image keys\");\n imageKeys.add(image.key);\n const width = integer(image.width, \"image width\", 1, 16384);\n const height = integer(image.height, \"image height\", 1, 16384);\n const byteLength = integer(image.byteLength, \"image byte length\", 1, LIMITS.frameBytes);\n if (image.format !== \"rgba\" && image.format !== \"png\") throw new Error(\"Unsupported image format\");\n if (image.format === \"rgba\" && byteLength !== width * height * 4) {\n throw new Error(\"RGBA image size mismatch\");\n }\n decodedImageBytes += width * height * 4;\n if (decodedImageBytes > LIMITS.textureBytes) throw new Error(\"New images exceed decoded texture budget\");\n }\n for (const placement of metadata.placements) {\n if (!isRecord(placement)) throw new Error(\"Invalid placement\");\n key(placement.key);\n if (!retained.has(placement.key)) throw new Error(\"Placement references an unretained image\");\n if (placement.kind !== \"kgp\" && placement.kind !== \"sixel\") throw new Error(\"Invalid placement kind\");\n for (const field of [\"x\", \"y\", \"width\", \"height\", \"sourceX\", \"sourceY\", \"sourceWidth\", \"sourceHeight\", \"clipX\", \"clipY\", \"clipWidth\", \"clipHeight\", \"z\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || !Number.isFinite(coordinate) || Math.abs(coordinate) > 2147483648) {\n throw new Error(`Invalid placement ${field}`);\n }\n }\n for (const field of [\"width\", \"height\", \"sourceWidth\", \"sourceHeight\", \"clipWidth\", \"clipHeight\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || coordinate < 0) throw new Error(`Negative placement ${field}`);\n }\n }\n}\n\n/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */\nexport function decodeFrame(buffer: unknown): TerminalFrame {\n if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.frameBytes) {\n throw new Error(\"Invalid or oversized binary frame\");\n }\n const view = new DataView(buffer);\n let offset = 0;\n const requireBytes = (count: number) => {\n if (count < 0 || count > view.byteLength - offset) throw new Error(\"Truncated HWT1 frame\");\n };\n const u32 = () => { requireBytes(4); const n = view.getUint32(offset, true); offset += 4; return n; };\n if (u32() !== 0x31545748) throw new Error(\"Unsupported frame magic (expected HWT1)\");\n const metadataLength = integer(u32(), \"metadata length\", 2, LIMITS.metadataBytes);\n requireBytes(metadataLength);\n const metadata: unknown = JSON.parse(utf8.decode(new Uint8Array(buffer, offset, metadataLength)));\n offset += metadataLength;\n validateMetadata(metadata);\n const cellCount = metadata.columns * metadata.rows;\n const changedCount = integer(u32(), \"changed cell count\", 0, cellCount);\n if (changedCount > Math.floor((view.byteLength - offset) / 22)) throw new Error(\"Truncated cell records\");\n if (metadata.full && changedCount !== cellCount) throw new Error(\"Incomplete full frame\");\n const cells = [];\n const seen = new Set();\n for (let i = 0; i < changedCount; i++) {\n requireBytes(22);\n const index = u32();\n if (index >= cellCount || seen.has(index)) throw new Error(\"Invalid or duplicate cell index\");\n seen.add(index);\n const foreground = u32();\n const background = u32();\n const underlineColor = u32();\n const attributes = view.getUint16(offset, true);\n const width = view.getUint8(offset + 2);\n const underlineStyle = view.getUint8(offset + 3);\n const textLength = view.getUint16(offset + 4, true);\n offset += 6;\n if (underlineStyle > 5) throw new Error(\"Unsupported underline style\");\n requireBytes(textLength);\n const text = utf8.decode(new Uint8Array(buffer, offset, textLength));\n offset += textLength;\n cells.push({ index, foreground, background, underlineColor, attributes, width, underlineStyle, text });\n }\n const imageBytes = metadata.images.reduce((total, image) => total + image.byteLength, 0);\n if (imageBytes !== view.byteLength - offset) throw new Error(\"Image payload length mismatch\");\n const images = metadata.images.map(image => {\n const bytes = new Uint8Array(buffer, offset, image.byteLength);\n offset += image.byteLength;\n return { ...image, bytes };\n });\n return { metadata, cells, images };\n}\n\n/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */\nexport function screenText(cells: readonly (TerminalCell | undefined)[], columns: number, rows: number): string {\n const lines = [];\n for (let y = 0; y < rows; y++) {\n let line = \"\";\n for (let x = 0; x < columns; x++) {\n const cell = cells[y * columns + x];\n if (!cell || cell.width === 0) continue;\n line += cell.attributes & 64 ? \" \".repeat(cell.width) : (cell.text || \" \");\n }\n lines.push(line.replace(/ +$/u, \"\"));\n }\n return lines.join(\"\\n\");\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.d.ts new file mode 100644 index 00000000000..ae7a639c2ef --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.d.ts @@ -0,0 +1,31 @@ +export declare const QUAD_STRIDE = 16; +export type RenderColor = [number, number, number, number]; +export type RenderPixels = Uint8Array | Uint8ClampedArray; +export interface RenderTexture { + readonly width: number; + readonly height: number; + writePixels(pixels: RenderPixels, width: number, height: number, x?: number, y?: number): void; + writeBitmap(bitmap: ImageBitmap): void; + destroy(): void; +} +export interface RenderBatch { + resource: RenderTexture; + start: number; + count: number; +} +/** Internal graphics boundary; layout, rasterization and ordering belong to TerminalRenderer. */ +export interface RenderBackend { + readonly kind: "webgpu" | "webgl2"; + readonly maxTextureDimension2D: number; + readonly maxCanvasDimension2D: number; + readonly instanceBufferBytes: number; + createTexture(width: number, height: number, label: string): RenderTexture; + resize(width: number, height: number): void; + submit(instances: Float32Array, quadCount: number, batches: readonly RenderBatch[], background: RenderColor): void; + idle(): Promise; + dispose(): void; +} +/** Only capability/device acquisition failures may trigger automatic backend fallback. */ +export declare class RendererUnavailableError extends Error { +} +//# sourceMappingURL=render-backend.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.d.ts.map new file mode 100644 index 00000000000..d85d9d3c42b --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"render-backend.d.ts","sourceRoot":"","sources":["../src/render-backend.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,MAAM,MAAM,WAAW,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,WAAW,CAAC,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAEpF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/F,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IACvC,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAAG,QAAQ,EAAE,aAAa,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AAEtF,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACnC,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,aAAa,CAAC;IAC3E,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,WAAW,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,WAAW,EAAE,EAC7F,UAAU,EAAE,WAAW,GAAG,IAAI,CAAC;IACjC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,0FAA0F;AAC1F,qBAAa,wBAAyB,SAAQ,KAAK;CAAG"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.js new file mode 100644 index 00000000000..e6e78a47c75 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.js @@ -0,0 +1,5 @@ +export const QUAD_STRIDE = 16; +/** Only capability/device acquisition failures may trigger automatic backend fallback. */ +export class RendererUnavailableError extends Error { +} +//# sourceMappingURL=render-backend.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.js.map new file mode 100644 index 00000000000..b6882c78307 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/render-backend.js.map @@ -0,0 +1 @@ +{"version":3,"file":"render-backend.js","sourceRoot":"","sources":["../src/render-backend.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,WAAW,GAAG,EAAE,CAAC;AA6B9B,0FAA0F;AAC1F,MAAM,OAAO,wBAAyB,SAAQ,KAAK;CAAG","sourcesContent":["export const QUAD_STRIDE = 16;\n\nexport type RenderColor = [number, number, number, number];\nexport type RenderPixels = Uint8Array | Uint8ClampedArray;\n\nexport interface RenderTexture {\n readonly width: number;\n readonly height: number;\n writePixels(pixels: RenderPixels, width: number, height: number, x?: number, y?: number): void;\n writeBitmap(bitmap: ImageBitmap): void;\n destroy(): void;\n}\n\nexport interface RenderBatch { resource: RenderTexture; start: number; count: number }\n\n/** Internal graphics boundary; layout, rasterization and ordering belong to TerminalRenderer. */\nexport interface RenderBackend {\n readonly kind: \"webgpu\" | \"webgl2\";\n readonly maxTextureDimension2D: number;\n readonly maxCanvasDimension2D: number;\n readonly instanceBufferBytes: number;\n createTexture(width: number, height: number, label: string): RenderTexture;\n resize(width: number, height: number): void;\n submit(instances: Float32Array, quadCount: number, batches: readonly RenderBatch[],\n background: RenderColor): void;\n idle(): Promise;\n dispose(): void;\n}\n\n/** Only capability/device acquisition failures may trigger automatic backend fallback. */\nexport class RendererUnavailableError extends Error {}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.d.ts new file mode 100644 index 00000000000..e2624ba44d4 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.d.ts @@ -0,0 +1,3 @@ +import type { TerminalRendererPreference } from "./types.js"; +export declare function normalizeRenderer(value?: unknown): TerminalRendererPreference; +//# sourceMappingURL=renderer-options.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.d.ts.map new file mode 100644 index 00000000000..05a5c8d716a --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"renderer-options.d.ts","sourceRoot":"","sources":["../src/renderer-options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAE7D,wBAAgB,iBAAiB,CAAC,KAAK,GAAE,OAAgB,GAAG,0BAA0B,CAGrF"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.js new file mode 100644 index 00000000000..309b8d17616 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.js @@ -0,0 +1,6 @@ +export function normalizeRenderer(value = "auto") { + if (value === "auto" || value === "webgpu" || value === "webgl2") + return value; + throw new TypeError('renderer must be "auto", "webgpu", or "webgl2"'); +} +//# sourceMappingURL=renderer-options.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.js.map new file mode 100644 index 00000000000..31a12b3d503 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer-options.js.map @@ -0,0 +1 @@ +{"version":3,"file":"renderer-options.js","sourceRoot":"","sources":["../src/renderer-options.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,iBAAiB,CAAC,QAAiB,MAAM;IACvD,IAAI,KAAK,KAAK,MAAM,IAAI,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC/E,MAAM,IAAI,SAAS,CAAC,gDAAgD,CAAC,CAAC;AACxE,CAAC","sourcesContent":["import type { TerminalRendererPreference } from \"./types.js\";\n\nexport function normalizeRenderer(value: unknown = \"auto\"): TerminalRendererPreference {\n if (value === \"auto\" || value === \"webgpu\" || value === \"webgl2\") return value;\n throw new TypeError('renderer must be \"auto\", \"webgpu\", or \"webgl2\"');\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts index 31d7d0796e5..6b36ef276c1 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts @@ -1,13 +1,9 @@ +import type { RenderBackend, RenderBatch, RenderColor, RenderTexture } from "./render-backend.js"; import type { FontMetrics, LoadedFont, NormalizedFont } from "./terminal-font.js"; -import type { TerminalFont, TerminalSize } from "./types.js"; +import type { TerminalFont, TerminalRendererPreference, TerminalSize } from "./types.js"; import type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from "./wire-types.js"; -type Vector4 = [number, number, number, number]; -interface TextureResource { - texture: GPUTexture; - bindGroup: GPUBindGroup; - width: number; - height: number; -} +type Vector4 = RenderColor; +type TextureResource = RenderTexture; interface Shelf { x: number; y: number; @@ -28,20 +24,16 @@ interface Glyph { u1: number; v1: number; } -interface Batch { - resource: TextureResource; - start: number; - count: number; -} -/** WebGPU instanced quads; Canvas2D is used only to rasterize reusable glyphs. */ +type Batch = RenderBatch; +/** Shared instanced-quad preparation; Canvas2D rasterizes reusable glyphs for either backend. */ export declare class TerminalRenderer { canvas: OffscreenCanvas; scale: number; backingScale: number; - device: GPUDevice; + backend: RenderBackend; + fallbackReason?: string; fontConfiguration: NormalizedFont; fontMetrics: Map; - context: GPUCanvasContext; images: Map; glyphs: Map; imageUploadBytes: number; @@ -50,16 +42,10 @@ export declare class TerminalRenderer { atlasRebuilds: number; textureBytes: number; instances: Float32Array; - instanceBuffer: GPUBuffer | null; - instanceBufferBytes: number; disposed: boolean; columns: number; rows: number; font: LoadedFont; - format: GPUTextureFormat; - uniform: GPUBuffer; - sampler: GPUSampler; - pipeline: GPURenderPipeline; rasterCanvas: OffscreenCanvas; raster: OffscreenCanvasRenderingContext2D; atlas: TextureResource; @@ -70,8 +56,8 @@ export declare class TerminalRenderer { height: number; quadCount: number; batches: Batch[]; - static create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error | GPUError) => void, font: TerminalFont): Promise; - constructor(canvas: OffscreenCanvas, scale: number, device: GPUDevice, font: NormalizedFont); + static create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error) => void, font?: TerminalFont, preference?: TerminalRendererPreference): Promise; + constructor(canvas: OffscreenCanvas, scale: number, backend: RenderBackend, font: NormalizedFont); initialize(): Promise; createTexture(width: number, height: number, label: string): TextureResource; resetAtlas(size: number): void; @@ -91,6 +77,8 @@ export declare class TerminalRenderer { drawCalls: number; }; metrics(): { + renderer: "webgl2" | "webgpu"; + rendererFallbackReason: string | undefined; fontFamily: string; rasterScale: number; backingScale: number; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map index d2967de063c..3af134dc66f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js index e90640c1a81..8fab657a032 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js @@ -1,53 +1,14 @@ import { LIMITS } from "./protocol.js"; import { loadFont, measureFont, normalizeFont } from "./terminal-font.js"; +import { createRenderBackend } from "./backend-selection.js"; +import { QUAD_STRIDE } from "./render-backend.js"; const CELL_WIDTH = 10; const CELL_HEIGHT = 20; const MAX_QUADS = 1024 * 1024; const MAX_GLYPHS = 16384; const MAX_GLYPH_KEY_UNITS = 1024 * 1024; -const STRIDE = 16; +const STRIDE = QUAD_STRIDE; const WHITE = [1, 1, 1, 1]; -const shader = /* wgsl */ ` -struct Viewport { size: vec2f, padding: vec2f } -@group(0) @binding(0) var viewport: Viewport; -@group(0) @binding(1) var image: texture_2d; -@group(0) @binding(2) var imageSampler: sampler; - -struct VertexOut { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, - @location(1) color: vec4f, - @location(2) @interpolate(flat) mode: f32, -} - -@vertex fn vertex( - @builtin(vertex_index) index: u32, - @location(0) rect: vec4f, - @location(1) uvRect: vec4f, - @location(2) color: vec4f, - @location(3) mode: f32, -) -> VertexOut { - let corners = array( - vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), - vec2f(0, 1), vec2f(1, 0), vec2f(1, 1) - ); - let corner = corners[index]; - let position = rect.xy + corner * rect.zw; - var out: VertexOut; - out.position = vec4f(position / viewport.size * vec2f(2, -2) + vec2f(-1, 1), 0, 1); - out.uv = mix(uvRect.xy, uvRect.zw, corner); - out.color = color; - out.mode = mode; - return out; -} - -@fragment fn fragment(in: VertexOut) -> @location(0) vec4f { - // Explicit LOD avoids derivative-uniformity requirements across solid/mask/image batches. - let texel = textureSampleLevel(image, imageSampler, in.uv, 0); - if (in.mode < 0.5) { return in.color; } - if (in.mode < 1.5) { return vec4f(in.color.rgb, in.color.a * texel.a); } - return texel * in.color; -}`; function rgba(packed) { return [ (packed & 255) / 255, @@ -81,15 +42,15 @@ function packGlyphs(glyphs, size, scale, initial = { x: 0, y: 0, rowHeight: 0 }) } return { placements, shelf: { x, y, rowHeight } }; } -/** WebGPU instanced quads; Canvas2D is used only to rasterize reusable glyphs. */ +/** Shared instanced-quad preparation; Canvas2D rasterizes reusable glyphs for either backend. */ export class TerminalRenderer { canvas; scale; backingScale; - device; + backend; + fallbackReason; fontConfiguration; fontMetrics; - context; images; glyphs; imageUploadBytes; @@ -98,17 +59,11 @@ export class TerminalRenderer { atlasRebuilds; textureBytes; instances; - instanceBuffer; - instanceBufferBytes; disposed; columns; rows; // Initialized by create() before the renderer can prepare or submit frames. font; - format; - uniform; - sampler; - pipeline; rasterCanvas; raster; atlas; @@ -119,22 +74,13 @@ export class TerminalRenderer { height = 0; quadCount = 0; batches = []; - static async create(canvas, scale, onFatal, font) { + static async create(canvas, scale, onFatal, font, preference = "auto") { const normalizedFont = normalizeFont(font); - if (!self.isSecureContext) - throw new Error("WebGPU requires HTTPS or localhost"); - if (!navigator.gpu) - throw new Error("WebGPU is unavailable in this browser worker"); - const adapter = await navigator.gpu.requestAdapter(); - if (!adapter) - throw new Error("No WebGPU adapter is available; check browser GPU support"); - const device = await adapter.requestDevice(); - const renderer = new TerminalRenderer(canvas, scale, device, normalizedFont); - device.lost.then(info => { - if (!renderer.disposed) - onFatal(new Error(`WebGPU device lost: ${info.message || info.reason}`)); - }); - device.addEventListener("uncapturederror", event => onFatal(event.error)); + if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) + throw new Error("Invalid backing scale"); + const { backend, fallbackReason } = await createRenderBackend(canvas, onFatal, preference); + const renderer = new TerminalRenderer(canvas, scale, backend, normalizedFont); + renderer.fallbackReason = fallbackReason; try { await renderer.initialize(); return renderer; @@ -144,19 +90,15 @@ export class TerminalRenderer { throw error; } } - constructor(canvas, scale, device, font) { + constructor(canvas, scale, backend, font) { if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error("Invalid backing scale"); this.canvas = canvas; this.scale = scale; this.backingScale = scale; - this.device = device; + this.backend = backend; this.fontConfiguration = font; this.fontMetrics = new Map(); - const context = canvas.getContext("webgpu"); - if (!context) - throw new Error("Could not create an OffscreenCanvas WebGPU context"); - this.context = context; this.images = new Map(); this.glyphs = new Map(); this.imageUploadBytes = 0; @@ -165,78 +107,24 @@ export class TerminalRenderer { this.atlasRebuilds = 0; this.textureBytes = 0; this.instances = new Float32Array(4096 * STRIDE); - this.instanceBuffer = null; - this.instanceBufferBytes = 0; this.disposed = false; this.columns = 0; this.rows = 0; } async initialize() { this.font = await loadFont(this.fontConfiguration); - const device = this.device; - this.format = navigator.gpu.getPreferredCanvasFormat(); - this.context.configure({ device, format: this.format, alphaMode: "opaque" }); - this.uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); - this.sampler = device.createSampler({ minFilter: "linear", magFilter: "linear" }); - const module = device.createShaderModule({ code: shader }); - this.pipeline = await device.createRenderPipelineAsync({ - layout: "auto", - vertex: { - module, - entryPoint: "vertex", - buffers: [{ - arrayStride: STRIDE * 4, - stepMode: "instance", - attributes: [ - { shaderLocation: 0, offset: 0, format: "float32x4" }, - { shaderLocation: 1, offset: 16, format: "float32x4" }, - { shaderLocation: 2, offset: 32, format: "float32x4" }, - { shaderLocation: 3, offset: 48, format: "float32" }, - ], - }], - }, - fragment: { - module, - entryPoint: "fragment", - targets: [{ - format: this.format, - blend: { - color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha" }, - alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }, - }, - }], - }, - primitive: { topology: "triangle-list" }, - }); this.rasterCanvas = new OffscreenCanvas(1, 1); const raster = this.rasterCanvas.getContext("2d", { willReadFrequently: true }); if (!raster) throw new Error("Worker glyph rasterization is unavailable"); this.raster = raster; - this.resetAtlas(Math.min(2048, device.limits.maxTextureDimension2D)); + this.resetAtlas(Math.min(2048, this.backend.maxTextureDimension2D)); } createTexture(width, height, label) { - if (width > this.device.limits.maxTextureDimension2D || height > this.device.limits.maxTextureDimension2D) { - throw new Error(`${label} exceeds the GPU texture dimension limit`); - } - const texture = this.device.createTexture({ - label, - size: [width, height], - format: "rgba8unorm", - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, - }); - const bindGroup = this.device.createBindGroup({ - layout: this.pipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: this.uniform } }, - { binding: 1, resource: texture.createView() }, - { binding: 2, resource: this.sampler }, - ], - }); - return { texture, bindGroup, width, height }; + return this.backend.createTexture(width, height, label); } resetAtlas(size) { - this.atlas?.texture.destroy(); + this.atlas?.destroy(); this.atlas = this.createTexture(size, size, "Glyph atlas"); this.glyphs.clear(); this.glyphKeyUnits = 0; @@ -245,7 +133,7 @@ export class TerminalRenderer { resize(columns, rows, viewport) { const width = columns * CELL_WIDTH; const height = rows * CELL_HEIGHT; - const limit = this.device.limits.maxTextureDimension2D; + const limit = this.backend.maxCanvasDimension2D; const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity); this.canvasLimited = width * requested > limit || height * requested > limit; this.backingScale = Math.min(requested, limit / width, limit / height); @@ -259,7 +147,7 @@ export class TerminalRenderer { this.height = height; this.canvas.width = backingWidth; this.canvas.height = backingHeight; - this.device.queue.writeBuffer(this.uniform, 0, new Float32Array([width, height, 0, 0])); + this.backend.resize(width, height); } /** Call only between submissions. Missing/over-budget resources terminate the session. */ async updateImages(incoming, retainedKeys) { @@ -276,7 +164,7 @@ export class TerminalRenderer { throw new Error("Retained images exceed the 256 MiB texture budget"); for (const [key, image] of this.images) { if (!retained.has(key) || replacements.has(key)) { - image.texture.destroy(); + image.destroy(); this.textureBytes -= image.width * image.height * 4; this.images.delete(key); } @@ -285,7 +173,7 @@ export class TerminalRenderer { const resource = this.createTexture(image.width, image.height, `Image ${image.key}`); try { if (image.format === "rgba") { - this.device.queue.writeTexture({ texture: resource.texture }, image.bytes, { bytesPerRow: image.width * 4, rowsPerImage: image.height }, [image.width, image.height]); + resource.writePixels(image.bytes, image.width, image.height); } else { // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions. @@ -302,7 +190,7 @@ export class TerminalRenderer { try { if (bitmap.width !== image.width || bitmap.height !== image.height) throw new Error("Decoded PNG dimension mismatch"); - this.device.queue.copyExternalImageToTexture({ source: bitmap }, { texture: resource.texture, premultipliedAlpha: false }, [image.width, image.height]); + resource.writeBitmap(bitmap); } finally { bitmap.close(); @@ -314,7 +202,7 @@ export class TerminalRenderer { this.imagePayloadBytes += image.byteLength; } catch (error) { - resource.texture.destroy(); + resource.destroy(); throw error; } } @@ -338,7 +226,7 @@ export class TerminalRenderer { let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null; if (!plan) { let size = this.atlas.width; - const maxSize = Math.min(4096, this.device.limits.maxTextureDimension2D); + const maxSize = Math.min(4096, this.backend.maxTextureDimension2D); while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) { size = Math.min(size * 2, maxSize); } @@ -378,7 +266,7 @@ export class TerminalRenderer { break; } } - this.device.queue.writeTexture({ texture: this.atlas.texture, origin: [x, y] }, pixels.data, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]); + this.atlas.writePixels(pixels.data, width, height, x, y); this.glyphUploadBytes += width * height * 4; this.glyphs.set(key, { colored, @@ -526,39 +414,14 @@ export class TerminalRenderer { this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color); } } - const usedBytes = this.quadCount * STRIDE * 4; - if (!this.instanceBuffer || usedBytes > this.instanceBufferBytes) { - this.instanceBuffer?.destroy(); - this.instanceBufferBytes = Math.max(256, this.instances.byteLength); - this.instanceBuffer = this.device.createBuffer({ - size: this.instanceBufferBytes, - usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, - }); - } - if (usedBytes) - this.device.queue.writeBuffer(this.instanceBuffer, 0, this.instances, 0, this.quadCount * STRIDE); - const encoder = this.device.createCommandEncoder(); const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000); - const pass = encoder.beginRenderPass({ - colorAttachments: [{ - view: this.context.getCurrentTexture().createView(), - clearValue: { r: base[0], g: base[1], b: base[2], a: 1 }, - loadOp: "clear", - storeOp: "store", - }], - }); - pass.setPipeline(this.pipeline); - pass.setVertexBuffer(0, this.instanceBuffer); - for (const batch of this.batches) { - pass.setBindGroup(0, batch.resource.bindGroup); - pass.draw(6, batch.count, 0, batch.start); - } - pass.end(); - this.device.queue.submit([encoder.finish()]); + this.backend.submit(this.instances, this.quadCount, this.batches, base); return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length }; } metrics() { return { + renderer: this.backend.kind, + rendererFallbackReason: this.fallbackReason, fontFamily: this.font.family, rasterScale: this.scale, backingScale: this.backingScale, @@ -572,24 +435,23 @@ export class TerminalRenderer { imageUploadBytes: this.imageUploadBytes, imagePayloadBytes: this.imagePayloadBytes, glyphUploadBytes: this.glyphUploadBytes, - instanceBufferBytes: this.instanceBufferBytes, + instanceBufferBytes: this.backend.instanceBufferBytes, }; } async idle() { - await this.device.queue.onSubmittedWorkDone(); + await this.backend.idle(); } dispose() { + if (this.disposed) + return; this.disposed = true; for (const image of this.images.values()) - image.texture.destroy(); + image.destroy(); this.images.clear(); - this.atlas?.texture.destroy(); - this.instanceBuffer?.destroy(); - this.uniform?.destroy(); + this.atlas?.destroy(); this.font?.dispose(); this.fontMetrics.clear(); - this.context.unconfigure(); - this.device.destroy(); + this.backend.dispose(); } } //# sourceMappingURL=renderer.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map index 530dcca1fd0..6106163c237 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map @@ -1 +1 @@ -{"version":3,"file":"renderer.js","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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","sourcesContent":["import { LIMITS } from \"./protocol.js\";\nimport { loadFont, measureFont, normalizeFont } from \"./terminal-font.js\";\nimport type { FontMetrics, LoadedFont, NormalizedFont } from \"./terminal-font.js\";\nimport type { TerminalFont, TerminalSize } from \"./types.js\";\nimport type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from \"./wire-types.js\";\n\ntype Vector4 = [number, number, number, number];\ninterface TextureResource { texture: GPUTexture; bindGroup: GPUBindGroup; width: number; height: number }\ninterface Shelf { x: number; y: number; rowHeight: number }\ninterface GlyphPlacement { key: string; cell: TerminalCell; x: number; y: number; width: number; height: number }\ninterface Glyph { colored: boolean; u0: number; v0: number; u1: number; v1: number }\ninterface Batch { resource: TextureResource; start: number; count: number }\n\nconst CELL_WIDTH = 10;\nconst CELL_HEIGHT = 20;\nconst MAX_QUADS = 1024 * 1024;\nconst MAX_GLYPHS = 16384;\nconst MAX_GLYPH_KEY_UNITS = 1024 * 1024;\nconst STRIDE = 16;\nconst WHITE: Vector4 = [1, 1, 1, 1];\n\nconst shader = /* wgsl */ `\nstruct Viewport { size: vec2f, padding: vec2f }\n@group(0) @binding(0) var viewport: Viewport;\n@group(0) @binding(1) var image: texture_2d;\n@group(0) @binding(2) var imageSampler: sampler;\n\nstruct VertexOut {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n @location(1) color: vec4f,\n @location(2) @interpolate(flat) mode: f32,\n}\n\n@vertex fn vertex(\n @builtin(vertex_index) index: u32,\n @location(0) rect: vec4f,\n @location(1) uvRect: vec4f,\n @location(2) color: vec4f,\n @location(3) mode: f32,\n) -> VertexOut {\n let corners = array(\n vec2f(0, 0), vec2f(1, 0), vec2f(0, 1),\n vec2f(0, 1), vec2f(1, 0), vec2f(1, 1)\n );\n let corner = corners[index];\n let position = rect.xy + corner * rect.zw;\n var out: VertexOut;\n out.position = vec4f(position / viewport.size * vec2f(2, -2) + vec2f(-1, 1), 0, 1);\n out.uv = mix(uvRect.xy, uvRect.zw, corner);\n out.color = color;\n out.mode = mode;\n return out;\n}\n\n@fragment fn fragment(in: VertexOut) -> @location(0) vec4f {\n // Explicit LOD avoids derivative-uniformity requirements across solid/mask/image batches.\n let texel = textureSampleLevel(image, imageSampler, in.uv, 0);\n if (in.mode < 0.5) { return in.color; }\n if (in.mode < 1.5) { return vec4f(in.color.rgb, in.color.a * texel.a); }\n return texel * in.color;\n}`;\n\nfunction rgba(packed: number): Vector4 {\n return [\n (packed & 255) / 255,\n ((packed >>> 8) & 255) / 255,\n ((packed >>> 16) & 255) / 255,\n ((packed >>> 24) & 255) / 255,\n ];\n}\n\nfunction glyphKey(cell: TerminalCell): string {\n return `${cell.attributes & 5}/${cell.width}/${cell.text}`;\n}\n\n/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */\nfunction packGlyphs(glyphs: ReadonlyMap, size: number, scale: number,\n initial: Shelf = { x: 0, y: 0, rowHeight: 0 }): { placements: GlyphPlacement[]; shelf: Shelf } | null {\n let { x, y, rowHeight } = initial;\n const placements = [];\n for (const [key, cell] of glyphs) {\n const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4;\n const height = Math.ceil(CELL_HEIGHT * scale) + 4;\n if (width > size || height > size) return null;\n if (x + width > size) { x = 0; y += rowHeight; rowHeight = 0; }\n if (y + height > size) return null;\n placements.push({ key, cell, x, y, width, height });\n x += width;\n rowHeight = Math.max(rowHeight, height);\n }\n return { placements, shelf: { x, y, rowHeight } };\n}\n\n/** WebGPU instanced quads; Canvas2D is used only to rasterize reusable glyphs. */\nexport class TerminalRenderer {\n canvas: OffscreenCanvas;\n scale: number;\n backingScale: number;\n device: GPUDevice;\n fontConfiguration: NormalizedFont;\n fontMetrics: Map;\n context: GPUCanvasContext;\n images: Map;\n glyphs: Map;\n imageUploadBytes: number;\n imagePayloadBytes: number;\n glyphUploadBytes: number;\n atlasRebuilds: number;\n textureBytes: number;\n instances: Float32Array;\n instanceBuffer: GPUBuffer | null;\n instanceBufferBytes: number;\n disposed: boolean;\n columns: number;\n rows: number;\n // Initialized by create() before the renderer can prepare or submit frames.\n font!: LoadedFont;\n format!: GPUTextureFormat;\n uniform!: GPUBuffer;\n sampler!: GPUSampler;\n pipeline!: GPURenderPipeline;\n rasterCanvas!: OffscreenCanvas;\n raster!: OffscreenCanvasRenderingContext2D;\n atlas!: TextureResource;\n glyphKeyUnits = 0;\n shelf: Shelf = { x: 0, y: 0, rowHeight: 0 };\n canvasLimited = false;\n width = 0;\n height = 0;\n quadCount = 0;\n batches: Batch[] = [];\n\n static async create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error | GPUError) => void,\n font: TerminalFont): Promise {\n const normalizedFont = normalizeFont(font);\n if (!self.isSecureContext) throw new Error(\"WebGPU requires HTTPS or localhost\");\n if (!navigator.gpu) throw new Error(\"WebGPU is unavailable in this browser worker\");\n const adapter = await navigator.gpu.requestAdapter();\n if (!adapter) throw new Error(\"No WebGPU adapter is available; check browser GPU support\");\n const device = await adapter.requestDevice();\n const renderer = new TerminalRenderer(canvas, scale, device, normalizedFont);\n device.lost.then(info => {\n if (!renderer.disposed) onFatal(new Error(`WebGPU device lost: ${info.message || info.reason}`));\n });\n device.addEventListener(\"uncapturederror\", event => onFatal(event.error));\n try {\n await renderer.initialize();\n return renderer;\n } catch (error) {\n renderer.dispose();\n throw error;\n }\n }\n\n constructor(canvas: OffscreenCanvas, scale: number, device: GPUDevice, font: NormalizedFont) {\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n this.canvas = canvas;\n this.scale = scale;\n this.backingScale = scale;\n this.device = device;\n this.fontConfiguration = font;\n this.fontMetrics = new Map();\n const context = canvas.getContext(\"webgpu\");\n if (!context) throw new Error(\"Could not create an OffscreenCanvas WebGPU context\");\n this.context = context;\n this.images = new Map();\n this.glyphs = new Map();\n this.imageUploadBytes = 0;\n this.imagePayloadBytes = 0;\n this.glyphUploadBytes = 0;\n this.atlasRebuilds = 0;\n this.textureBytes = 0;\n this.instances = new Float32Array(4096 * STRIDE);\n this.instanceBuffer = null;\n this.instanceBufferBytes = 0;\n this.disposed = false;\n this.columns = 0;\n this.rows = 0;\n }\n\n async initialize() {\n this.font = await loadFont(this.fontConfiguration);\n const device = this.device;\n this.format = navigator.gpu.getPreferredCanvasFormat();\n this.context.configure({ device, format: this.format, alphaMode: \"opaque\" });\n this.uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });\n this.sampler = device.createSampler({ minFilter: \"linear\", magFilter: \"linear\" });\n const module = device.createShaderModule({ code: shader });\n this.pipeline = await device.createRenderPipelineAsync({\n layout: \"auto\",\n vertex: {\n module,\n entryPoint: \"vertex\",\n buffers: [{\n arrayStride: STRIDE * 4,\n stepMode: \"instance\",\n attributes: [\n { shaderLocation: 0, offset: 0, format: \"float32x4\" },\n { shaderLocation: 1, offset: 16, format: \"float32x4\" },\n { shaderLocation: 2, offset: 32, format: \"float32x4\" },\n { shaderLocation: 3, offset: 48, format: \"float32\" },\n ],\n }],\n },\n fragment: {\n module,\n entryPoint: \"fragment\",\n targets: [{\n format: this.format,\n blend: {\n color: { srcFactor: \"src-alpha\", dstFactor: \"one-minus-src-alpha\" },\n alpha: { srcFactor: \"one\", dstFactor: \"one-minus-src-alpha\" },\n },\n }],\n },\n primitive: { topology: \"triangle-list\" },\n });\n this.rasterCanvas = new OffscreenCanvas(1, 1);\n const raster = this.rasterCanvas.getContext(\"2d\", { willReadFrequently: true });\n if (!raster) throw new Error(\"Worker glyph rasterization is unavailable\");\n this.raster = raster;\n this.resetAtlas(Math.min(2048, device.limits.maxTextureDimension2D));\n }\n\n createTexture(width: number, height: number, label: string): TextureResource {\n if (width > this.device.limits.maxTextureDimension2D || height > this.device.limits.maxTextureDimension2D) {\n throw new Error(`${label} exceeds the GPU texture dimension limit`);\n }\n const texture = this.device.createTexture({\n label,\n size: [width, height],\n format: \"rgba8unorm\",\n usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,\n });\n const bindGroup = this.device.createBindGroup({\n layout: this.pipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: { buffer: this.uniform } },\n { binding: 1, resource: texture.createView() },\n { binding: 2, resource: this.sampler },\n ],\n });\n return { texture, bindGroup, width, height };\n }\n\n resetAtlas(size: number): void {\n this.atlas?.texture.destroy();\n this.atlas = this.createTexture(size, size, \"Glyph atlas\");\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.shelf = { x: 0, y: 0, rowHeight: 0 };\n }\n\n resize(columns: number, rows: number, viewport?: TerminalSize): void {\n const width = columns * CELL_WIDTH;\n const height = rows * CELL_HEIGHT;\n const limit = this.device.limits.maxTextureDimension2D;\n const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity);\n this.canvasLimited = width * requested > limit || height * requested > limit;\n this.backingScale = Math.min(requested, limit / width, limit / height);\n const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale)));\n const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale)));\n if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight) return;\n this.columns = columns;\n this.rows = rows;\n this.width = width;\n this.height = height;\n this.canvas.width = backingWidth;\n this.canvas.height = backingHeight;\n this.device.queue.writeBuffer(this.uniform, 0, new Float32Array([width, height, 0, 0]));\n }\n\n /** Call only between submissions. Missing/over-budget resources terminate the session. */\n async updateImages(incoming: readonly FrameImage[], retainedKeys: readonly string[]): Promise {\n const retained = new Set(retainedKeys);\n const replacements = new Map(incoming.map(image => [image.key, image]));\n let projectedBytes = 0;\n for (const key of retained) {\n const image = replacements.get(key) || this.images.get(key);\n if (!image) throw new Error(`Missing retained image resource: ${key}`);\n projectedBytes += image.width * image.height * 4;\n }\n if (projectedBytes > LIMITS.textureBytes) throw new Error(\"Retained images exceed the 256 MiB texture budget\");\n for (const [key, image] of this.images) {\n if (!retained.has(key) || replacements.has(key)) {\n image.texture.destroy();\n this.textureBytes -= image.width * image.height * 4;\n this.images.delete(key);\n }\n }\n for (const image of incoming) {\n const resource = this.createTexture(image.width, image.height, `Image ${image.key}`);\n try {\n if (image.format === \"rgba\") {\n this.device.queue.writeTexture(\n { texture: resource.texture },\n image.bytes,\n { bytesPerRow: image.width * 4, rowsPerImage: image.height },\n [image.width, image.height],\n );\n } else {\n // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions.\n const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength);\n if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a ||\n png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 ||\n png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) {\n throw new Error(`PNG header dimensions do not match resource ${image.key}`);\n }\n const bitmap = await createImageBitmap(new Blob([image.bytes], { type: \"image/png\" }), {\n premultiplyAlpha: \"none\",\n colorSpaceConversion: \"none\",\n });\n try {\n if (bitmap.width !== image.width || bitmap.height !== image.height) throw new Error(\"Decoded PNG dimension mismatch\");\n this.device.queue.copyExternalImageToTexture(\n { source: bitmap },\n { texture: resource.texture, premultipliedAlpha: false },\n [image.width, image.height],\n );\n } finally {\n bitmap.close();\n }\n }\n this.images.set(image.key, resource);\n this.textureBytes += image.width * image.height * 4;\n this.imageUploadBytes += image.width * image.height * 4;\n this.imagePayloadBytes += image.byteLength;\n } catch (error) {\n resource.texture.destroy();\n throw error;\n }\n }\n }\n\n prepareGlyphs(cells: readonly (TerminalCell | undefined)[]): void {\n const visible = new Map();\n for (const cell of cells) {\n if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64)) continue;\n visible.set(glyphKey(cell), cell);\n }\n const keyUnits = (glyphs: ReadonlyMap) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0);\n if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) {\n throw new Error(\"Visible glyph metadata exceeds the bounded glyph cache\");\n }\n const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key)));\n if (!missing.size) return;\n const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS &&\n this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS;\n let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null;\n if (!plan) {\n let size = this.atlas.width;\n const maxSize = Math.min(4096, this.device.limits.maxTextureDimension2D);\n while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) {\n size = Math.min(size * 2, maxSize);\n }\n if (!plan) throw new Error(\"Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale\");\n this.resetAtlas(size);\n this.atlasRebuilds++;\n }\n for (const placement of plan.placements) this.uploadGlyph(placement);\n this.shelf = plan.shelf;\n }\n\n uploadGlyph({ key, cell, x, y, width, height }: GlyphPlacement): void {\n const scale = this.scale;\n const raster = this.raster;\n const style = cell.attributes & 5;\n let metrics = this.fontMetrics.get(style);\n if (!metrics) {\n metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT);\n this.fontMetrics.set(style, metrics);\n }\n this.rasterCanvas.width = width;\n this.rasterCanvas.height = height;\n raster.font = metrics.font;\n raster.textBaseline = \"alphabetic\";\n raster.textAlign = \"left\";\n raster.fillStyle = \"white\";\n // One transform per font style, not per glyph: borders remain font outlines,\n // and graphemes are clipped to their server-owned span without individual stretching.\n raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline);\n raster.fillText(cell.text, 0, 0);\n const pixels = raster.getImageData(0, 0, width, height);\n let colored = false;\n for (let i = 0; i < pixels.data.length; i += 4) {\n if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) {\n colored = true;\n break;\n }\n }\n this.device.queue.writeTexture(\n { texture: this.atlas.texture, origin: [x, y] },\n pixels.data,\n { bytesPerRow: width * 4, rowsPerImage: height },\n [width, height],\n );\n this.glyphUploadBytes += width * height * 4;\n this.glyphs.set(key, {\n colored,\n u0: (x + 2) / this.atlas.width,\n v0: (y + 2) / this.atlas.height,\n u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width,\n v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height,\n });\n this.glyphKeyUnits += key.length;\n }\n\n /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */\n quad(resource: TextureResource, x: number, y: number, width: number, height: number,\n color: Vector4, mode = 0, uv: Vector4 = [0, 0, 1, 1],\n clip: Vector4 = [0, 0, this.width, this.height]): void {\n if (width <= 0 || height <= 0 || color[3] <= 0) return;\n const left = Math.max(0, x, clip[0]);\n const top = Math.max(0, y, clip[1]);\n const right = Math.min(this.width, x + width, clip[0] + clip[2]);\n const bottom = Math.min(this.height, y + height, clip[1] + clip[3]);\n if (right <= left || bottom <= top) return;\n if (this.quadCount >= MAX_QUADS) throw new Error(\"Frame exceeds bounded quad budget\");\n const offset = this.quadCount * STRIDE;\n if (offset + STRIDE > this.instances.length) {\n const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE));\n grown.set(this.instances);\n this.instances = grown;\n }\n const du = uv[2] - uv[0];\n const dv = uv[3] - uv[1];\n this.instances.set([\n left, top, right - left, bottom - top,\n uv[0] + (left - x) / width * du,\n uv[1] + (top - y) / height * dv,\n uv[0] + (right - x) / width * du,\n uv[1] + (bottom - y) / height * dv,\n ...color, mode, 0, 0, 0,\n ], offset);\n const last = this.batches[this.batches.length - 1];\n if (last?.resource === resource) last.count++;\n else this.batches.push({ resource, start: this.quadCount, count: 1 });\n this.quadCount++;\n }\n\n solid(x: number, y: number, width: number, height: number, color: Vector4): void {\n this.quad(this.atlas, x, y, width, height, color);\n }\n\n placement(placement: ImagePlacement): void {\n const image = this.images.get(placement.key);\n if (!image) throw new Error(`Placement texture is missing: ${placement.key}`);\n const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement;\n if (!sw || !sh || !placement.width || !placement.height) return;\n // Clip out-of-texture source regions in destination space instead of stretching edge texels.\n const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width;\n const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height;\n const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width;\n const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height;\n const left = Math.max(sourceLeft, placement.clipX);\n const top = Math.max(sourceTop, placement.clipY);\n const right = Math.min(sourceRight, placement.clipX + placement.clipWidth);\n const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight);\n this.quad(\n image, placement.x, placement.y, placement.width, placement.height, WHITE, 2,\n [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height],\n [left, top, Math.max(0, right - left), Math.max(0, bottom - top)],\n );\n }\n\n decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void {\n if (cell.attributes & 128) this.solid(x, y + 10, width, 1, foreground);\n if (cell.attributes & 256) this.solid(x, y + 1, width, 1, foreground);\n const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0);\n const color = rgba(cell.underlineColor);\n if (style === 1) this.solid(x, y + 18, width, 1, color);\n else if (style === 2) {\n this.solid(x, y + 16, width, 1, color);\n this.solid(x, y + 18, width, 1, color);\n } else if (style === 3) {\n for (let dx = 0; dx < width; dx++) {\n this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color);\n }\n } else if (style === 4 || style === 5) {\n const step = style === 4 ? 2 : 5;\n const segment = style === 4 ? 1 : 3;\n for (let dx = 0; dx < width; dx += step) this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color);\n }\n }\n\n render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean) {\n const start = performance.now();\n this.quadCount = 0;\n this.batches = [];\n const placements = metadata.placements.map((placement, order) => ({\n placement,\n order,\n z: placement.kind === \"sixel\" ? -1 : placement.z,\n })).sort((a, b) => a.z - b.z || a.order - b.order);\n for (const item of placements) if (item.z < -1073741824) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell) continue;\n this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background));\n }\n for (const item of placements) if (item.z >= -1073741824 && item.z < 0) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn)) continue;\n const x = (i % this.columns) * CELL_WIDTH;\n const y = Math.floor(i / this.columns) * CELL_HEIGHT;\n const width = Math.min(cell.width * CELL_WIDTH, this.width - x);\n const foreground = rgba(cell.foreground);\n const glyph = this.glyphs.get(glyphKey(cell));\n if (glyph) {\n const tint: Vector4 = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground;\n this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT,\n tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]);\n }\n // Reverse and dim are already reflected in server-projected colors.\n this.decorations(cell, x, y, width, foreground);\n }\n for (const item of placements) if (item.z >= 0) this.placement(item.placement);\n const cursor = metadata.cursor;\n const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1;\n if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) {\n const cell = cells[cursor.y * this.columns + cursor.x];\n const color = rgba(cell?.foreground ?? 0xffffffff);\n const x = cursor.x * CELL_WIDTH;\n const y = cursor.y * CELL_HEIGHT;\n if (cursor.shape === 3 || cursor.shape === 4) this.solid(x, y + 18, CELL_WIDTH, 2, color);\n else if (cursor.shape === 5 || cursor.shape === 6) this.solid(x, y, 2, CELL_HEIGHT, color);\n else {\n color[3] *= 0.55;\n this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color);\n }\n }\n const usedBytes = this.quadCount * STRIDE * 4;\n if (!this.instanceBuffer || usedBytes > this.instanceBufferBytes) {\n this.instanceBuffer?.destroy();\n this.instanceBufferBytes = Math.max(256, this.instances.byteLength);\n this.instanceBuffer = this.device.createBuffer({\n size: this.instanceBufferBytes,\n usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,\n });\n }\n if (usedBytes) this.device.queue.writeBuffer(this.instanceBuffer, 0, this.instances, 0, this.quadCount * STRIDE);\n const encoder = this.device.createCommandEncoder();\n const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000);\n const pass = encoder.beginRenderPass({\n colorAttachments: [{\n view: this.context.getCurrentTexture().createView(),\n clearValue: { r: base[0], g: base[1], b: base[2], a: 1 },\n loadOp: \"clear\",\n storeOp: \"store\",\n }],\n });\n pass.setPipeline(this.pipeline);\n pass.setVertexBuffer(0, this.instanceBuffer);\n for (const batch of this.batches) {\n pass.setBindGroup(0, batch.resource.bindGroup);\n pass.draw(6, batch.count, 0, batch.start);\n }\n pass.end();\n this.device.queue.submit([encoder.finish()]);\n return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length };\n }\n\n metrics() {\n return {\n fontFamily: this.font.family,\n rasterScale: this.scale,\n backingScale: this.backingScale,\n backingWidth: this.canvas.width,\n backingHeight: this.canvas.height,\n imageCount: this.images.size,\n textureBytes: this.textureBytes,\n atlasGlyphs: this.glyphs.size,\n atlasBytes: this.atlas.width * this.atlas.height * 4,\n atlasRebuilds: this.atlasRebuilds,\n imageUploadBytes: this.imageUploadBytes,\n imagePayloadBytes: this.imagePayloadBytes,\n glyphUploadBytes: this.glyphUploadBytes,\n instanceBufferBytes: this.instanceBufferBytes,\n };\n }\n\n async idle() {\n await this.device.queue.onSubmittedWorkDone();\n }\n\n dispose() {\n this.disposed = true;\n for (const image of this.images.values()) image.texture.destroy();\n this.images.clear();\n this.atlas?.texture.destroy();\n this.instanceBuffer?.destroy();\n this.uniform?.destroy();\n this.font?.dispose();\n this.fontMetrics.clear();\n this.context.unconfigure();\n this.device.destroy();\n }\n}\n"]} \ No newline at end of file +{"version":3,"file":"renderer.js","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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","sourcesContent":["import { LIMITS } from \"./protocol.js\";\nimport { loadFont, measureFont, normalizeFont } from \"./terminal-font.js\";\nimport { createRenderBackend } from \"./backend-selection.js\";\nimport { QUAD_STRIDE } from \"./render-backend.js\";\nimport type { RenderBackend, RenderBatch, RenderColor, RenderTexture } from \"./render-backend.js\";\nimport type { FontMetrics, LoadedFont, NormalizedFont } from \"./terminal-font.js\";\nimport type { TerminalFont, TerminalRendererPreference, TerminalSize } from \"./types.js\";\nimport type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from \"./wire-types.js\";\n\ntype Vector4 = RenderColor;\ntype TextureResource = RenderTexture;\ninterface Shelf { x: number; y: number; rowHeight: number }\ninterface GlyphPlacement { key: string; cell: TerminalCell; x: number; y: number; width: number; height: number }\ninterface Glyph { colored: boolean; u0: number; v0: number; u1: number; v1: number }\ntype Batch = RenderBatch;\n\nconst CELL_WIDTH = 10;\nconst CELL_HEIGHT = 20;\nconst MAX_QUADS = 1024 * 1024;\nconst MAX_GLYPHS = 16384;\nconst MAX_GLYPH_KEY_UNITS = 1024 * 1024;\nconst STRIDE = QUAD_STRIDE;\nconst WHITE: Vector4 = [1, 1, 1, 1];\n\nfunction rgba(packed: number): Vector4 {\n return [\n (packed & 255) / 255,\n ((packed >>> 8) & 255) / 255,\n ((packed >>> 16) & 255) / 255,\n ((packed >>> 24) & 255) / 255,\n ];\n}\n\nfunction glyphKey(cell: TerminalCell): string {\n return `${cell.attributes & 5}/${cell.width}/${cell.text}`;\n}\n\n/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */\nfunction packGlyphs(glyphs: ReadonlyMap, size: number, scale: number,\n initial: Shelf = { x: 0, y: 0, rowHeight: 0 }): { placements: GlyphPlacement[]; shelf: Shelf } | null {\n let { x, y, rowHeight } = initial;\n const placements = [];\n for (const [key, cell] of glyphs) {\n const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4;\n const height = Math.ceil(CELL_HEIGHT * scale) + 4;\n if (width > size || height > size) return null;\n if (x + width > size) { x = 0; y += rowHeight; rowHeight = 0; }\n if (y + height > size) return null;\n placements.push({ key, cell, x, y, width, height });\n x += width;\n rowHeight = Math.max(rowHeight, height);\n }\n return { placements, shelf: { x, y, rowHeight } };\n}\n\n/** Shared instanced-quad preparation; Canvas2D rasterizes reusable glyphs for either backend. */\nexport class TerminalRenderer {\n canvas: OffscreenCanvas;\n scale: number;\n backingScale: number;\n backend: RenderBackend;\n fallbackReason?: string;\n fontConfiguration: NormalizedFont;\n fontMetrics: Map;\n images: Map;\n glyphs: Map;\n imageUploadBytes: number;\n imagePayloadBytes: number;\n glyphUploadBytes: number;\n atlasRebuilds: number;\n textureBytes: number;\n instances: Float32Array;\n disposed: boolean;\n columns: number;\n rows: number;\n // Initialized by create() before the renderer can prepare or submit frames.\n font!: LoadedFont;\n rasterCanvas!: OffscreenCanvas;\n raster!: OffscreenCanvasRenderingContext2D;\n atlas!: TextureResource;\n glyphKeyUnits = 0;\n shelf: Shelf = { x: 0, y: 0, rowHeight: 0 };\n canvasLimited = false;\n width = 0;\n height = 0;\n quadCount = 0;\n batches: Batch[] = [];\n\n static async create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error) => void,\n font?: TerminalFont, preference: TerminalRendererPreference = \"auto\"): Promise {\n const normalizedFont = normalizeFont(font);\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n const { backend, fallbackReason } = await createRenderBackend(canvas, onFatal, preference);\n const renderer = new TerminalRenderer(canvas, scale, backend, normalizedFont);\n renderer.fallbackReason = fallbackReason;\n try {\n await renderer.initialize();\n return renderer;\n } catch (error) {\n renderer.dispose();\n throw error;\n }\n }\n\n constructor(canvas: OffscreenCanvas, scale: number, backend: RenderBackend, font: NormalizedFont) {\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n this.canvas = canvas;\n this.scale = scale;\n this.backingScale = scale;\n this.backend = backend;\n this.fontConfiguration = font;\n this.fontMetrics = new Map();\n this.images = new Map();\n this.glyphs = new Map();\n this.imageUploadBytes = 0;\n this.imagePayloadBytes = 0;\n this.glyphUploadBytes = 0;\n this.atlasRebuilds = 0;\n this.textureBytes = 0;\n this.instances = new Float32Array(4096 * STRIDE);\n this.disposed = false;\n this.columns = 0;\n this.rows = 0;\n }\n\n async initialize() {\n this.font = await loadFont(this.fontConfiguration);\n this.rasterCanvas = new OffscreenCanvas(1, 1);\n const raster = this.rasterCanvas.getContext(\"2d\", { willReadFrequently: true });\n if (!raster) throw new Error(\"Worker glyph rasterization is unavailable\");\n this.raster = raster;\n this.resetAtlas(Math.min(2048, this.backend.maxTextureDimension2D));\n }\n\n createTexture(width: number, height: number, label: string): TextureResource {\n return this.backend.createTexture(width, height, label);\n }\n\n resetAtlas(size: number): void {\n this.atlas?.destroy();\n this.atlas = this.createTexture(size, size, \"Glyph atlas\");\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.shelf = { x: 0, y: 0, rowHeight: 0 };\n }\n\n resize(columns: number, rows: number, viewport?: TerminalSize): void {\n const width = columns * CELL_WIDTH;\n const height = rows * CELL_HEIGHT;\n const limit = this.backend.maxCanvasDimension2D;\n const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity);\n this.canvasLimited = width * requested > limit || height * requested > limit;\n this.backingScale = Math.min(requested, limit / width, limit / height);\n const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale)));\n const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale)));\n if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight) return;\n this.columns = columns;\n this.rows = rows;\n this.width = width;\n this.height = height;\n this.canvas.width = backingWidth;\n this.canvas.height = backingHeight;\n this.backend.resize(width, height);\n }\n\n /** Call only between submissions. Missing/over-budget resources terminate the session. */\n async updateImages(incoming: readonly FrameImage[], retainedKeys: readonly string[]): Promise {\n const retained = new Set(retainedKeys);\n const replacements = new Map(incoming.map(image => [image.key, image]));\n let projectedBytes = 0;\n for (const key of retained) {\n const image = replacements.get(key) || this.images.get(key);\n if (!image) throw new Error(`Missing retained image resource: ${key}`);\n projectedBytes += image.width * image.height * 4;\n }\n if (projectedBytes > LIMITS.textureBytes) throw new Error(\"Retained images exceed the 256 MiB texture budget\");\n for (const [key, image] of this.images) {\n if (!retained.has(key) || replacements.has(key)) {\n image.destroy();\n this.textureBytes -= image.width * image.height * 4;\n this.images.delete(key);\n }\n }\n for (const image of incoming) {\n const resource = this.createTexture(image.width, image.height, `Image ${image.key}`);\n try {\n if (image.format === \"rgba\") {\n resource.writePixels(image.bytes, image.width, image.height);\n } else {\n // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions.\n const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength);\n if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a ||\n png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 ||\n png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) {\n throw new Error(`PNG header dimensions do not match resource ${image.key}`);\n }\n const bitmap = await createImageBitmap(new Blob([image.bytes], { type: \"image/png\" }), {\n premultiplyAlpha: \"none\",\n colorSpaceConversion: \"none\",\n });\n try {\n if (bitmap.width !== image.width || bitmap.height !== image.height) throw new Error(\"Decoded PNG dimension mismatch\");\n resource.writeBitmap(bitmap);\n } finally {\n bitmap.close();\n }\n }\n this.images.set(image.key, resource);\n this.textureBytes += image.width * image.height * 4;\n this.imageUploadBytes += image.width * image.height * 4;\n this.imagePayloadBytes += image.byteLength;\n } catch (error) {\n resource.destroy();\n throw error;\n }\n }\n }\n\n prepareGlyphs(cells: readonly (TerminalCell | undefined)[]): void {\n const visible = new Map();\n for (const cell of cells) {\n if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64)) continue;\n visible.set(glyphKey(cell), cell);\n }\n const keyUnits = (glyphs: ReadonlyMap) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0);\n if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) {\n throw new Error(\"Visible glyph metadata exceeds the bounded glyph cache\");\n }\n const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key)));\n if (!missing.size) return;\n const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS &&\n this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS;\n let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null;\n if (!plan) {\n let size = this.atlas.width;\n const maxSize = Math.min(4096, this.backend.maxTextureDimension2D);\n while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) {\n size = Math.min(size * 2, maxSize);\n }\n if (!plan) throw new Error(\"Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale\");\n this.resetAtlas(size);\n this.atlasRebuilds++;\n }\n for (const placement of plan.placements) this.uploadGlyph(placement);\n this.shelf = plan.shelf;\n }\n\n uploadGlyph({ key, cell, x, y, width, height }: GlyphPlacement): void {\n const scale = this.scale;\n const raster = this.raster;\n const style = cell.attributes & 5;\n let metrics = this.fontMetrics.get(style);\n if (!metrics) {\n metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT);\n this.fontMetrics.set(style, metrics);\n }\n this.rasterCanvas.width = width;\n this.rasterCanvas.height = height;\n raster.font = metrics.font;\n raster.textBaseline = \"alphabetic\";\n raster.textAlign = \"left\";\n raster.fillStyle = \"white\";\n // One transform per font style, not per glyph: borders remain font outlines,\n // and graphemes are clipped to their server-owned span without individual stretching.\n raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline);\n raster.fillText(cell.text, 0, 0);\n const pixels = raster.getImageData(0, 0, width, height);\n let colored = false;\n for (let i = 0; i < pixels.data.length; i += 4) {\n if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) {\n colored = true;\n break;\n }\n }\n this.atlas.writePixels(pixels.data, width, height, x, y);\n this.glyphUploadBytes += width * height * 4;\n this.glyphs.set(key, {\n colored,\n u0: (x + 2) / this.atlas.width,\n v0: (y + 2) / this.atlas.height,\n u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width,\n v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height,\n });\n this.glyphKeyUnits += key.length;\n }\n\n /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */\n quad(resource: TextureResource, x: number, y: number, width: number, height: number,\n color: Vector4, mode = 0, uv: Vector4 = [0, 0, 1, 1],\n clip: Vector4 = [0, 0, this.width, this.height]): void {\n if (width <= 0 || height <= 0 || color[3] <= 0) return;\n const left = Math.max(0, x, clip[0]);\n const top = Math.max(0, y, clip[1]);\n const right = Math.min(this.width, x + width, clip[0] + clip[2]);\n const bottom = Math.min(this.height, y + height, clip[1] + clip[3]);\n if (right <= left || bottom <= top) return;\n if (this.quadCount >= MAX_QUADS) throw new Error(\"Frame exceeds bounded quad budget\");\n const offset = this.quadCount * STRIDE;\n if (offset + STRIDE > this.instances.length) {\n const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE));\n grown.set(this.instances);\n this.instances = grown;\n }\n const du = uv[2] - uv[0];\n const dv = uv[3] - uv[1];\n this.instances.set([\n left, top, right - left, bottom - top,\n uv[0] + (left - x) / width * du,\n uv[1] + (top - y) / height * dv,\n uv[0] + (right - x) / width * du,\n uv[1] + (bottom - y) / height * dv,\n ...color, mode, 0, 0, 0,\n ], offset);\n const last = this.batches[this.batches.length - 1];\n if (last?.resource === resource) last.count++;\n else this.batches.push({ resource, start: this.quadCount, count: 1 });\n this.quadCount++;\n }\n\n solid(x: number, y: number, width: number, height: number, color: Vector4): void {\n this.quad(this.atlas, x, y, width, height, color);\n }\n\n placement(placement: ImagePlacement): void {\n const image = this.images.get(placement.key);\n if (!image) throw new Error(`Placement texture is missing: ${placement.key}`);\n const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement;\n if (!sw || !sh || !placement.width || !placement.height) return;\n // Clip out-of-texture source regions in destination space instead of stretching edge texels.\n const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width;\n const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height;\n const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width;\n const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height;\n const left = Math.max(sourceLeft, placement.clipX);\n const top = Math.max(sourceTop, placement.clipY);\n const right = Math.min(sourceRight, placement.clipX + placement.clipWidth);\n const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight);\n this.quad(\n image, placement.x, placement.y, placement.width, placement.height, WHITE, 2,\n [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height],\n [left, top, Math.max(0, right - left), Math.max(0, bottom - top)],\n );\n }\n\n decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void {\n if (cell.attributes & 128) this.solid(x, y + 10, width, 1, foreground);\n if (cell.attributes & 256) this.solid(x, y + 1, width, 1, foreground);\n const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0);\n const color = rgba(cell.underlineColor);\n if (style === 1) this.solid(x, y + 18, width, 1, color);\n else if (style === 2) {\n this.solid(x, y + 16, width, 1, color);\n this.solid(x, y + 18, width, 1, color);\n } else if (style === 3) {\n for (let dx = 0; dx < width; dx++) {\n this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color);\n }\n } else if (style === 4 || style === 5) {\n const step = style === 4 ? 2 : 5;\n const segment = style === 4 ? 1 : 3;\n for (let dx = 0; dx < width; dx += step) this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color);\n }\n }\n\n render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean) {\n const start = performance.now();\n this.quadCount = 0;\n this.batches = [];\n const placements = metadata.placements.map((placement, order) => ({\n placement,\n order,\n z: placement.kind === \"sixel\" ? -1 : placement.z,\n })).sort((a, b) => a.z - b.z || a.order - b.order);\n for (const item of placements) if (item.z < -1073741824) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell) continue;\n this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background));\n }\n for (const item of placements) if (item.z >= -1073741824 && item.z < 0) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn)) continue;\n const x = (i % this.columns) * CELL_WIDTH;\n const y = Math.floor(i / this.columns) * CELL_HEIGHT;\n const width = Math.min(cell.width * CELL_WIDTH, this.width - x);\n const foreground = rgba(cell.foreground);\n const glyph = this.glyphs.get(glyphKey(cell));\n if (glyph) {\n const tint: Vector4 = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground;\n this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT,\n tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]);\n }\n // Reverse and dim are already reflected in server-projected colors.\n this.decorations(cell, x, y, width, foreground);\n }\n for (const item of placements) if (item.z >= 0) this.placement(item.placement);\n const cursor = metadata.cursor;\n const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1;\n if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) {\n const cell = cells[cursor.y * this.columns + cursor.x];\n const color = rgba(cell?.foreground ?? 0xffffffff);\n const x = cursor.x * CELL_WIDTH;\n const y = cursor.y * CELL_HEIGHT;\n if (cursor.shape === 3 || cursor.shape === 4) this.solid(x, y + 18, CELL_WIDTH, 2, color);\n else if (cursor.shape === 5 || cursor.shape === 6) this.solid(x, y, 2, CELL_HEIGHT, color);\n else {\n color[3] *= 0.55;\n this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color);\n }\n }\n const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000);\n this.backend.submit(this.instances, this.quadCount, this.batches, base);\n return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length };\n }\n\n metrics() {\n return {\n renderer: this.backend.kind,\n rendererFallbackReason: this.fallbackReason,\n fontFamily: this.font.family,\n rasterScale: this.scale,\n backingScale: this.backingScale,\n backingWidth: this.canvas.width,\n backingHeight: this.canvas.height,\n imageCount: this.images.size,\n textureBytes: this.textureBytes,\n atlasGlyphs: this.glyphs.size,\n atlasBytes: this.atlas.width * this.atlas.height * 4,\n atlasRebuilds: this.atlasRebuilds,\n imageUploadBytes: this.imageUploadBytes,\n imagePayloadBytes: this.imagePayloadBytes,\n glyphUploadBytes: this.glyphUploadBytes,\n instanceBufferBytes: this.backend.instanceBufferBytes,\n };\n }\n\n async idle() {\n await this.backend.idle();\n }\n\n dispose() {\n if (this.disposed) return;\n this.disposed = true;\n for (const image of this.images.values()) image.destroy();\n this.images.clear();\n this.atlas?.destroy();\n this.font?.dispose();\n this.fontMetrics.clear();\n this.backend.dispose();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js index b899d1085ea..bb765c2a87c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js @@ -123,7 +123,7 @@ async function drawFrame() { type: "geometry", columns: metadata.columns, rows: metadata.rows, cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight, mouseTracking: metadata.mouseTracking, peer: metadata.peer, - history: metadata.history, revision: frame.revision, text + history: metadata.history, revision: frame.revision, text, hyperlinks: metadata.hyperlinks }); send({ type: "ack", revision: frame.revision }); emitStats(text); @@ -194,8 +194,8 @@ async function initialize(message) { if (typeof self.requestAnimationFrame !== "function") { throw new Error("This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker"); } - postStatus("Loading terminal font and initializing WebGPU..."); - renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font); + postStatus("Loading terminal font and initializing renderer..."); + renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer); if (failed || stopped) { renderer?.dispose(); return; @@ -203,7 +203,10 @@ async function initialize(message) { stats.gpu = "ready"; stats.backingScale = message.scale; emitStats(); - postStatus("WebGPU ready. Attaching terminal view..."); + const rendererName = renderer.backend.kind === "webgpu" ? "WebGPU" : "WebGL2"; + if (renderer.fallbackReason) + postStatus(`Using WebGL2: ${renderer.fallbackReason}`); + postStatus(`${rendererName} ready. Attaching terminal view...`); const url = new URL(message.url); if (!["ws:", "wss:"].includes(url.protocol)) { throw new Error("The terminal WebSocket URL must use ws: or wss:"); @@ -215,7 +218,7 @@ async function initialize(message) { return; stats.connected = true; self.postMessage({ type: "connected" }); - postStatus("Connected · WebGPU worker · server-authoritative cells and graphics", "ready"); + postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, "ready"); emitStats(); }); socket.addEventListener("message", event => { diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map index f0c88cb05ad..47b888ed8dd 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map @@ -1 +1 @@ -{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1011, \"Browser renderer failed\");\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, text\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing WebGPU...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n postStatus(\"WebGPU ready. Attaching terminal view...\");\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(\"Connected · WebGPU worker · server-authoritative cells and graphics\", \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n socket.addEventListener(\"error\", () => fail(new Error(\"WebSocket connection failed; verify the demo server is running\")));\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"disconnected\" });\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file +{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1011, \"Browser renderer failed\");\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, text, hyperlinks: metadata.hyperlinks\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n socket.addEventListener(\"error\", () => fail(new Error(\"WebSocket connection failed; verify the demo server is running\")));\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"disconnected\" });\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts index 822064d0b64..17a3ff20478 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts @@ -115,8 +115,15 @@ export type TerminalSelection = ({ copyError: string; }; export type TerminalStatusLevel = "info" | "ready" | "error"; +export type TerminalRendererKind = "webgpu" | "webgl2"; +/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */ +export type TerminalRendererPreference = "auto" | TerminalRendererKind; /** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */ export interface TerminalStats { + /** Active backend; absent until renderer initialization completes. */ + renderer?: TerminalRendererKind; + /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */ + rendererFallbackReason?: string; revision?: number; fullFrames?: number; frames?: number; @@ -266,6 +273,8 @@ export interface WebTerminalOptions extends InputPolicyOptions { workerUrl?: string | URL; signal?: AbortSignal; scale?: number | "auto"; + /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */ + renderer?: TerminalRendererPreference; font?: TerminalFont; sizing?: TerminalSizing; label?: string; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map index d317bd8da02..fa459df5618 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map index 2cfc22bf143..c52ad84c24b 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map index 47c1b7f199b..128d5a36191 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js index a69516bbf9c..ff165346f7d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js @@ -1,11 +1,13 @@ import { captureMouse } from "./mouse-input.js"; import { normalizeFont } from "./terminal-font.js"; +import { normalizeRenderer } from "./renderer-options.js"; import { dimensions, normalizeSizing, requestedGrid, fittedScale } from "./terminal-sizing.js"; import { HistoryState } from "./history-state.js"; import { terminalThemeCss } from "./terminal-theme.js"; import { InputPolicy, InputRoute, TerminalAction, inputModifiers } from "./input-policy.js"; import { assertCommandSize } from "./protocol.js"; import { SelectionUI } from "./selection-ui.js"; +import { Hyperlinks } from "./hyperlinks.js"; import { errorMessage, isRecord } from "./validation.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; function requiredElement(root, selector, type) { @@ -21,6 +23,7 @@ function requiredElement(root, selector, type) { export class WebTerminal { element; #options; + #renderer; // DOM and worker fields are initialized by mount before a handle is returned. #worker; #surface; @@ -57,6 +60,7 @@ export class WebTerminal { #selectionOverlay; #selectionUIError = ""; #canvasSize = { width: 0, height: 0 }; + #hyperlinks = new Hyperlinks(); /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */ static async mount(container, options) { if (!(container instanceof HTMLElement)) @@ -65,8 +69,9 @@ export class WebTerminal { throw new TypeError("A terminal WebSocket URL is required"); if (options.signal?.aborted) throw options.signal.reason; - if (!window.isSecureContext || !navigator.gpu) - throw new Error("WebTerminal requires WebGPU over HTTPS or localhost"); + if (normalizeRenderer(options.renderer) === "webgpu" && (!window.isSecureContext || !navigator.gpu)) { + throw new Error("The requested WebGPU renderer requires WebGPU over HTTPS or localhost"); + } if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas || !HTMLCanvasElement.prototype.transferControlToOffscreen) { throw new Error("WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas"); @@ -83,6 +88,7 @@ export class WebTerminal { } constructor(options) { this.#options = options; + this.#renderer = normalizeRenderer(options.renderer); if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) && (typeof options.workerUrl !== "string" || !options.workerUrl.trim())) throw new TypeError("workerUrl must be a nonempty URL string or URL"); @@ -209,7 +215,16 @@ export class WebTerminal { scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)), end: cancelled => this.#history.endGesture(cancelled), resolve: input => this.#resolveInput(input), - execute: (decision, input) => this.#executeInputAction(decision, input) + execute: (decision, input) => this.#executeInputAction(decision, input), + hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null, + openHyperlink: uri => { + try { + window.open(uri, "_blank", "noopener,noreferrer"); + } + catch (error) { + this.#actionFailed(error); + } + } }); this.#bindKeyboard(); requiredElement(this.#inspection, ".return-live", HTMLButtonElement).addEventListener("click", () => { @@ -250,7 +265,8 @@ export class WebTerminal { }); this.#worker.addEventListener("messageerror", () => this.#fail(new Error("Terminal worker message could not be decoded"))); const canvas = this.#canvas.transferControlToOffscreen(); - this.#post({ type: "init", canvas, url: url.href, scale, font }, [canvas]); + this.#post({ type: "init", canvas, url: url.href, scale, font, + renderer: this.#renderer }, [canvas]); } #message(message) { if (this.#disposed) @@ -283,7 +299,7 @@ export class WebTerminal { this.#input.disabled = !this.#canInput(); if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus(); - this.#mouse?.update(message.columns, message.rows, message.mouseTracking); + this.#hyperlinks.update(message.hyperlinks); if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit(); if (!this.#peer.isPrimary) { @@ -299,6 +315,7 @@ export class WebTerminal { this.#screenText = message.text; this.#history.accept(message.history, message.revision); } + this.#mouse?.update(message.columns, message.rows, message.mouseTracking); if (geometryChanged) this.#options.onGeometry?.(this.geometry); if (first) @@ -380,6 +397,7 @@ export class WebTerminal { #inspectionChanged() { const viewport = this.viewport; const selection = this.selection; + this.#mouse?.refresh(); if (selection.status === "invalidated") this.#mouse?.cancel(); if (this.#highlights) { @@ -694,6 +712,7 @@ export class WebTerminal { #disconnect() { this.#inputSerial++; this.#connected = false; + this.#hyperlinks.update([]); if (this.#input) this.#input.disabled = true; this.#mouse?.update(1, 1, 0); diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map index c07dfd661ee..6397e43f22d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (!window.isSecureContext || !navigator.gpu) throw new Error(\"WebTerminal requires WebGPU over HTTPS or localhost\");\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input)\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"disconnected\") {\n this.#disconnect();\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#connected) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#options.readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try { return this.#policy.resolve(Object.freeze(input), this.inputContext); }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#options.readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file +{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,\n openHyperlink: uri => {\n try { window.open(uri, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n }\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"disconnected\") {\n this.#disconnect();\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#connected) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#options.readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try { return this.#policy.resolve(Object.freeze(input), this.inputContext); }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#options.readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.d.ts new file mode 100644 index 00000000000..8f82556cd8b --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.d.ts @@ -0,0 +1,38 @@ +import type { RenderBackend, RenderBatch, RenderColor, RenderTexture } from "./render-backend.js"; +export declare class WebGl2Backend implements RenderBackend { + private readonly canvas; + readonly gl: WebGL2RenderingContext; + private readonly onFatal; + readonly kind = "webgl2"; + private textureLimit; + private canvasLimit; + private bufferBytes; + private disposed; + private lostError?; + private program?; + private instanceBuffer?; + private vertexArray?; + private viewport?; + private readonly shaders; + private readonly textures; + private readonly fences; + private readonly onContextLost; + static create(canvas: OffscreenCanvas, onFatal: (error: Error) => void): Promise; + private constructor(); + get maxTextureDimension2D(): number; + get maxCanvasDimension2D(): number; + get instanceBufferBytes(): number; + private initialize; + private compile; + private assertActive; + private contextLost; + private checkErrors; + createTexture(width: number, height: number, label: string): RenderTexture; + resize(width: number, height: number): void; + submit(instances: Float32Array, quadCount: number, batches: readonly RenderBatch[], background: RenderColor): void; + idle(): Promise; + private pollFence; + private settleFence; + dispose(): void; +} +//# sourceMappingURL=webgl2-backend.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.d.ts.map new file mode 100644 index 00000000000..f14d4f8c95d --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"webgl2-backend.d.ts","sourceRoot":"","sources":["../src/webgl2-backend.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAgB,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAqHhH,qBAAa,aAAc,YAAW,aAAa;IAuC7B,OAAO,CAAC,QAAQ,CAAC,MAAM;IAAmB,QAAQ,CAAC,EAAE,EAAE,sBAAsB;IAC/F,OAAO,CAAC,QAAQ,CAAC,OAAO;IAvC1B,QAAQ,CAAC,IAAI,YAAY;IACzB,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,SAAS,CAAC,CAAQ;IAC1B,OAAO,CAAC,OAAO,CAAC,CAAe;IAC/B,OAAO,CAAC,cAAc,CAAC,CAAc;IACrC,OAAO,CAAC,WAAW,CAAC,CAAyB;IAC7C,OAAO,CAAC,QAAQ,CAAC,CAAuB;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA4B;IACrD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;IAClD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAG5B;WAEW,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC;IAoBrG,OAAO;IAGP,IAAI,qBAAqB,IAAI,MAAM,CAA8B;IACjE,IAAI,oBAAoB,IAAI,MAAM,CAA6B;IAC/D,IAAI,mBAAmB,IAAI,MAAM,CAA6B;IAE9D,OAAO,CAAC,UAAU;IA2DlB,OAAO,CAAC,OAAO;IAaf,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,WAAW;IAmBnB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,aAAa;IAgC1E,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAoB3C,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,WAAW,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,WAAW,EAAE,EAC7F,UAAU,EAAE,WAAW,GAAG,IAAI;IA+C1B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB3B,OAAO,CAAC,SAAS;IA4BjB,OAAO,CAAC,WAAW;IAQnB,OAAO,IAAI,IAAI;CAkBhB"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.js new file mode 100644 index 00000000000..80f37283405 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.js @@ -0,0 +1,456 @@ +import { QUAD_STRIDE, RendererUnavailableError } from "./render-backend.js"; +const vertexShader = /* glsl */ `#version 300 es +precision highp float; +precision highp int; + +layout(location = 0) in vec4 rect; +layout(location = 1) in vec4 uvRect; +layout(location = 2) in vec4 color; +layout(location = 3) in float mode; +uniform vec2 viewport; +out vec2 fragmentUv; +out vec4 fragmentTint; +flat out float fragmentMode; + +void main() { + const vec2 corners[6] = vec2[6]( + vec2(0, 0), vec2(1, 0), vec2(0, 1), + vec2(0, 1), vec2(1, 0), vec2(1, 1) + ); + vec2 corner = corners[gl_VertexID]; + vec2 position = rect.xy + corner * rect.zw; + gl_Position = vec4(position / viewport * vec2(2, -2) + vec2(-1, 1), 0, 1); + fragmentUv = mix(uvRect.xy, uvRect.zw, corner); + fragmentTint = color; + fragmentMode = mode; +}`; +const fragmentShader = /* glsl */ `#version 300 es +precision highp float; +precision highp int; + +uniform highp sampler2D image; +in vec2 fragmentUv; +in vec4 fragmentTint; +flat in float fragmentMode; +out vec4 fragmentColor; + +void main() { + vec4 texel = textureLod(image, fragmentUv, 0.0); + if (fragmentMode < 0.5) { + fragmentColor = fragmentTint; + } else if (fragmentMode < 1.5) { + fragmentColor = vec4(fragmentTint.rgb, fragmentTint.a * texel.a); + } else { + fragmentColor = texel * fragmentTint; + } +}`; +function errorFrom(value) { + return value instanceof Error ? value : new Error(String(value)); +} +function positiveInteger(value) { + return Number.isSafeInteger(value) && value > 0; +} +function prepareUpload(gl) { + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE); + gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0); + gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, 0); + gl.pixelStorei(gl.UNPACK_SKIP_ROWS, 0); +} +class WebGl2Texture { + owner; + gl; + texture; + width; + height; + assertActive; + release; + destroyed = false; + constructor(owner, gl, texture, width, height, assertActive, release) { + this.owner = owner; + this.gl = gl; + this.texture = texture; + this.width = width; + this.height = height; + this.assertActive = assertActive; + this.release = release; + } + bind() { + this.assertActive(); + if (this.destroyed) + throw new Error("WebGL2 texture is destroyed"); + this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture); + prepareUpload(this.gl); + } + writePixels(pixels, width, height, x = 0, y = 0) { + if (!positiveInteger(width) || !positiveInteger(height) || + !Number.isSafeInteger(x) || !Number.isSafeInteger(y) || x < 0 || y < 0 || + x + width > this.width || y + height > this.height || pixels.byteLength < width * height * 4) { + throw new Error("Invalid WebGL2 texture upload dimensions or pixel data"); + } + this.bind(); + this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, x, y, width, height, this.gl.RGBA, this.gl.UNSIGNED_BYTE, pixels); + } + writeBitmap(bitmap) { + if (bitmap.width !== this.width || bitmap.height !== this.height) { + throw new Error("WebGL2 bitmap dimensions do not match the texture"); + } + this.bind(); + // ImageBitmap ignores unpack conversion flags; the shared decoder supplies straight-alpha, + // unconverted, top-down bitmaps. Raw RGBA uploads use the explicit unpack state above. + this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, 0, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, bitmap); + } + destroy() { + if (this.destroyed) + return; + this.destroyed = true; + this.release(this); + this.gl.deleteTexture(this.texture); + } +} +export class WebGl2Backend { + canvas; + gl; + onFatal; + kind = "webgl2"; + textureLimit = 0; + canvasLimit = 0; + bufferBytes = 0; + disposed = false; + lostError; + program; + instanceBuffer; + vertexArray; + viewport; + shaders = new Set(); + textures = new Set(); + fences = new Set(); + onContextLost = (event) => { + const message = event.statusMessage; + this.contextLost(new Error(`WebGL2 context lost${message ? `: ${message}` : ""}`)); + }; + static async create(canvas, onFatal) { + const gl = canvas.getContext("webgl2", { + alpha: false, + antialias: false, + depth: false, + stencil: false, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + }); + if (!gl) + throw new RendererUnavailableError("Could not create an OffscreenCanvas WebGL2 context"); + const backend = new WebGl2Backend(canvas, gl, onFatal); + try { + backend.initialize(); + return backend; + } + catch (error) { + backend.dispose(); + throw error; + } + } + constructor(canvas, gl, onFatal) { + this.canvas = canvas; + this.gl = gl; + this.onFatal = onFatal; + } + get maxTextureDimension2D() { return this.textureLimit; } + get maxCanvasDimension2D() { return this.canvasLimit; } + get instanceBufferBytes() { return this.bufferBytes; } + initialize() { + const gl = this.gl; + this.canvas.addEventListener("webglcontextlost", this.onContextLost); + this.checkErrors("initialization"); + this.textureLimit = gl.getParameter(gl.MAX_TEXTURE_SIZE); + const viewportLimit = gl.getParameter(gl.MAX_VIEWPORT_DIMS); + this.canvasLimit = Math.min(this.textureLimit, gl.getParameter(gl.MAX_RENDERBUFFER_SIZE), viewportLimit[0], viewportLimit[1]); + if (!positiveInteger(this.textureLimit) || !positiveInteger(this.canvasLimit)) { + throw new Error("Invalid WebGL2 texture or viewport limits"); + } + const vertex = this.compile(gl.VERTEX_SHADER, vertexShader); + const fragment = this.compile(gl.FRAGMENT_SHADER, fragmentShader); + const program = gl.createProgram(); + if (!program) + throw new Error("Could not allocate a WebGL2 shader program"); + this.program = program; + gl.attachShader(program, vertex); + gl.attachShader(program, fragment); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`WebGL2 shader link failed: ${gl.getProgramInfoLog(program) || "No diagnostic available"}`); + } + for (const shader of this.shaders) { + gl.detachShader(program, shader); + gl.deleteShader(shader); + } + this.shaders.clear(); + const viewport = gl.getUniformLocation(program, "viewport"); + const image = gl.getUniformLocation(program, "image"); + if (viewport === null || image === null) + throw new Error("WebGL2 shader uniforms are unavailable"); + this.viewport = viewport; + const buffer = gl.createBuffer(); + if (!buffer) + throw new Error("Could not allocate a WebGL2 instance buffer"); + this.instanceBuffer = buffer; + const vertexArray = gl.createVertexArray(); + if (!vertexArray) + throw new Error("Could not allocate a WebGL2 vertex array"); + this.vertexArray = vertexArray; + gl.useProgram(program); + gl.uniform1i(image, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindVertexArray(vertexArray); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + for (let attribute = 0; attribute < 4; attribute++) { + gl.enableVertexAttribArray(attribute); + gl.vertexAttribDivisor(attribute, 1); + } + gl.enable(gl.BLEND); + gl.blendEquationSeparate(gl.FUNC_ADD, gl.FUNC_ADD); + gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.STENCIL_TEST); + gl.disable(gl.CULL_FACE); + gl.disable(gl.SCISSOR_TEST); + gl.disable(gl.DITHER); + this.resize(this.canvas.width, this.canvas.height); + } + compile(type, source) { + const gl = this.gl; + const shader = gl.createShader(type); + if (!shader) + throw new Error("Could not allocate a WebGL2 shader"); + this.shaders.add(shader); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(`WebGL2 shader compilation failed: ${gl.getShaderInfoLog(shader) || "No diagnostic available"}`); + } + return shader; + } + assertActive() { + if (this.disposed) + throw new Error("WebGL2 backend is disposed"); + if (this.lostError) + throw this.lostError; + } + contextLost(error) { + if (this.disposed || this.lostError) + return; + this.lostError = error; + for (const fence of this.fences) + this.settleFence(fence, error); + this.onFatal(error); + } + checkErrors(operation) { + this.assertActive(); + const gl = this.gl; + const code = gl.getError(); + if (code === gl.CONTEXT_LOST_WEBGL || gl.isContextLost()) { + const error = new Error("WebGL2 context lost"); + this.contextLost(error); + throw error; + } + if (code !== gl.NO_ERROR) { + const name = code === gl.OUT_OF_MEMORY ? "OUT_OF_MEMORY" : + code === gl.INVALID_ENUM ? "INVALID_ENUM" : + code === gl.INVALID_VALUE ? "INVALID_VALUE" : + code === gl.INVALID_OPERATION ? "INVALID_OPERATION" : + code === gl.INVALID_FRAMEBUFFER_OPERATION ? "INVALID_FRAMEBUFFER_OPERATION" : `0x${code.toString(16)}`; + throw new Error(`WebGL2 ${operation} failed: ${name}`); + } + } + createTexture(width, height, label) { + this.assertActive(); + if (!positiveInteger(width) || !positiveInteger(height) || + width > this.textureLimit || height > this.textureLimit) { + throw new Error(`${label} has invalid dimensions or exceeds the WebGL2 texture dimension limit`); + } + const gl = this.gl; + const texture = gl.createTexture(); + if (!texture) { + this.checkErrors("texture allocation"); + throw new Error(`Could not allocate WebGL2 texture: ${label}`); + } + try { + gl.bindTexture(gl.TEXTURE_2D, texture); + prepareUpload(gl); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + // WebGL guarantees zero initialization for null data, including untouched atlas padding. + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + this.checkErrors(`texture allocation (${label})`); + const resource = new WebGl2Texture(this, gl, texture, width, height, () => this.assertActive(), resource => this.textures.delete(resource)); + this.textures.add(resource); + return resource; + } + catch (error) { + gl.deleteTexture(texture); + throw error; + } + } + resize(width, height) { + this.assertActive(); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error("Invalid WebGL2 logical viewport dimensions"); + } + if (!positiveInteger(this.canvas.width) || !positiveInteger(this.canvas.height) || + this.canvas.width > this.canvasLimit || this.canvas.height > this.canvasLimit) { + throw new Error("Canvas exceeds the WebGL2 drawing buffer dimension limit"); + } + const gl = this.gl; + if (gl.drawingBufferWidth !== this.canvas.width || gl.drawingBufferHeight !== this.canvas.height) { + this.checkErrors("drawing buffer resize"); + throw new Error("WebGL2 could not allocate the requested canvas drawing buffer"); + } + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.useProgram(this.program); + gl.uniform2f(this.viewport, width, height); + this.checkErrors("resize"); + } + submit(instances, quadCount, batches, background) { + this.assertActive(); + if (!Number.isSafeInteger(quadCount) || quadCount < 0 || quadCount * QUAD_STRIDE > instances.length) { + throw new Error("Invalid WebGL2 instance data"); + } + for (const batch of batches) { + if (!(batch.resource instanceof WebGl2Texture) || batch.resource.owner !== this) { + throw new Error("Texture belongs to a different rendering backend"); + } + if (batch.resource.destroyed) + throw new Error("WebGL2 texture is destroyed"); + if (!Number.isSafeInteger(batch.start) || !Number.isSafeInteger(batch.count) || + batch.start < 0 || batch.count < 0 || batch.start + batch.count > quadCount) { + throw new Error("Invalid WebGL2 batch instance range"); + } + } + const gl = this.gl; + const strideBytes = QUAD_STRIDE * Float32Array.BYTES_PER_ELEMENT; + const usedBytes = quadCount * strideBytes; + gl.bindVertexArray(this.vertexArray); + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer); + if (!this.bufferBytes || usedBytes > this.bufferBytes) { + const bytes = Math.max(256, instances.byteLength); + gl.bufferData(gl.ARRAY_BUFFER, bytes, gl.DYNAMIC_DRAW); + this.checkErrors("instance buffer allocation"); + this.bufferBytes = bytes; + } + if (usedBytes) + gl.bufferSubData(gl.ARRAY_BUFFER, 0, instances, 0, quadCount * QUAD_STRIDE); + gl.useProgram(this.program); + gl.activeTexture(gl.TEXTURE0); + gl.clearColor(background[0], background[1], background[2], 1); + gl.clear(gl.COLOR_BUFFER_BIT); + for (const batch of batches) { + if (!batch.count) + continue; + const resource = batch.resource; + gl.bindTexture(gl.TEXTURE_2D, resource.texture); + // WebGL2 has no baseInstance; rebase all per-instance attributes for each ordered batch. + const offset = batch.start * strideBytes; + gl.vertexAttribPointer(0, 4, gl.FLOAT, false, strideBytes, offset); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, strideBytes, offset + 16); + gl.vertexAttribPointer(2, 4, gl.FLOAT, false, strideBytes, offset + 32); + gl.vertexAttribPointer(3, 1, gl.FLOAT, false, strideBytes, offset + 48); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, batch.count); + } + // Upload errors are checked once per submission, not once per glyph. + this.checkErrors("frame submission"); + } + async idle() { + this.checkErrors("GPU completion"); + const gl = this.gl; + const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + if (!sync) { + this.checkErrors("GPU fence allocation"); + throw new Error("Could not allocate a WebGL2 GPU fence"); + } + return new Promise((resolve, reject) => { + const fence = { sync, resolve, reject }; + this.fences.add(fence); + try { + gl.flush(); + this.checkErrors("GPU fence submission"); + this.pollFence(fence); + } + catch (error) { + this.settleFence(fence, errorFrom(error)); + } + }); + } + pollFence(fence) { + if (!this.fences.has(fence)) + return; + try { + this.assertActive(); + const gl = this.gl; + if (gl.isContextLost()) { + const error = new Error("WebGL2 context lost"); + this.contextLost(error); + throw error; + } + const status = gl.clientWaitSync(fence.sync, 0, 0); + if (status === gl.ALREADY_SIGNALED || status === gl.CONDITION_SATISFIED) { + this.checkErrors("GPU completion"); + this.settleFence(fence); + } + else if (status === gl.TIMEOUT_EXPIRED) { + fence.timer = setTimeout(() => { + fence.timer = undefined; + this.pollFence(fence); + }, 0); + } + else { + this.checkErrors("GPU fence wait"); + throw new Error("WebGL2 GPU fence wait failed"); + } + } + catch (error) { + this.settleFence(fence, errorFrom(error)); + } + } + settleFence(fence, error) { + if (!this.fences.delete(fence)) + return; + if (fence.timer !== undefined) + clearTimeout(fence.timer); + this.gl.deleteSync(fence.sync); + if (error) + fence.reject(error); + else + fence.resolve(); + } + dispose() { + if (this.disposed) + return; + this.disposed = true; + this.canvas.removeEventListener("webglcontextlost", this.onContextLost); + for (const fence of this.fences) + this.settleFence(fence, new Error("WebGL2 backend is disposed")); + for (const texture of this.textures) + texture.destroy(); + const gl = this.gl; + if (this.instanceBuffer) + gl.deleteBuffer(this.instanceBuffer); + if (this.vertexArray) + gl.deleteVertexArray(this.vertexArray); + if (this.program) + gl.deleteProgram(this.program); + for (const shader of this.shaders) + gl.deleteShader(shader); + this.shaders.clear(); + this.instanceBuffer = undefined; + this.vertexArray = undefined; + this.program = undefined; + this.bufferBytes = 0; + if (!gl.isContextLost()) + gl.getExtension("WEBGL_lose_context")?.loseContext(); + } +} +//# sourceMappingURL=webgl2-backend.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.js.map new file mode 100644 index 00000000000..c581d7fa752 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgl2-backend.js.map @@ -0,0 +1 @@ +{"version":3,"file":"webgl2-backend.js","sourceRoot":"","sources":["../src/webgl2-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { QUAD_STRIDE, RendererUnavailableError } from \"./render-backend.js\";\nimport type { RenderBackend, RenderBatch, RenderColor, RenderPixels, RenderTexture } from \"./render-backend.js\";\n\nconst vertexShader = /* glsl */ `#version 300 es\nprecision highp float;\nprecision highp int;\n\nlayout(location = 0) in vec4 rect;\nlayout(location = 1) in vec4 uvRect;\nlayout(location = 2) in vec4 color;\nlayout(location = 3) in float mode;\nuniform vec2 viewport;\nout vec2 fragmentUv;\nout vec4 fragmentTint;\nflat out float fragmentMode;\n\nvoid main() {\n const vec2 corners[6] = vec2[6](\n vec2(0, 0), vec2(1, 0), vec2(0, 1),\n vec2(0, 1), vec2(1, 0), vec2(1, 1)\n );\n vec2 corner = corners[gl_VertexID];\n vec2 position = rect.xy + corner * rect.zw;\n gl_Position = vec4(position / viewport * vec2(2, -2) + vec2(-1, 1), 0, 1);\n fragmentUv = mix(uvRect.xy, uvRect.zw, corner);\n fragmentTint = color;\n fragmentMode = mode;\n}`;\n\nconst fragmentShader = /* glsl */ `#version 300 es\nprecision highp float;\nprecision highp int;\n\nuniform highp sampler2D image;\nin vec2 fragmentUv;\nin vec4 fragmentTint;\nflat in float fragmentMode;\nout vec4 fragmentColor;\n\nvoid main() {\n vec4 texel = textureLod(image, fragmentUv, 0.0);\n if (fragmentMode < 0.5) {\n fragmentColor = fragmentTint;\n } else if (fragmentMode < 1.5) {\n fragmentColor = vec4(fragmentTint.rgb, fragmentTint.a * texel.a);\n } else {\n fragmentColor = texel * fragmentTint;\n }\n}`;\n\nfunction errorFrom(value: unknown): Error {\n return value instanceof Error ? value : new Error(String(value));\n}\n\nfunction positiveInteger(value: number): boolean {\n return Number.isSafeInteger(value) && value > 0;\n}\n\nfunction prepareUpload(gl: WebGL2RenderingContext): void {\n gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);\n gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);\n gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);\n gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);\n gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, 0);\n gl.pixelStorei(gl.UNPACK_SKIP_ROWS, 0);\n}\n\nclass WebGl2Texture implements RenderTexture {\n destroyed = false;\n\n constructor(readonly owner: WebGl2Backend, private readonly gl: WebGL2RenderingContext,\n readonly texture: WebGLTexture, readonly width: number, readonly height: number,\n private readonly assertActive: () => void, private readonly release: (texture: WebGl2Texture) => void) {}\n\n private bind(): void {\n this.assertActive();\n if (this.destroyed) throw new Error(\"WebGL2 texture is destroyed\");\n this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);\n prepareUpload(this.gl);\n }\n\n writePixels(pixels: RenderPixels, width: number, height: number, x = 0, y = 0): void {\n if (!positiveInteger(width) || !positiveInteger(height) ||\n !Number.isSafeInteger(x) || !Number.isSafeInteger(y) || x < 0 || y < 0 ||\n x + width > this.width || y + height > this.height || pixels.byteLength < width * height * 4) {\n throw new Error(\"Invalid WebGL2 texture upload dimensions or pixel data\");\n }\n this.bind();\n this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, x, y, width, height,\n this.gl.RGBA, this.gl.UNSIGNED_BYTE, pixels);\n }\n\n writeBitmap(bitmap: ImageBitmap): void {\n if (bitmap.width !== this.width || bitmap.height !== this.height) {\n throw new Error(\"WebGL2 bitmap dimensions do not match the texture\");\n }\n this.bind();\n // ImageBitmap ignores unpack conversion flags; the shared decoder supplies straight-alpha,\n // unconverted, top-down bitmaps. Raw RGBA uploads use the explicit unpack state above.\n this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, 0, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, bitmap);\n }\n\n destroy(): void {\n if (this.destroyed) return;\n this.destroyed = true;\n this.release(this);\n this.gl.deleteTexture(this.texture);\n }\n}\n\ninterface PendingFence {\n sync: WebGLSync;\n timer?: ReturnType;\n resolve: () => void;\n reject: (error: Error) => void;\n}\n\nexport class WebGl2Backend implements RenderBackend {\n readonly kind = \"webgl2\";\n private textureLimit = 0;\n private canvasLimit = 0;\n private bufferBytes = 0;\n private disposed = false;\n private lostError?: Error;\n private program?: WebGLProgram;\n private instanceBuffer?: WebGLBuffer;\n private vertexArray?: WebGLVertexArrayObject;\n private viewport?: WebGLUniformLocation;\n private readonly shaders = new Set();\n private readonly textures = new Set();\n private readonly fences = new Set();\n private readonly onContextLost = (event: Event): void => {\n const message = (event as WebGLContextEvent).statusMessage;\n this.contextLost(new Error(`WebGL2 context lost${message ? `: ${message}` : \"\"}`));\n };\n\n static async create(canvas: OffscreenCanvas, onFatal: (error: Error) => void): Promise {\n const gl = canvas.getContext(\"webgl2\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n premultipliedAlpha: false,\n preserveDrawingBuffer: false,\n });\n if (!gl) throw new RendererUnavailableError(\"Could not create an OffscreenCanvas WebGL2 context\");\n const backend = new WebGl2Backend(canvas, gl, onFatal);\n try {\n backend.initialize();\n return backend;\n } catch (error) {\n backend.dispose();\n throw error;\n }\n }\n\n private constructor(private readonly canvas: OffscreenCanvas, readonly gl: WebGL2RenderingContext,\n private readonly onFatal: (error: Error) => void) {}\n\n get maxTextureDimension2D(): number { return this.textureLimit; }\n get maxCanvasDimension2D(): number { return this.canvasLimit; }\n get instanceBufferBytes(): number { return this.bufferBytes; }\n\n private initialize(): void {\n const gl = this.gl;\n this.canvas.addEventListener(\"webglcontextlost\", this.onContextLost);\n this.checkErrors(\"initialization\");\n this.textureLimit = gl.getParameter(gl.MAX_TEXTURE_SIZE);\n const viewportLimit = gl.getParameter(gl.MAX_VIEWPORT_DIMS) as Int32Array;\n this.canvasLimit = Math.min(this.textureLimit, gl.getParameter(gl.MAX_RENDERBUFFER_SIZE),\n viewportLimit[0], viewportLimit[1]);\n if (!positiveInteger(this.textureLimit) || !positiveInteger(this.canvasLimit)) {\n throw new Error(\"Invalid WebGL2 texture or viewport limits\");\n }\n\n const vertex = this.compile(gl.VERTEX_SHADER, vertexShader);\n const fragment = this.compile(gl.FRAGMENT_SHADER, fragmentShader);\n const program = gl.createProgram();\n if (!program) throw new Error(\"Could not allocate a WebGL2 shader program\");\n this.program = program;\n gl.attachShader(program, vertex);\n gl.attachShader(program, fragment);\n gl.linkProgram(program);\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n throw new Error(`WebGL2 shader link failed: ${gl.getProgramInfoLog(program) || \"No diagnostic available\"}`);\n }\n for (const shader of this.shaders) {\n gl.detachShader(program, shader);\n gl.deleteShader(shader);\n }\n this.shaders.clear();\n const viewport = gl.getUniformLocation(program, \"viewport\");\n const image = gl.getUniformLocation(program, \"image\");\n if (viewport === null || image === null) throw new Error(\"WebGL2 shader uniforms are unavailable\");\n this.viewport = viewport;\n const buffer = gl.createBuffer();\n if (!buffer) throw new Error(\"Could not allocate a WebGL2 instance buffer\");\n this.instanceBuffer = buffer;\n const vertexArray = gl.createVertexArray();\n if (!vertexArray) throw new Error(\"Could not allocate a WebGL2 vertex array\");\n this.vertexArray = vertexArray;\n\n gl.useProgram(program);\n gl.uniform1i(image, 0);\n gl.activeTexture(gl.TEXTURE0);\n gl.bindVertexArray(vertexArray);\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n for (let attribute = 0; attribute < 4; attribute++) {\n gl.enableVertexAttribArray(attribute);\n gl.vertexAttribDivisor(attribute, 1);\n }\n gl.enable(gl.BLEND);\n gl.blendEquationSeparate(gl.FUNC_ADD, gl.FUNC_ADD);\n gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n gl.disable(gl.DEPTH_TEST);\n gl.disable(gl.STENCIL_TEST);\n gl.disable(gl.CULL_FACE);\n gl.disable(gl.SCISSOR_TEST);\n gl.disable(gl.DITHER);\n this.resize(this.canvas.width, this.canvas.height);\n }\n\n private compile(type: number, source: string): WebGLShader {\n const gl = this.gl;\n const shader = gl.createShader(type);\n if (!shader) throw new Error(\"Could not allocate a WebGL2 shader\");\n this.shaders.add(shader);\n gl.shaderSource(shader, source);\n gl.compileShader(shader);\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n throw new Error(`WebGL2 shader compilation failed: ${gl.getShaderInfoLog(shader) || \"No diagnostic available\"}`);\n }\n return shader;\n }\n\n private assertActive(): void {\n if (this.disposed) throw new Error(\"WebGL2 backend is disposed\");\n if (this.lostError) throw this.lostError;\n }\n\n private contextLost(error: Error): void {\n if (this.disposed || this.lostError) return;\n this.lostError = error;\n for (const fence of this.fences) this.settleFence(fence, error);\n this.onFatal(error);\n }\n\n private checkErrors(operation: string): void {\n this.assertActive();\n const gl = this.gl;\n const code = gl.getError();\n if (code === gl.CONTEXT_LOST_WEBGL || gl.isContextLost()) {\n const error = new Error(\"WebGL2 context lost\");\n this.contextLost(error);\n throw error;\n }\n if (code !== gl.NO_ERROR) {\n const name = code === gl.OUT_OF_MEMORY ? \"OUT_OF_MEMORY\" :\n code === gl.INVALID_ENUM ? \"INVALID_ENUM\" :\n code === gl.INVALID_VALUE ? \"INVALID_VALUE\" :\n code === gl.INVALID_OPERATION ? \"INVALID_OPERATION\" :\n code === gl.INVALID_FRAMEBUFFER_OPERATION ? \"INVALID_FRAMEBUFFER_OPERATION\" : `0x${code.toString(16)}`;\n throw new Error(`WebGL2 ${operation} failed: ${name}`);\n }\n }\n\n createTexture(width: number, height: number, label: string): RenderTexture {\n this.assertActive();\n if (!positiveInteger(width) || !positiveInteger(height) ||\n width > this.textureLimit || height > this.textureLimit) {\n throw new Error(`${label} has invalid dimensions or exceeds the WebGL2 texture dimension limit`);\n }\n const gl = this.gl;\n const texture = gl.createTexture();\n if (!texture) {\n this.checkErrors(\"texture allocation\");\n throw new Error(`Could not allocate WebGL2 texture: ${label}`);\n }\n try {\n gl.bindTexture(gl.TEXTURE_2D, texture);\n prepareUpload(gl);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n // WebGL guarantees zero initialization for null data, including untouched atlas padding.\n gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);\n this.checkErrors(`texture allocation (${label})`);\n const resource = new WebGl2Texture(this, gl, texture, width, height,\n () => this.assertActive(), resource => this.textures.delete(resource));\n this.textures.add(resource);\n return resource;\n } catch (error) {\n gl.deleteTexture(texture);\n throw error;\n }\n }\n\n resize(width: number, height: number): void {\n this.assertActive();\n if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {\n throw new Error(\"Invalid WebGL2 logical viewport dimensions\");\n }\n if (!positiveInteger(this.canvas.width) || !positiveInteger(this.canvas.height) ||\n this.canvas.width > this.canvasLimit || this.canvas.height > this.canvasLimit) {\n throw new Error(\"Canvas exceeds the WebGL2 drawing buffer dimension limit\");\n }\n const gl = this.gl;\n if (gl.drawingBufferWidth !== this.canvas.width || gl.drawingBufferHeight !== this.canvas.height) {\n this.checkErrors(\"drawing buffer resize\");\n throw new Error(\"WebGL2 could not allocate the requested canvas drawing buffer\");\n }\n gl.viewport(0, 0, this.canvas.width, this.canvas.height);\n gl.useProgram(this.program!);\n gl.uniform2f(this.viewport!, width, height);\n this.checkErrors(\"resize\");\n }\n\n submit(instances: Float32Array, quadCount: number, batches: readonly RenderBatch[],\n background: RenderColor): void {\n this.assertActive();\n if (!Number.isSafeInteger(quadCount) || quadCount < 0 || quadCount * QUAD_STRIDE > instances.length) {\n throw new Error(\"Invalid WebGL2 instance data\");\n }\n for (const batch of batches) {\n if (!(batch.resource instanceof WebGl2Texture) || batch.resource.owner !== this) {\n throw new Error(\"Texture belongs to a different rendering backend\");\n }\n if (batch.resource.destroyed) throw new Error(\"WebGL2 texture is destroyed\");\n if (!Number.isSafeInteger(batch.start) || !Number.isSafeInteger(batch.count) ||\n batch.start < 0 || batch.count < 0 || batch.start + batch.count > quadCount) {\n throw new Error(\"Invalid WebGL2 batch instance range\");\n }\n }\n const gl = this.gl;\n const strideBytes = QUAD_STRIDE * Float32Array.BYTES_PER_ELEMENT;\n const usedBytes = quadCount * strideBytes;\n gl.bindVertexArray(this.vertexArray!);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer!);\n if (!this.bufferBytes || usedBytes > this.bufferBytes) {\n const bytes = Math.max(256, instances.byteLength);\n gl.bufferData(gl.ARRAY_BUFFER, bytes, gl.DYNAMIC_DRAW);\n this.checkErrors(\"instance buffer allocation\");\n this.bufferBytes = bytes;\n }\n if (usedBytes) gl.bufferSubData(gl.ARRAY_BUFFER, 0, instances, 0, quadCount * QUAD_STRIDE);\n gl.useProgram(this.program!);\n gl.activeTexture(gl.TEXTURE0);\n gl.clearColor(background[0], background[1], background[2], 1);\n gl.clear(gl.COLOR_BUFFER_BIT);\n for (const batch of batches) {\n if (!batch.count) continue;\n const resource = batch.resource as WebGl2Texture;\n gl.bindTexture(gl.TEXTURE_2D, resource.texture);\n // WebGL2 has no baseInstance; rebase all per-instance attributes for each ordered batch.\n const offset = batch.start * strideBytes;\n gl.vertexAttribPointer(0, 4, gl.FLOAT, false, strideBytes, offset);\n gl.vertexAttribPointer(1, 4, gl.FLOAT, false, strideBytes, offset + 16);\n gl.vertexAttribPointer(2, 4, gl.FLOAT, false, strideBytes, offset + 32);\n gl.vertexAttribPointer(3, 1, gl.FLOAT, false, strideBytes, offset + 48);\n gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, batch.count);\n }\n // Upload errors are checked once per submission, not once per glyph.\n this.checkErrors(\"frame submission\");\n }\n\n async idle(): Promise {\n this.checkErrors(\"GPU completion\");\n const gl = this.gl;\n const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);\n if (!sync) {\n this.checkErrors(\"GPU fence allocation\");\n throw new Error(\"Could not allocate a WebGL2 GPU fence\");\n }\n return new Promise((resolve, reject) => {\n const fence: PendingFence = { sync, resolve, reject };\n this.fences.add(fence);\n try {\n gl.flush();\n this.checkErrors(\"GPU fence submission\");\n this.pollFence(fence);\n } catch (error) {\n this.settleFence(fence, errorFrom(error));\n }\n });\n }\n\n private pollFence(fence: PendingFence): void {\n if (!this.fences.has(fence)) return;\n try {\n this.assertActive();\n const gl = this.gl;\n if (gl.isContextLost()) {\n const error = new Error(\"WebGL2 context lost\");\n this.contextLost(error);\n throw error;\n }\n const status = gl.clientWaitSync(fence.sync, 0, 0);\n if (status === gl.ALREADY_SIGNALED || status === gl.CONDITION_SATISFIED) {\n this.checkErrors(\"GPU completion\");\n this.settleFence(fence);\n } else if (status === gl.TIMEOUT_EXPIRED) {\n fence.timer = setTimeout(() => {\n fence.timer = undefined;\n this.pollFence(fence);\n }, 0);\n } else {\n this.checkErrors(\"GPU fence wait\");\n throw new Error(\"WebGL2 GPU fence wait failed\");\n }\n } catch (error) {\n this.settleFence(fence, errorFrom(error));\n }\n }\n\n private settleFence(fence: PendingFence, error?: Error): void {\n if (!this.fences.delete(fence)) return;\n if (fence.timer !== undefined) clearTimeout(fence.timer);\n this.gl.deleteSync(fence.sync);\n if (error) fence.reject(error);\n else fence.resolve();\n }\n\n dispose(): void {\n if (this.disposed) return;\n this.disposed = true;\n this.canvas.removeEventListener(\"webglcontextlost\", this.onContextLost);\n for (const fence of this.fences) this.settleFence(fence, new Error(\"WebGL2 backend is disposed\"));\n for (const texture of this.textures) texture.destroy();\n const gl = this.gl;\n if (this.instanceBuffer) gl.deleteBuffer(this.instanceBuffer);\n if (this.vertexArray) gl.deleteVertexArray(this.vertexArray);\n if (this.program) gl.deleteProgram(this.program);\n for (const shader of this.shaders) gl.deleteShader(shader);\n this.shaders.clear();\n this.instanceBuffer = undefined;\n this.vertexArray = undefined;\n this.program = undefined;\n this.bufferBytes = 0;\n if (!gl.isContextLost()) gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.d.ts new file mode 100644 index 00000000000..d51fc964400 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.d.ts @@ -0,0 +1,24 @@ +import type { RenderBackend, RenderBatch, RenderColor, RenderTexture } from "./render-backend.js"; +export declare class WebGpuBackend implements RenderBackend { + readonly device: GPUDevice; + readonly kind = "webgpu"; + instanceBufferBytes: number; + instanceBuffer: GPUBuffer | undefined; + disposed: boolean; + context?: GPUCanvasContext; + format: GPUTextureFormat; + uniform: GPUBuffer; + sampler: GPUSampler; + pipeline?: GPURenderPipeline; + static create(canvas: OffscreenCanvas, onFatal: (error: Error) => void): Promise; + constructor(device: GPUDevice); + get maxTextureDimension2D(): number; + get maxCanvasDimension2D(): number; + initialize(canvas: OffscreenCanvas): Promise; + createTexture(width: number, height: number, label: string): RenderTexture; + resize(width: number, height: number): void; + submit(instances: Float32Array, quadCount: number, batches: readonly RenderBatch[], background: RenderColor): void; + idle(): Promise; + dispose(): void; +} +//# sourceMappingURL=webgpu-backend.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.d.ts.map new file mode 100644 index 00000000000..e5b010c906c --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"webgpu-backend.d.ts","sourceRoot":"","sources":["../src/webgpu-backend.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAgB,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA6DhH,qBAAa,aAAc,YAAW,aAAa;IA4CrC,QAAQ,CAAC,MAAM,EAAE,SAAS;IA3CtC,QAAQ,CAAC,IAAI,YAAY;IACzB,mBAAmB,SAAK;IACxB,cAAc,EAAE,SAAS,GAAG,SAAS,CAAC;IACtC,QAAQ,UAAS;IACjB,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;IACzB,OAAO,EAAE,SAAS,CAAC;IACnB,OAAO,EAAE,UAAU,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;WAEhB,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC;gBAiChF,MAAM,EAAE,SAAS;IAMtC,IAAI,qBAAqB,IAAI,MAAM,CAAqD;IACxF,IAAI,oBAAoB,IAAI,MAAM,CAAuC;IAEnE,UAAU,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCxD,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,aAAa;IAyB1E,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAI3C,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,WAAW,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,WAAW,EAAE,EAC7F,UAAU,EAAE,WAAW,GAAG,IAAI;IA8B1B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAE3B,OAAO,IAAI,IAAI;CAQhB"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.js new file mode 100644 index 00000000000..1cadfca5bc8 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.js @@ -0,0 +1,234 @@ +import { QUAD_STRIDE, RendererUnavailableError } from "./render-backend.js"; +const shader = /* wgsl */ ` +struct Viewport { size: vec2f, padding: vec2f } +@group(0) @binding(0) var viewport: Viewport; +@group(0) @binding(1) var image: texture_2d; +@group(0) @binding(2) var imageSampler: sampler; + +struct VertexOut { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, + @location(1) color: vec4f, + @location(2) @interpolate(flat) mode: f32, +} + +@vertex fn vertex( + @builtin(vertex_index) index: u32, + @location(0) rect: vec4f, + @location(1) uvRect: vec4f, + @location(2) color: vec4f, + @location(3) mode: f32, +) -> VertexOut { + let corners = array( + vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), + vec2f(0, 1), vec2f(1, 0), vec2f(1, 1) + ); + let corner = corners[index]; + let position = rect.xy + corner * rect.zw; + var out: VertexOut; + out.position = vec4f(position / viewport.size * vec2f(2, -2) + vec2f(-1, 1), 0, 1); + out.uv = mix(uvRect.xy, uvRect.zw, corner); + out.color = color; + out.mode = mode; + return out; +} + +@fragment fn fragment(in: VertexOut) -> @location(0) vec4f { + // Explicit LOD avoids derivative-uniformity requirements across solid/mask/image batches. + let texel = textureSampleLevel(image, imageSampler, in.uv, 0); + if (in.mode < 0.5) { return in.color; } + if (in.mode < 1.5) { return vec4f(in.color.rgb, in.color.a * texel.a); } + return texel * in.color; +}`; +class WebGpuTexture { + device; + texture; + bindGroup; + width; + height; + constructor(device, texture, bindGroup, width, height) { + this.device = device; + this.texture = texture; + this.bindGroup = bindGroup; + this.width = width; + this.height = height; + } + writePixels(pixels, width, height, x = 0, y = 0) { + this.device.queue.writeTexture({ texture: this.texture, origin: [x, y] }, pixels, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]); + } + writeBitmap(bitmap) { + this.device.queue.copyExternalImageToTexture({ source: bitmap }, { texture: this.texture, premultipliedAlpha: false }, [this.width, this.height]); + } + destroy() { this.texture.destroy(); } +} +export class WebGpuBackend { + device; + kind = "webgpu"; + instanceBufferBytes = 0; + instanceBuffer; + disposed = false; + context; + format; + uniform; + sampler; + pipeline; + static async create(canvas, onFatal) { + if (!globalThis.isSecureContext) + throw new RendererUnavailableError("WebGPU requires HTTPS or localhost"); + if (!navigator.gpu) + throw new RendererUnavailableError("WebGPU is unavailable in this browser worker"); + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) + throw new RendererUnavailableError("No WebGPU adapter is available"); + let device; + try { + device = await adapter.requestDevice(); + } + catch (error) { + if (error instanceof DOMException && error.name === "OperationError") { + throw new RendererUnavailableError(`Could not acquire a WebGPU device: ${error.message}`, { cause: error }); + } + throw error; + } + let backend; + try { + backend = new WebGpuBackend(device); + const active = backend; + device.lost.then(info => { + if (!active.disposed) + onFatal(new Error(`WebGPU device lost: ${info.message || info.reason}`)); + }); + device.addEventListener("uncapturederror", event => { + if (!active.disposed) + onFatal(new Error(`WebGPU error: ${event.error.message}`)); + }); + await backend.initialize(canvas); + return backend; + } + catch (error) { + if (backend) + backend.dispose(); + else + device.destroy(); + throw error; + } + } + constructor(device) { + this.device = device; + this.format = navigator.gpu.getPreferredCanvasFormat(); + this.uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + this.sampler = device.createSampler({ minFilter: "linear", magFilter: "linear" }); + } + get maxTextureDimension2D() { return this.device.limits.maxTextureDimension2D; } + get maxCanvasDimension2D() { return this.maxTextureDimension2D; } + async initialize(canvas) { + const module = this.device.createShaderModule({ code: shader }); + this.pipeline = await this.device.createRenderPipelineAsync({ + layout: "auto", + vertex: { + module, + entryPoint: "vertex", + buffers: [{ + arrayStride: QUAD_STRIDE * 4, + stepMode: "instance", + attributes: [ + { shaderLocation: 0, offset: 0, format: "float32x4" }, + { shaderLocation: 1, offset: 16, format: "float32x4" }, + { shaderLocation: 2, offset: 32, format: "float32x4" }, + { shaderLocation: 3, offset: 48, format: "float32" }, + ], + }], + }, + fragment: { + module, + entryPoint: "fragment", + targets: [{ + format: this.format, + blend: { + color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha" }, + alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }, + }, + }], + }, + primitive: { topology: "triangle-list" }, + }); + // Acquire the presentation surface last. A null context leaves it usable by WebGL2. + const context = canvas.getContext("webgpu"); + if (!context) + throw new RendererUnavailableError("Could not create an OffscreenCanvas WebGPU context"); + this.context = context; + context.configure({ device: this.device, format: this.format, alphaMode: "opaque" }); + } + createTexture(width, height, label) { + if (width > this.maxTextureDimension2D || height > this.maxTextureDimension2D) { + throw new Error(`${label} exceeds the GPU texture dimension limit`); + } + if (!this.pipeline) + throw new Error("WebGPU backend is not initialized"); + const texture = this.device.createTexture({ + label, size: [width, height], format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + }); + try { + const bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: this.uniform } }, + { binding: 1, resource: texture.createView() }, + { binding: 2, resource: this.sampler }, + ], + }); + return new WebGpuTexture(this.device, texture, bindGroup, width, height); + } + catch (error) { + texture.destroy(); + throw error; + } + } + resize(width, height) { + this.device.queue.writeBuffer(this.uniform, 0, new Float32Array([width, height, 0, 0])); + } + submit(instances, quadCount, batches, background) { + if (this.disposed || !this.context || !this.pipeline) + throw new Error("WebGPU backend is not initialized"); + const usedBytes = quadCount * QUAD_STRIDE * 4; + if (!this.instanceBuffer || usedBytes > this.instanceBufferBytes) { + this.instanceBuffer?.destroy(); + this.instanceBufferBytes = Math.max(256, instances.byteLength); + this.instanceBuffer = this.device.createBuffer({ + size: this.instanceBufferBytes, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + } + if (usedBytes) + this.device.queue.writeBuffer(this.instanceBuffer, 0, instances, 0, quadCount * QUAD_STRIDE); + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: this.context.getCurrentTexture().createView(), + clearValue: { r: background[0], g: background[1], b: background[2], a: 1 }, + loadOp: "clear", storeOp: "store", + }], + }); + pass.setPipeline(this.pipeline); + pass.setVertexBuffer(0, this.instanceBuffer); + for (const batch of batches) { + if (!(batch.resource instanceof WebGpuTexture)) + throw new Error("Texture belongs to a different rendering backend"); + pass.setBindGroup(0, batch.resource.bindGroup); + pass.draw(6, batch.count, 0, batch.start); + } + pass.end(); + this.device.queue.submit([encoder.finish()]); + } + async idle() { await this.device.queue.onSubmittedWorkDone(); } + dispose() { + if (this.disposed) + return; + this.disposed = true; + this.instanceBuffer?.destroy(); + this.uniform.destroy(); + this.context?.unconfigure(); + this.device.destroy(); + } +} +//# sourceMappingURL=webgpu-backend.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.js.map new file mode 100644 index 00000000000..71908b76108 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/webgpu-backend.js.map @@ -0,0 +1 @@ +{"version":3,"file":"webgpu-backend.js","sourceRoot":"","sources":["../src/webgpu-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { QUAD_STRIDE, RendererUnavailableError } from \"./render-backend.js\";\nimport type { RenderBackend, RenderBatch, RenderColor, RenderPixels, RenderTexture } from \"./render-backend.js\";\n\nconst shader = /* wgsl */ `\nstruct Viewport { size: vec2f, padding: vec2f }\n@group(0) @binding(0) var viewport: Viewport;\n@group(0) @binding(1) var image: texture_2d;\n@group(0) @binding(2) var imageSampler: sampler;\n\nstruct VertexOut {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n @location(1) color: vec4f,\n @location(2) @interpolate(flat) mode: f32,\n}\n\n@vertex fn vertex(\n @builtin(vertex_index) index: u32,\n @location(0) rect: vec4f,\n @location(1) uvRect: vec4f,\n @location(2) color: vec4f,\n @location(3) mode: f32,\n) -> VertexOut {\n let corners = array(\n vec2f(0, 0), vec2f(1, 0), vec2f(0, 1),\n vec2f(0, 1), vec2f(1, 0), vec2f(1, 1)\n );\n let corner = corners[index];\n let position = rect.xy + corner * rect.zw;\n var out: VertexOut;\n out.position = vec4f(position / viewport.size * vec2f(2, -2) + vec2f(-1, 1), 0, 1);\n out.uv = mix(uvRect.xy, uvRect.zw, corner);\n out.color = color;\n out.mode = mode;\n return out;\n}\n\n@fragment fn fragment(in: VertexOut) -> @location(0) vec4f {\n // Explicit LOD avoids derivative-uniformity requirements across solid/mask/image batches.\n let texel = textureSampleLevel(image, imageSampler, in.uv, 0);\n if (in.mode < 0.5) { return in.color; }\n if (in.mode < 1.5) { return vec4f(in.color.rgb, in.color.a * texel.a); }\n return texel * in.color;\n}`;\n\nclass WebGpuTexture implements RenderTexture {\n constructor(readonly device: GPUDevice, readonly texture: GPUTexture, readonly bindGroup: GPUBindGroup,\n readonly width: number, readonly height: number) {}\n\n writePixels(pixels: RenderPixels, width: number, height: number, x = 0, y = 0): void {\n this.device.queue.writeTexture({ texture: this.texture, origin: [x, y] }, pixels,\n { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]);\n }\n\n writeBitmap(bitmap: ImageBitmap): void {\n this.device.queue.copyExternalImageToTexture({ source: bitmap },\n { texture: this.texture, premultipliedAlpha: false }, [this.width, this.height]);\n }\n\n destroy(): void { this.texture.destroy(); }\n}\n\nexport class WebGpuBackend implements RenderBackend {\n readonly kind = \"webgpu\";\n instanceBufferBytes = 0;\n instanceBuffer: GPUBuffer | undefined;\n disposed = false;\n context?: GPUCanvasContext;\n format: GPUTextureFormat;\n uniform: GPUBuffer;\n sampler: GPUSampler;\n pipeline?: GPURenderPipeline;\n\n static async create(canvas: OffscreenCanvas, onFatal: (error: Error) => void): Promise {\n if (!globalThis.isSecureContext) throw new RendererUnavailableError(\"WebGPU requires HTTPS or localhost\");\n if (!navigator.gpu) throw new RendererUnavailableError(\"WebGPU is unavailable in this browser worker\");\n const adapter = await navigator.gpu.requestAdapter();\n if (!adapter) throw new RendererUnavailableError(\"No WebGPU adapter is available\");\n let device: GPUDevice;\n try {\n device = await adapter.requestDevice();\n } catch (error) {\n if (error instanceof DOMException && error.name === \"OperationError\") {\n throw new RendererUnavailableError(`Could not acquire a WebGPU device: ${error.message}`, { cause: error });\n }\n throw error;\n }\n let backend: WebGpuBackend | undefined;\n try {\n backend = new WebGpuBackend(device);\n const active = backend;\n device.lost.then(info => {\n if (!active.disposed) onFatal(new Error(`WebGPU device lost: ${info.message || info.reason}`));\n });\n device.addEventListener(\"uncapturederror\", event => {\n if (!active.disposed) onFatal(new Error(`WebGPU error: ${event.error.message}`));\n });\n await backend.initialize(canvas);\n return backend;\n } catch (error) {\n if (backend) backend.dispose();\n else device.destroy();\n throw error;\n }\n }\n\n constructor(readonly device: GPUDevice) {\n this.format = navigator.gpu.getPreferredCanvasFormat();\n this.uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });\n this.sampler = device.createSampler({ minFilter: \"linear\", magFilter: \"linear\" });\n }\n\n get maxTextureDimension2D(): number { return this.device.limits.maxTextureDimension2D; }\n get maxCanvasDimension2D(): number { return this.maxTextureDimension2D; }\n\n async initialize(canvas: OffscreenCanvas): Promise {\n const module = this.device.createShaderModule({ code: shader });\n this.pipeline = await this.device.createRenderPipelineAsync({\n layout: \"auto\",\n vertex: {\n module,\n entryPoint: \"vertex\",\n buffers: [{\n arrayStride: QUAD_STRIDE * 4,\n stepMode: \"instance\",\n attributes: [\n { shaderLocation: 0, offset: 0, format: \"float32x4\" },\n { shaderLocation: 1, offset: 16, format: \"float32x4\" },\n { shaderLocation: 2, offset: 32, format: \"float32x4\" },\n { shaderLocation: 3, offset: 48, format: \"float32\" },\n ],\n }],\n },\n fragment: {\n module,\n entryPoint: \"fragment\",\n targets: [{\n format: this.format,\n blend: {\n color: { srcFactor: \"src-alpha\", dstFactor: \"one-minus-src-alpha\" },\n alpha: { srcFactor: \"one\", dstFactor: \"one-minus-src-alpha\" },\n },\n }],\n },\n primitive: { topology: \"triangle-list\" },\n });\n // Acquire the presentation surface last. A null context leaves it usable by WebGL2.\n const context = canvas.getContext(\"webgpu\");\n if (!context) throw new RendererUnavailableError(\"Could not create an OffscreenCanvas WebGPU context\");\n this.context = context;\n context.configure({ device: this.device, format: this.format, alphaMode: \"opaque\" });\n }\n\n createTexture(width: number, height: number, label: string): RenderTexture {\n if (width > this.maxTextureDimension2D || height > this.maxTextureDimension2D) {\n throw new Error(`${label} exceeds the GPU texture dimension limit`);\n }\n if (!this.pipeline) throw new Error(\"WebGPU backend is not initialized\");\n const texture = this.device.createTexture({\n label, size: [width, height], format: \"rgba8unorm\",\n usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,\n });\n try {\n const bindGroup = this.device.createBindGroup({\n layout: this.pipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: { buffer: this.uniform } },\n { binding: 1, resource: texture.createView() },\n { binding: 2, resource: this.sampler },\n ],\n });\n return new WebGpuTexture(this.device, texture, bindGroup, width, height);\n } catch (error) {\n texture.destroy();\n throw error;\n }\n }\n\n resize(width: number, height: number): void {\n this.device.queue.writeBuffer(this.uniform, 0, new Float32Array([width, height, 0, 0]));\n }\n\n submit(instances: Float32Array, quadCount: number, batches: readonly RenderBatch[],\n background: RenderColor): void {\n if (this.disposed || !this.context || !this.pipeline) throw new Error(\"WebGPU backend is not initialized\");\n const usedBytes = quadCount * QUAD_STRIDE * 4;\n if (!this.instanceBuffer || usedBytes > this.instanceBufferBytes) {\n this.instanceBuffer?.destroy();\n this.instanceBufferBytes = Math.max(256, instances.byteLength);\n this.instanceBuffer = this.device.createBuffer({\n size: this.instanceBufferBytes, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,\n });\n }\n if (usedBytes) this.device.queue.writeBuffer(this.instanceBuffer, 0, instances, 0, quadCount * QUAD_STRIDE);\n const encoder = this.device.createCommandEncoder();\n const pass = encoder.beginRenderPass({\n colorAttachments: [{\n view: this.context.getCurrentTexture().createView(),\n clearValue: { r: background[0], g: background[1], b: background[2], a: 1 },\n loadOp: \"clear\", storeOp: \"store\",\n }],\n });\n pass.setPipeline(this.pipeline);\n pass.setVertexBuffer(0, this.instanceBuffer);\n for (const batch of batches) {\n if (!(batch.resource instanceof WebGpuTexture)) throw new Error(\"Texture belongs to a different rendering backend\");\n pass.setBindGroup(0, batch.resource.bindGroup);\n pass.draw(6, batch.count, 0, batch.start);\n }\n pass.end();\n this.device.queue.submit([encoder.finish()]);\n }\n\n async idle(): Promise { await this.device.queue.onSubmittedWorkDone(); }\n\n dispose(): void {\n if (this.disposed) return;\n this.disposed = true;\n this.instanceBuffer?.destroy();\n this.uniform.destroy();\n this.context?.unconfigure();\n this.device.destroy();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts index 572f6242293..12f68c23878 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts @@ -1,4 +1,4 @@ -import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalStatusLevel } from "./types.js"; +import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel } from "./types.js"; export type SelectionText = { status: "valid"; text: string; @@ -35,6 +35,12 @@ export interface TerminalCell { underlineStyle: number; text: string; } +export interface HyperlinkRange { + row: number; + startColumn: number; + endColumn: number; + uri: string; +} export interface ImageMetadata { key: string; width: number; @@ -81,6 +87,7 @@ export interface FrameMetadata extends TerminalGeometry { retainedImages: string[]; placements: ImagePlacement[]; warnings: string[]; + hyperlinks: HyperlinkRange[]; stats: { workloadBytes: number; outputBatches: number; @@ -156,6 +163,7 @@ export type WorkerInputMessage = { url: string; scale: number; font: TerminalFont; + renderer: TerminalRendererPreference; } | ({ type: "viewport"; } & TerminalSize) | { @@ -205,6 +213,7 @@ export type WorkerOutputMessage = { history: HistoryMetadata | null; revision: number; text: string; + hyperlinks: HyperlinkRange[]; } & TerminalGeometry) | { type: "history"; history: HistoryMetadata | null; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map index 8b1b51dbf8b..fe5f2dfde00 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map index aed85a03666..6c4355dd457 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalStatusLevel } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" | \"disconnected\" }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; text: string } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file +{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" | \"disconnected\" }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index 6d2e7cd8b99..7caac592199 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@hex1b/web-terminal", - "version": "0.167.0-alpha.1509.1.1f47fd9", - "description": "First-party WebGPU browser terminal for Hex1b", + "version": "0.167.0-alpha.1519.1.b8be265", + "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 0807a8f6330..7ed86dd10f4 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -22,7 +22,6 @@ public TerminalViewTests() } [Theory] - [InlineData("unsupported", nameof(Resources.ConsoleLogs.TerminalWebGpuUnsupported))] [InlineData("mount-failed", nameof(Resources.ConsoleLogs.TerminalMountFailed))] [InlineData("disconnected", nameof(Resources.ConsoleLogs.TerminalDisconnected))] [InlineData("input-failed", nameof(Resources.ConsoleLogs.TerminalInputFailed))] @@ -43,7 +42,7 @@ await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalTool })); Assert.Equal(loc[resourceKey].Value, cut.Find("[role=alert]").TextContent); - Assert.Equal(error == "unsupported" ? 0 : 1, cut.FindAll("fluent-button").Count); + Assert.Single(cut.FindAll("fluent-button")); } [Fact] diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 5f8a44ff5eb..0ab2ecbd918 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -158,6 +158,7 @@ test("init returns an id while mount waits for its first connected frame", async assert.equal(terminal.getToolbarState(id).connected, false); assert.equal(attempts[0].options.label, "Localized terminal input"); assert.equal(attempts[0].options.url, "wss://dashboard/api/terminal?resource=app&replica=1"); + assert.equal(attempts[0].options.renderer, "auto"); attempts[0].options.onStatus("Socket open", "ready"); attempts[0].role(false); await settle(); @@ -172,20 +173,28 @@ test("init returns an id while mount waits for its first connected frame", async }); assert.equal(attempts[0].client.primaryRequests, 0); assert.equal(attempts[0].options.onInput, undefined); + assert.equal(attempts[0].options.inputBindings, undefined); + assert.equal(attempts[0].options.actions, undefined); assert.equal(attempts[0].options.readOnly, undefined); }); -test("unsupported WebGPU and insecure origins produce a localizable error without retrying", async () => { +test("missing WebGPU and ordinary HTTP leave renderer selection to the package", async () => { navigator.gpu = undefined; const first = mount(); navigator.gpu = {}; window.isSecureContext = false; const second = mount(); + assert.equal(attempts.length, 2); + for (const attempt of attempts) { + assert.equal(attempt.options.renderer, "auto"); + attempt.resolve(); + } await settle(); - assert.equal(attempts.length, 0); assert.equal(timers.size, 0); - assert.equal(terminal.getToolbarState(first.id).error, "unsupported"); - assert.equal(terminal.getToolbarState(second.id).error, "unsupported"); + assert.equal(terminal.getToolbarState(first.id).connected, true); + assert.equal(terminal.getToolbarState(second.id).connected, true); + assert.equal(terminal.getToolbarState(first.id).error, null); + assert.equal(terminal.getToolbarState(second.id).error, null); }); test("hidden initial mounts wait for visibility without consuming the first-frame timeout", async () => { @@ -378,7 +387,7 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.167.0-alpha.1509.1.1f47fd9"); + assert.equal(version, "0.167.0-alpha.1519.1.b8be265"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); @@ -401,6 +410,9 @@ test("checked-in deployment includes the worker and licensed font without npm in for (const name of [ "dist/index.js", "dist/terminal-worker.js", + "dist/webgpu-backend.js", + "dist/webgl2-backend.js", + "dist/hyperlinks.js", "dist/fonts/cascadia-mono-nf/CascadiaMonoNF.woff2", "dist/fonts/cascadia-mono-nf/LICENSE.txt", "dist/fonts/cascadia-mono-nf/README.md", diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs index 98264a1ec5a..1d79911b503 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -126,6 +126,43 @@ public async Task BrowserView_ProjectsSixelAndKittyGraphics(string sequence) await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); } + [Fact] + public async Task BrowserView_PreservesHyperlinkDestinationChangesAcrossReconnect() + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var browser = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(browser, _ => true, timeout.Token); + + // OSC 8: ESC ] 8 ; parameters ; URI ST text ESC ] 8 ; ; ST. + // Replacing only the destination must update HWT metadata even when + // the visible cells remain identical. + host.Workload.Write("\u001b[H\u001b]8;;https://example.com/first\u001b\\link\u001b]8;;\u001b\\"); + var initial = await ReadUntilAsync(browser, frame => frame.GetProperty("hyperlinks").GetArrayLength() > 0, timeout.Token); + AssertLink(initial, "https://example.com/first"); + + host.Workload.Write("\u001b[H\u001b]8;;https://example.com/second\u001b\\link\u001b]8;;\u001b\\"); + var changed = await ReadUntilAsync(browser, frame => frame.GetProperty("hyperlinks").EnumerateArray() + .Any(link => link.GetProperty("uri").GetString() == "https://example.com/second"), timeout.Token); + AssertLink(changed, "https://example.com/second"); + await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnect", timeout.Token); + + using var reconnected = await host.ConnectBrowserAsync(timeout.Token); + var restored = await ReadUntilAsync(reconnected, frame => frame.GetProperty("hyperlinks").GetArrayLength() > 0, timeout.Token); + AssertLink(restored, "https://example.com/second"); + await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + + static void AssertLink(JsonElement frame, string uri) + { + var link = Assert.Single(frame.GetProperty("hyperlinks").EnumerateArray()); + Assert.Equal(uri, link.GetProperty("uri").GetString()); + Assert.Equal(0, link.GetProperty("row").GetInt32()); + Assert.Equal(0, link.GetProperty("startColumn").GetInt32()); + Assert.Equal(4, link.GetProperty("endColumn").GetInt32()); + } + } + [Fact] public async Task BrowserView_RequiresAuthenticationBeforeConnectingToProducer() { From c9f8901d651c25279bbc29b3d1ecd4125ea8783c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Sep 2026 14:44:10 +1000 Subject: [PATCH 048/106] Upgrade Hex1b terminal replay to published build 1522 Use the exact paired npm and NuGet release containing retained KGP image and partial ANSI checkpoint replay fixes. Cover late viewers, reconnects, placement-only animation, and split graphics commands through the dashboard bridge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 8 +- src/Aspire.Dashboard/package-lock.json | 8 +- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 4 +- .../js/hex1b-web-terminal/package.json | 2 +- .../JavaScript/TerminalView.test.mjs | 2 +- .../Shared/TerminalTestHost.cs | 12 ++ .../Terminal/TerminalWebSocketTests.cs | 140 ++++++++++++++++++ 9 files changed, 169 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0e477f691d6..1ed458c0a34 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -113,7 +113,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 566b8506974..99ce6960fa9 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -119,7 +119,7 @@ stream. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.167.0-alpha.1519.1.b8be265`. HWT1 is experimental state transfer +exactly `0.167.0-alpha.1522.1.3085d8b`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. @@ -135,6 +135,12 @@ Sixel and Kitty Graphics Protocol are rendered from server-authoritative state. Historical rendering is text-only. The dashboard's independent console-log view remains available. +HMP checkpoints retain uploaded Kitty image data even when an animation +temporarily removes its placements. They also preserve partially received ANSI +sequences, so late and reconnected viewers can resume placement-only updates +without losing pixels or displaying fragments of graphics commands. See the +[graphics and partial-sequence replay fix](https://github.com/mitchdenny/hex1b/pull/496). + The package handles Ctrl/Cmd+click on authoritative OSC 8 hyperlinks in live output and history. HMP state replay preserves link destinations across late attachment and reconnect. It only opens absolute HTTP, HTTPS and mailto destinations diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 49d86368ea0..71f1f555279 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1519.1.b8be265" + "@hex1b/web-terminal": "0.167.0-alpha.1522.1.3085d8b" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.167.0-alpha.1519.1.b8be265", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1519.1.b8be265.tgz", - "integrity": "sha512-XGQQecWHfu5Z/r4lib5wHI9MlzdhUpkDSdPqCbEDEsmCWlhtE5sK34SOozg4+gMIPXqFc0T523Cpwq8yzcQ73w==", + "version": "0.167.0-alpha.1522.1.3085d8b", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1522.1.3085d8b.tgz", + "integrity": "sha512-VECjNkISPXsGiQark9rxNH4l9wCQdCpuY8CmRNFv3BD7aIDvntdGcfUHzjDtiyI9KDmqGTtTo0UZseqgWGB30A==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index b31429aa1ef..30e7f0bbdc0 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1519.1.b8be265" + "@hex1b/web-terminal": "0.167.0-alpha.1522.1.3085d8b" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index 9ab56fb2e88..0d91f38734a 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -14,9 +14,9 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1519.1.b8be265**, +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1522.1.3085d8b**, paired with the Hex1b NuGet build from commit -`b8be2654e874efa394b496b1c114edcf75c52c14`. The client and server use the evolving +`3085d8bd20c7579f27e873e98b740daf8cf57a11`. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index 7caac592199..8af24c071c0 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.167.0-alpha.1519.1.b8be265", + "version": "0.167.0-alpha.1522.1.3085d8b", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 0ab2ecbd918..8610dbadca2 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -387,7 +387,7 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.167.0-alpha.1519.1.b8be265"); + assert.equal(version, "0.167.0-alpha.1522.1.3085d8b"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); diff --git a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs index e969c15566d..6cdc6bfc837 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Tests.Integration; using Aspire.Hosting; using Hex1b; +using Hex1b.Automation; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -49,6 +50,17 @@ public TerminalTestHost(ITestOutputHelper output, bool requireAuthentication) public Task StartAsync(CancellationToken cancellationToken) => _app.StartAsync(cancellationToken); + public async Task WaitForProducerTextAsync(string text, CancellationToken cancellationToken) + { + using var snapshot = await new Hex1bTerminalInputSequenceBuilder() + .WaitUntil(snapshot => snapshot.ContainsText(text), TimeSpan.FromSeconds(10), "Terminal producer output was not applied.") + .Build() + .ApplyAsync(_producer, cancellationToken); + } + + public Task WaitForPeerHandshakesAsync(CancellationToken cancellationToken) => + Task.WhenAll(_connections).WaitAsync(cancellationToken); + public async Task ConnectBrowserAsync(CancellationToken cancellationToken) { var frontend = new Uri(_app.FrontendSingleEndPointAccessor().GetResolvedAddress()); diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs index 1d79911b503..069908be5f9 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -126,6 +126,146 @@ public async Task BrowserView_ProjectsSixelAndKittyGraphics(string sequence) await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task BrowserView_PreservesKittyImageForLatePeerAndPlacementUpdates(bool alternateScreen, bool nativeSize) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var first = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(first, _ => true, timeout.Token); + + if (alternateScreen) + { + host.Workload.Write("\u001b[?1049h"); + } + + // Kitty transmits pixels with a=t, then reuses the image id in a=p + // placement commands that contain no image data. Like KgpCloudDemo, + // synchronized frames use lowercase d=a to remove placements, not pixels. + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#displaying-images-on-screen + var sizing = nativeSize ? "" : ",c=2,r=2"; + host.Workload.Write("\u001b[?2026h\u001b_Ga=t,f=32,t=d,s=1,v=1,i=7300,q=2;/wAA/w==\u001b\\" + + "\u001b_Ga=d,d=a,q=2\u001b\\" + + $"\u001b[2;3H\u001b_Ga=p,i=7300{sizing},C=1,q=2\u001b\\\u001b[?2026l"); + var initial = await ReadUntilAsync(first, HasPlacement, timeout.Token); + AssertImageIncluded(initial); + + using var second = await host.ConnectBrowserAsync(timeout.Token); + var late = await ReadUntilAsync(second, HasPlacement, timeout.Token); + AssertImageIncluded(late); + Assert.Equal(initial.GetProperty("placements")[0].GetProperty("x").GetDouble(), + late.GetProperty("placements")[0].GetProperty("x").GetDouble()); + + host.Workload.Write("\u001b[?2026h\u001b_Ga=d,d=a,q=2\u001b\\" + + $"\u001b[2;8H\u001b_Ga=p,i=7300{sizing},C=1,q=2\u001b\\\u001b[?2026l"); + var originalX = initial.GetProperty("placements")[0].GetProperty("x").GetDouble(); + var updates = await Task.WhenAll( + ReadUntilAsync(first, HasMovedPlacement, timeout.Token), + ReadUntilAsync(second, HasMovedPlacement, timeout.Token)); + Assert.Equal(updates[0].GetProperty("placements")[0].GetProperty("x").GetDouble(), + updates[1].GetProperty("placements")[0].GetProperty("x").GetDouble()); + + await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnect", timeout.Token); + using var reconnected = await host.ConnectBrowserAsync(timeout.Token); + var restored = await ReadUntilAsync(reconnected, HasMovedPlacement, timeout.Token); + AssertImageIncluded(restored); + await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + await second.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + + static bool HasPlacement(JsonElement frame) => frame.GetProperty("placements").GetArrayLength() == 1; + + bool HasMovedPlacement(JsonElement frame) => + HasPlacement(frame) && frame.GetProperty("placements")[0].GetProperty("x").GetDouble() > originalX; + + static void AssertImageIncluded(JsonElement frame) + { + var image = Assert.Single(frame.GetProperty("images").EnumerateArray()); + Assert.Equal(1, image.GetProperty("width").GetInt32()); + Assert.Equal(1, image.GetProperty("height").GetInt32()); + Assert.Equal(4, image.GetProperty("byteLength").GetInt32()); + Assert.Equal(image.GetProperty("key").GetString(), frame.GetProperty("placements")[0].GetProperty("key").GetString()); + } + } + + [Fact] + public async Task BrowserView_AttachingDuringKittyPlacementReplacementRetainsPixels() + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var first = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(first, _ => true, timeout.Token); + + host.Workload.Write("\u001b[?1049h\u001b_Ga=t,f=32,t=d,s=1,v=1,i=7300,q=2;/wAA/w==\u001b\\" + + "\u001b[2;3H\u001b_Ga=p,i=7300,C=1,q=2\u001b\\"); + var initial = await ReadUntilAsync(first, frame => frame.GetProperty("placements").GetArrayLength() == 1, timeout.Token); + var originalX = initial.GetProperty("placements")[0].GetProperty("x").GetDouble(); + + // An animation can clear placements inside a synchronized-output frame + // before emitting replacements. Lowercase d=a must leave the uploaded + // pixels available to viewers that attach during that interval. + host.Workload.Write("\u001b[?2026h\u001b_Ga=d,d=a,q=2\u001b\\\u001b[Hpalette-cleared"); + await host.WaitForProducerTextAsync("palette-cleared", timeout.Token); + using var late = await host.ConnectBrowserAsync(timeout.Token); + await host.WaitForPeerHandshakesAsync(timeout.Token); + + host.Workload.Write("\u001b[2;8H\u001b_Ga=p,i=7300,C=1,q=2\u001b\\\u001b[?2026l"); + var original = await ReadUntilAsync(first, frame => frame.GetProperty("placements").GetArrayLength() == 1 && + frame.GetProperty("placements")[0].GetProperty("x").GetDouble() > originalX, timeout.Token); + var restored = await ReadUntilAsync(late, frame => frame.GetProperty("placements").GetArrayLength() == 1, timeout.Token); + var image = Assert.Single(restored.GetProperty("images").EnumerateArray()); + Assert.Equal(4, image.GetProperty("byteLength").GetInt32()); + Assert.Equal(original.GetProperty("placements")[0].GetProperty("x").GetDouble(), + restored.GetProperty("placements")[0].GetProperty("x").GetDouble()); + await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + await late.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(12)] + [InlineData(27)] + public async Task BrowserView_AttachingDuringKittyPlacementCommandReplaysCompleteSequence(int splitIndex) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var first = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(first, _ => true, timeout.Token); + + host.Workload.Write("\u001b[?1049h\u001b_Ga=T,f=32,t=d,s=1,v=1,i=7300,p=11,C=1,q=2;/wAA/w==\u001b\\"); + var initial = await ReadUntilAsync(first, frame => frame.GetProperty("placements").GetArrayLength() == 1, timeout.Token); + var originalX = initial.GetProperty("placements")[0].GetProperty("x").GetDouble(); + + // PTY reads can split ESC_Ga=p,...ESC\ within its introducer, fields or + // terminator. A new HMP peer needs that incomplete parser prefix as well + // as the screen checkpoint, or the suffix becomes ordinary screen text. + const string placement = "\u001b_Ga=p,i=7300,p=11,C=1,q=2\u001b\\"; + host.Workload.Write("\u001b[Hprefix-ready\u001b[2;8H" + placement[..splitIndex]); + await host.WaitForProducerTextAsync("prefix-ready", timeout.Token); + using var late = await host.ConnectBrowserAsync(timeout.Token); + await host.WaitForPeerHandshakesAsync(timeout.Token); + + host.Workload.Write(placement[splitIndex..]); + var updates = await Task.WhenAll( + ReadUntilAsync(first, HasMovedPlacement, timeout.Token), + ReadUntilAsync(late, HasMovedPlacement, timeout.Token)); + Assert.Equal(updates[0].GetProperty("placements")[0].GetProperty("x").GetDouble(), + updates[1].GetProperty("placements")[0].GetProperty("x").GetDouble()); + await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + await late.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + + bool HasMovedPlacement(JsonElement frame) => + frame.GetProperty("placements").GetArrayLength() == 1 && + frame.GetProperty("placements")[0].GetProperty("x").GetDouble() > originalX; + } + [Fact] public async Task BrowserView_PreservesHyperlinkDestinationChangesAcrossReconnect() { From d087247fc9be6eb835eac78c722b77a069eee85f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Sep 2026 15:28:29 +1000 Subject: [PATCH 049/106] Restore dashboard terminal window chrome Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- .../Components/Controls/TerminalView.razor | 25 ++++++--- .../Components/Controls/TerminalView.razor.cs | 12 ++-- .../Controls/TerminalView.razor.css | 56 ++++++++++++++++++- .../Controls/TerminalViewTests.cs | 44 +++++++++++++++ 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index ce7d1620f3f..56ab8c4a70f 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -1,12 +1,23 @@ @namespace Aspire.Dashboard.Components.Controls
-
- @if (_terminalError is not null) - { -
- @GetErrorMessage() - @Loc[nameof(Resources.ConsoleLogs.TerminalRetry)] +
+
+ @(ResourceName ?? Loc[nameof(Resources.ConsoleLogs.ConsoleLogsViewTerminalOption)].Value) + @if (_terminalColumns > 0 && _terminalRows > 0) + { + @_terminalColumns × @_terminalRows + }
- } +
+
+ @if (_terminalError is not null) + { +
+ @GetErrorMessage() + @Loc[nameof(Resources.ConsoleLogs.TerminalRetry)] +
+ } +
+
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 94c1b8fc6bd..3de260bb7c5 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -44,6 +44,8 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable private bool _initializationFailed; private bool _disposed; private string? _terminalError; + private int _terminalColumns; + private int _terminalRows; private Task? _initializationTask; /// @@ -62,10 +64,8 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable /// /// Raised when the JS side pushes a fresh toolbar state snapshot (role, - /// dims, font size, etc.). The host page subscribes so the chrome that - /// used to live inside the terminal frame — status badge, "Take control" - /// button, font controls, size dropdown, dims readout — can be rendered - /// in the page's existing toolbar instead. + /// dims, font size, etc.). The host page subscribes to render status, + /// "Take control", font controls and size options in its toolbar. /// [Parameter] public EventCallback OnToolbarStateChanged { get; set; } @@ -308,9 +308,11 @@ public async Task OnTerminalStateChanged(TerminalToolbarState state) return; } - if (_terminalError != state.Error) + if (_terminalError != state.Error || _terminalColumns != state.Cols || _terminalRows != state.Rows) { _terminalError = state.Error; + _terminalColumns = state.Cols; + _terminalRows = state.Rows; StateHasChanged(); } await OnToolbarStateChanged.InvokeAsync(state); diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css index 1cd196a69fe..5cc2b2b0548 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css @@ -1,9 +1,63 @@ .terminal-view { - position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; + padding: 0 8px 8px; + box-sizing: border-box; +} + +.terminal-frame { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + box-sizing: border-box; + background: #0d1117; + border: 2px solid #3a4250; + border-radius: 6px; + overflow: hidden; +} + +.terminal-titlebar { + display: flex; + align-items: center; + flex: 0 0 30px; + min-width: 0; + padding: 0 14px; + box-sizing: border-box; + background: linear-gradient(180deg, #1a2029 0%, #161b22 100%); + border-bottom: 1px solid #30363d; + color: #8b949e; + font: 12px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; +} + +.terminal-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.terminal-dimensions { + flex: 0 0 auto; + margin-inline-start: 12px; + padding-inline-start: 12px; + border-inline-start: 1px solid #30363d; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.terminal-body { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + padding: 6px; + box-sizing: border-box; } .terminal-container { diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 7ed86dd10f4..59d66799602 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -21,6 +21,50 @@ public TerminalViewTests() FluentUISetupHelpers.SetupFluentUIComponents(this); } + [Fact] + public async Task TerminalChrome_DisplaysResourceAndCurrentDimensionsWithoutRemounting() + { + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var initialization = module.Setup("initTerminal", _ => true); + initialization.SetResult(1); + var disposal = module.SetupVoid("disposeTerminal", _ => true); + disposal.SetVoidResult(); + var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "shell ")); + Assert.Single(cut.FindAll(".terminal-frame > .terminal-body > .terminal-container")); + + Assert.Equal("shell ", cut.Find(".terminal-titlebar .terminal-title").TextContent); + Assert.Empty(cut.FindAll(".terminal-dimensions")); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 2, Cols = 120, Rows = 30, Connected = true + })); + Assert.Equal("120 \u00d7 30", cut.Find(".terminal-dimensions").TextContent); + Assert.Equal(Resources.ConsoleLogs.TerminalToolbarGridSize, cut.Find(".terminal-dimensions").GetAttribute("title")); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Cols = 80, Rows = 24 + })); + Assert.Equal("120 \u00d7 30", cut.Find(".terminal-dimensions").TextContent); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 2, Cols = 132, Rows = 50, Connected = true + })); + Assert.Equal("132 \u00d7 50", cut.Find(".terminal-dimensions").TextContent); + Assert.Single(cut.FindAll(".terminal-frame > .terminal-body > .terminal-container")); + Assert.Single(initialization.Invocations); + Assert.Empty(disposal.Invocations); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 3 + })); + Assert.Empty(cut.FindAll(".terminal-dimensions")); + Assert.Equal("shell ", cut.Find(".terminal-title").TextContent); + } + [Theory] [InlineData("mount-failed", nameof(Resources.ConsoleLogs.TerminalMountFailed))] [InlineData("disconnected", nameof(Resources.ConsoleLogs.TerminalDisconnected))] From 6933127621c92d723b38ddb2d53e57cb6a4a42dd Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Sep 2026 16:05:52 +1000 Subject: [PATCH 050/106] Add Notcurses shell to Terminals playground Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- docs/specs/with-terminal.md | 12 ++++++++++-- .../Terminals/Terminals.AppHost/AppHost.cs | 11 ++++++++++- .../Terminals/Terminals.Notcurses/Dockerfile | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 playground/Terminals/Terminals.Notcurses/Dockerfile diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 99ce6960fa9..3bc6619b888 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -205,8 +205,16 @@ when the executable (or container) spec carries a populated `terminal` block: process owns the listener). The dimensions are the initial PTY size; both sides exchange resize frames over HMP afterwards. -Desktop PTY support is implemented across all three platforms (Unix98 `/dev/ptmx` on Linux and macOS; ConPTY on Windows). Container PTYs are tracked -as a Phase 3 follow-up on the parent issue. +Desktop PTY support is implemented across all three platforms (Unix98 `/dev/ptmx` on Linux and macOS; ConPTY on Windows). +Container PTYs use the container runtime's attach command. DCP currently starts the +container before attaching, so one-time startup output, including terminal capability +queries, can be lost. See the [DCP startup ordering](https://github.com/microsoft/dcp/blob/v0.25.13/controllers/container_controller.go#L1843-L1855). + +The `notcurses` resource in `playground/Terminals` installs Ubuntu's `notcurses-bin` +package and starts an interactive Bash shell. Open its dashboard Terminal view, then +run `notcurses-demo` to exercise graphics, color and Unicode rendering. Starting the +demo from the attached shell avoids losing its initial capability queries. Press +`q` to return to the shell; run the command again to repeat the stress workload. ## Files of interest diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index 467558ac499..c746b00b03e 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -59,6 +59,16 @@ .WithTerminal(options => options.ShowTerminalHost = true); } +builder.AddDockerfile("notcurses", "../Terminals.Notcurses") + .WithTerminal(options => + { + // The demo recommends at least 80x45 for its graphics and Unicode workloads: + // https://manpages.ubuntu.com/manpages/noble/man1/notcurses-demo.1.html + options.Columns = 120; + options.Rows = 45; + options.ShowTerminalHost = true; + }); + #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging // of the dashboard. It is not required in end developer code. Comment out this code @@ -70,4 +80,3 @@ #endif builder.Build().Run(); - diff --git a/playground/Terminals/Terminals.Notcurses/Dockerfile b/playground/Terminals/Terminals.Notcurses/Dockerfile new file mode 100644 index 00000000000..2d8a4e04a43 --- /dev/null +++ b/playground/Terminals/Terminals.Notcurses/Dockerfile @@ -0,0 +1,18 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +FROM ubuntu:24.04 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends notcurses-bin \ + && rm -rf /var/lib/apt/lists + +ENV LANG=C.UTF-8 \ + TERM=xterm-256color \ + COLORTERM=24bit + +USER 1000:1000 + +# Launch notcurses-demo from the shell after the terminal has attached, so DCP +# does not lose the demo's one-time terminal capability queries during startup. +CMD ["bash", "-i", "-l"] From 606a70efcdb3e6baa2e842b6bac8b7238a8d68f1 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Sep 2026 16:30:20 +1000 Subject: [PATCH 051/106] Add BB ASCII demo container to Terminals playground Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- docs/specs/with-terminal.md | 4 ++++ .../Terminals/Terminals.AppHost/AppHost.cs | 3 +++ playground/Terminals/Terminals.Bb/Dockerfile | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 playground/Terminals/Terminals.Bb/Dockerfile diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 3bc6619b888..8a8407ffe8c 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -216,6 +216,10 @@ run `notcurses-demo` to exercise graphics, color and Unicode rendering. Starting demo from the attached shell avoids losing its initial capability queries. Press `q` to return to the shell; run the command again to repeat the stress workload. +The separate `bb` container follows the same shell-first pattern. Run `bb` from its +Terminal view to launch the AAlib ASCII-art demo, declining audio when prompted. +Use `bb -loop` to repeat the demo continuously. + ## Files of interest | Concern | File | diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index c746b00b03e..517dfcf8ad0 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -69,6 +69,9 @@ options.ShowTerminalHost = true; }); +builder.AddDockerfile("bb", "../Terminals.Bb") + .WithTerminal(options => options.ShowTerminalHost = true); + #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging // of the dashboard. It is not required in end developer code. Comment out this code diff --git a/playground/Terminals/Terminals.Bb/Dockerfile b/playground/Terminals/Terminals.Bb/Dockerfile new file mode 100644 index 00000000000..25afe7ab4ac --- /dev/null +++ b/playground/Terminals/Terminals.Bb/Dockerfile @@ -0,0 +1,19 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +FROM ubuntu:24.04 + +# Ubuntu installs BB outside the container's default PATH. +RUN apt-get update \ + && apt-get install -y --no-install-recommends bb \ + && ln -s /usr/games/bb /usr/local/bin/bb \ + && rm -rf /var/lib/apt/lists + +ENV LANG=C.UTF-8 \ + TERM=xterm-256color \ + COLORTERM=24bit + +USER 1000:1000 + +# Start bb manually after attaching to the terminal, like the Notcurses demo. +CMD ["bash", "-i", "-l"] From 3f03c7eba37e888749d0fbbd27f91809550054ae Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Sep 2026 16:45:22 +1000 Subject: [PATCH 052/106] Remove BB from Terminals playground Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- docs/specs/with-terminal.md | 4 ---- .../Terminals/Terminals.AppHost/AppHost.cs | 3 --- playground/Terminals/Terminals.Bb/Dockerfile | 19 ------------------- 3 files changed, 26 deletions(-) delete mode 100644 playground/Terminals/Terminals.Bb/Dockerfile diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 8a8407ffe8c..3bc6619b888 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -216,10 +216,6 @@ run `notcurses-demo` to exercise graphics, color and Unicode rendering. Starting demo from the attached shell avoids losing its initial capability queries. Press `q` to return to the shell; run the command again to repeat the stress workload. -The separate `bb` container follows the same shell-first pattern. Run `bb` from its -Terminal view to launch the AAlib ASCII-art demo, declining audio when prompted. -Use `bb -loop` to repeat the demo continuously. - ## Files of interest | Concern | File | diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index 517dfcf8ad0..c746b00b03e 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -69,9 +69,6 @@ options.ShowTerminalHost = true; }); -builder.AddDockerfile("bb", "../Terminals.Bb") - .WithTerminal(options => options.ShowTerminalHost = true); - #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging // of the dashboard. It is not required in end developer code. Comment out this code diff --git a/playground/Terminals/Terminals.Bb/Dockerfile b/playground/Terminals/Terminals.Bb/Dockerfile deleted file mode 100644 index 25afe7ab4ac..00000000000 --- a/playground/Terminals/Terminals.Bb/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -# Licensed to the .NET Foundation under one or more agreements. -# The .NET Foundation licenses this file to you under the MIT license. - -FROM ubuntu:24.04 - -# Ubuntu installs BB outside the container's default PATH. -RUN apt-get update \ - && apt-get install -y --no-install-recommends bb \ - && ln -s /usr/games/bb /usr/local/bin/bb \ - && rm -rf /var/lib/apt/lists - -ENV LANG=C.UTF-8 \ - TERM=xterm-256color \ - COLORTERM=24bit - -USER 1000:1000 - -# Start bb manually after attaching to the terminal, like the Notcurses demo. -CMD ["bash", "-i", "-l"] From 6f1e200170a6120c1184d105ae750160d2e91d11 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Sep 2026 17:22:40 +1000 Subject: [PATCH 053/106] Anchor terminal copy controls to selected text Use Hex1b's public selection overlay with localized Fluent controls and Aspire accent highlights. Preserve keyboard focus, authoritative copying, and connection lifecycle. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- docs/specs/with-terminal.md | 8 + .../Components/Controls/TerminalView.razor | 14 ++ .../Components/Controls/TerminalView.razor.cs | 6 +- .../Controls/TerminalView.razor.css | 52 +++++ .../Components/Controls/TerminalView.razor.js | 151 ++++++++++++- .../Controls/TerminalViewTests.cs | 22 +- .../JavaScript/TerminalView.test.mjs | 210 +++++++++++++++++- .../Pages/ConsoleLogsTerminalTests.cs | 4 +- 8 files changed, 457 insertions(+), 10 deletions(-) diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 3bc6619b888..f8fdd1e2c07 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -135,6 +135,14 @@ Sixel and Kitty Graphics Protocol are rendered from server-authoritative state. Historical rendering is text-only. The dashboard's independent console-log view remains available. +Text selections use a translucent Aspire accent highlight. A Fluent copy button +appears below and to the right of the last visible selected line, clamping to the +canvas edges and moving above the line when there is not enough room below. +The dashboard uses Hex1b's public selection overlay and copy action; Hex1b retains +ownership of authoritative selection text, history and clipboard handling. +Copying preserves the selection and keyboard focus, with a checkmark and localized +confirmation on success. + HMP checkpoints retain uploaded Kitty image data even when an animation temporarily removes its placements. They also preserve partially received ANSI sequences, so late and reconnected viewers can resume placement-only updates diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index 56ab8c4a70f..a5f7191baad 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -1,6 +1,20 @@ @namespace Aspire.Dashboard.Components.Controls
+
@(ResourceName ?? Loc[nameof(Resources.ConsoleLogs.ConsoleLogsViewTerminalOption)].Value) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 3de260bb7c5..a5a0d67bb6b 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -15,6 +15,7 @@ namespace Aspire.Dashboard.Components.Controls; public sealed partial class TerminalView : ComponentBase, IAsyncDisposable { private ElementReference _terminalElement; + private ElementReference _selectionTemplateElement; private IJSObjectReference? _jsModule; private DotNetObjectReference? _selfRef; private int _terminalId; @@ -79,6 +80,9 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Inject] public required IStringLocalizer Loc { get; init; } + [Inject] + public required IStringLocalizer ControlsLoc { get; init; } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (_disposed || _initializationFailed || string.IsNullOrEmpty(ResourceName)) @@ -214,7 +218,7 @@ private async Task InitializeTerminalCoreAsync(string resourceName, int replicaI _connectedGeneration = -1; _terminalId = await _jsModule.InvokeAsync( "initTerminal", _terminalElement, BuildWebSocketUrl(resourceName, replicaIndex), _selfRef, - Loc[nameof(Resources.ConsoleLogs.TerminalInputLabel)].Value); + Loc[nameof(Resources.ConsoleLogs.TerminalInputLabel)].Value, _selectionTemplateElement); } catch (JSDisconnectedException) { diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css index 5cc2b2b0548..ec3beefd51a 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css @@ -68,6 +68,58 @@ overflow: hidden; } +.terminal-container ::deep ::part(selection-highlight) { + background: var(--accent-fill-rest); + opacity: 0.3; +} + +.terminal-view ::deep .terminal-selection-actions { + position: absolute; + width: 32px; + height: 32px; + pointer-events: auto; +} + +.terminal-view ::deep .terminal-selection-actions[hidden], +.terminal-view ::deep .terminal-selection-actions [hidden] { + display: none; +} + +.terminal-view ::deep .terminal-selection-copy { + width: 100%; + height: 100%; + border-radius: calc(var(--control-corner-radius) * 1px); + box-shadow: 0 2px 8px rgb(0 0 0 / 30%); +} + +.terminal-view ::deep .terminal-selection-copy::part(control) { + padding: 0; + width: 100%; + height: 100%; + border-radius: inherit; +} + +.terminal-view ::deep .terminal-selection-copy[aria-busy="true"]::part(control) { + opacity: 0.6; + cursor: progress; +} + +.terminal-view ::deep .terminal-selection-status { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +@media (forced-colors: active) { + .terminal-container ::deep ::part(selection-highlight) { + background: Highlight; + forced-color-adjust: none; + } +} + .terminal-error { position: absolute; inset-inline: 12px; diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 5e41c1a564a..2ed602b65a1 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -108,6 +108,148 @@ function connectionFailed(state, generation, error) { scheduleReconnect(state, generation); } +function inputFailed(state, error) { + console.warn("Dashboard terminal input failed.", error); + state.error = "input-failed"; + notifyToolbar(state); +} + +function selectionCopyPosition(rects, canvasSize, width, height) { + let anchor = null; + // Public selection rectangles are overlay-local CSS pixels, including + // font scaling. Clip before choosing the last visible selected line so + // scrollback and reverse/multiline selections anchor on visible text. + for (const rect of rects) { + const left = Math.max(0, rect.left); + const top = Math.max(0, rect.top); + const right = Math.min(canvasSize.width, rect.left + rect.width); + const bottom = Math.min(canvasSize.height, rect.top + rect.height); + if (right > left && bottom > top && + (!anchor || bottom > anchor.bottom || (bottom === anchor.bottom && right > anchor.right))) { + anchor = { top, right, bottom }; + } + } + if (!anchor) { + return null; + } + const gap = 6; + const below = anchor.bottom + gap; + const top = below + height <= canvasSize.height ? below : anchor.top - gap - height; + return { + left: Math.max(0, Math.min(anchor.right + gap, canvasSize.width - width)), + top: Math.max(0, Math.min(top, canvasSize.height - height)), + }; +} + +function createSelectionUI(state, current) { + let actions; + let button; + let copyIcon; + let copiedIcon; + let status; + let detail; + let copying = false; + + function updateButtonState() { + const busy = copying || detail.selection.copying; + button.disabled = !detail.connected || detail.selection.status !== "valid" || detail.viewport.pending; + // Native disabling blurs a focused Fluent button. Use aria-disabled + // and the click guard while busy so keyboard copying retains focus. + button.setAttribute("aria-disabled", String(button.disabled || busy)); + button.setAttribute("aria-busy", String(busy)); + } + + function resetFeedback() { + copyIcon.hidden = false; + copiedIcon.hidden = true; + button.title = button.dataset.copyLabel; + button.setAttribute("aria-label", button.dataset.copyLabel); + status.textContent = ""; + } + + return event => { + // Claim only the built-in Copy button, not highlights, clipboard state + // or Return to live. Keep controls inside the public overlay so clicks + // do not look like an outside click to the terminal's input handlers. + event.preventDefault(); + if (!current() || event.detail.signal.aborted) { + return; + } + if (!actions) { + // Clone inert Fluent markup rather than moving Blazor-owned nodes. + // All live control events stay in JS to preserve clipboard user + // activation and avoid a server round-trip on selection updates. + actions = state.selectionTemplate.firstElementChild.cloneNode(true); + button = actions.querySelector("fluent-button"); + button.removeAttribute("id"); + copyIcon = actions.querySelector("[data-copy-icon]"); + copiedIcon = actions.querySelector("[data-copied-icon]"); + status = actions.querySelector("[role=status]"); + const signal = event.detail.signal; + actions.addEventListener("pointerdown", e => { + // Retain terminal focus for pointer copying; keyboard users + // can still Tab to the Fluent button and activate it normally. + e.preventDefault(); + }, { signal }); + button.addEventListener("click", () => { + if (!current() || signal.aborted || button.disabled || copying || detail.selection.copying) { + return; + } + const requestId = detail.selection.requestId; + resetFeedback(); + copying = true; + updateButtonState(); + void detail.runAction("copySelection").then(() => { + if (!current() || signal.aborted || detail.selection.status !== "valid" || + detail.selection.requestId !== requestId) { + return; + } + copyIcon.hidden = true; + copiedIcon.hidden = false; + button.title = button.dataset.copiedLabel; + button.setAttribute("aria-label", button.dataset.copiedLabel); + status.textContent = button.dataset.copiedLabel; + if (state.error === "input-failed") { + state.error = null; + notifyToolbar(state); + } + }).catch(error => { + if (current() && !signal.aborted && detail.selection.requestId === requestId) { + inputFailed(state, error); + } + }).finally(() => { + copying = false; + if (current() && !signal.aborted) { + updateButtonState(); + } + }); + }, { signal }); + signal.addEventListener("abort", () => actions.remove(), { once: true }); + event.detail.overlay.append(actions); + } + if (!detail || detail.selection.requestId !== event.detail.selection.requestId || + detail.selection.text !== event.detail.selection.text || event.detail.selection.status !== "valid") { + resetFeedback(); + } + detail = event.detail; + const selectable = detail.connected && ["valid", "pending"].includes(detail.selection.status); + const hadFocus = actions.contains(document.activeElement); + // Reveal before measuring: hidden controls have zero layout dimensions. + actions.hidden = !selectable; + const position = selectable + ? selectionCopyPosition(detail.rects, detail.canvasSize, actions.offsetWidth, actions.offsetHeight) + : null; + actions.hidden = !position; + updateButtonState(); + if (position) { + actions.style.left = `${position.left}px`; + actions.style.top = `${position.top}px`; + } else if (hadFocus) { + state.client?.focus(); + } + }; +} + function connectClient(state) { cancelReconnect(state); const generation = ++state.generation; @@ -141,6 +283,7 @@ async function mountClient(state, generation, controller) { signal: controller.signal, label: state.label, sizing: state.sizing, + onSelectionUI: createSelectionUI(state, current), // Let the package fall back to WebGL2 for unavailable WebGPU // capabilities, including ordinary HTTP. Other initialization // errors and runtime GPU loss must still surface as failures. @@ -183,9 +326,7 @@ async function mountClient(state, generation, controller) { }, onInputError(error) { if (current()) { - console.warn("Dashboard terminal input failed.", error); - state.error = "input-failed"; - notifyToolbar(state); + inputFailed(state, error); } }, }); @@ -244,10 +385,10 @@ function changeSizing(state, sizing) { } } -export function initTerminal(element, wsUrl, dotNetRef, label) { +export function initTerminal(element, wsUrl, dotNetRef, label, selectionTemplate) { const id = nextId++; const state = { - id, element, wsUrl, dotNetRef, label, + id, element, wsUrl, dotNetRef, label, selectionTemplate, client: null, controller: null, disposed: false, diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 59d66799602..dcf2b46e166 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -21,6 +21,26 @@ public TerminalViewTests() FluentUISetupHelpers.SetupFluentUIComponents(this); } + [Fact] + public void SelectionTemplate_ProvidesLocalizedFluentCopyControl() + { + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var initialization = module.Setup("initTerminal", _ => true); + initialization.SetResult(1); + module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + + var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "shell")); + var button = cut.Find("div[hidden] .terminal-selection-copy"); + Assert.Equal(Resources.ControlsStrings.GridValueCopyToClipboard, button.GetAttribute("aria-label")); + Assert.Equal(Resources.ControlsStrings.GridValueCopyToClipboard, button.GetAttribute("data-copy-label")); + Assert.Equal(Resources.ControlsStrings.GridValueCopied, button.GetAttribute("data-copied-label")); + Assert.Equal(2, button.QuerySelectorAll("svg").Length); + Assert.True(cut.Find("[data-copied-icon]").HasAttribute("hidden")); + Assert.Equal("polite", cut.Find(".terminal-selection-status").GetAttribute("aria-live")); + var invocation = Assert.Single(initialization.Invocations); + Assert.IsType(invocation.Arguments[4]); + } + [Fact] public async Task TerminalChrome_DisplaysResourceAndCurrentDimensionsWithoutRemounting() { @@ -86,7 +106,7 @@ await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalTool })); Assert.Equal(loc[resourceKey].Value, cut.Find("[role=alert]").TextContent); - Assert.Single(cut.FindAll("fluent-button")); + Assert.Single(cut.FindAll(".terminal-error fluent-button")); } [Fact] diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 8610dbadca2..a1a336a5b52 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -122,16 +122,63 @@ afterEach(async () => { globals.clear(); }); +function selectionControl() { + const button = Object.assign(new EventTarget(), { + dataset: { copyLabel: "Localized copy", copiedLabel: "Localized copied" }, + attributes: new Map([["id", "template-button"]]), + setAttribute(name, value) { this.attributes.set(name, value); }, + removeAttribute(name) { this.attributes.delete(name); }, + }); + const copyIcon = {}; + const copiedIcon = {}; + const status = {}; + const nodes = { "fluent-button": button, "[data-copy-icon]": copyIcon, + "[data-copied-icon]": copiedIcon, "[role=status]": status }; + const actions = Object.assign(new EventTarget(), { + style: {}, offsetWidth: 32, offsetHeight: 32, removed: false, + querySelector(selector) { return nodes[selector]; }, + contains(element) { return element === button; }, + remove() { this.removed = true; }, + }); + return { actions, button, copyIcon, copiedIcon, status }; +} + +function selectionEvent(attempt, overrides = {}) { + const event = new Event("selectionui", { cancelable: true }); + attempt.selectionChildren ??= []; + Object.defineProperty(event, "detail", { value: { + connected: true, readOnly: true, + rects: [{ left: 20, top: 10, width: 60, height: 20 }], + canvasSize: { width: 800, height: 600 }, + viewport: { pending: false }, + signal: attempt.options.signal, + overlay: { append: actions => attempt.selectionChildren.push(actions) }, + runAction: () => Promise.resolve("authoritative selection"), + ...overrides, + selection: { status: "valid", requestId: 1, text: "authoritative selection", copying: false, + ...overrides.selection }, + } }); + assert.equal(attempt.options.onSelectionUI(event), undefined, "UI ownership must be synchronous"); + assert.equal(event.defaultPrevented, true); + return event; +} + function mount({ visible = true, dotNetRef } = {}) { const element = { clientWidth: visible ? 800 : 0, clientHeight: visible ? 600 : 0, contains: value => value === element, }; + const controls = []; + const template = { firstElementChild: { cloneNode() { + const control = selectionControl(); + controls.push(control); + return control.actions; + } } }; const id = terminal.initTerminal(element, "wss://dashboard/api/terminal?resource=app&replica=1", - dotNetRef ?? { invokeMethodAsync: (_name, snapshot) => snapshots.push(snapshot) }, "Localized terminal input"); + dotNetRef ?? { invokeMethodAsync: (_name, snapshot) => snapshots.push(snapshot) }, "Localized terminal input", template); ids.push(id); - return { id, element }; + return { id, element, controls }; } async function settle() { @@ -153,6 +200,165 @@ function retry() { return delay; } +for (const [name, rects, position] of [ + ["single line", [{ left: 20, top: 10, width: 60, height: 20 }], { left: "86px", top: "36px" }], + ["last line rather than bounding box", [ + { left: 10, top: 40, width: 30, height: 20 }, { left: 10, top: 20, width: 300, height: 20 }, + ], { left: "46px", top: "66px" }], + ["bottom edge", [{ left: 100, top: 580, width: 100, height: 20 }], { left: "206px", top: "542px" }], + ["right edge", [{ left: 790, top: 10, width: 20, height: 20 }], { left: "768px", top: "36px" }], + ["clipped history", [ + { left: 20, top: -30, width: 600, height: 20 }, + { left: 20, top: -10, width: 60, height: 20 }, + { left: 20, top: 610, width: 300, height: 20 }, + ], { left: "86px", top: "16px" }], +]) { + test(`selection copy control anchors to ${name}`, () => { + const { controls } = mount(); + selectionEvent(attempts[0], { rects }); + const { actions, button } = controls[0]; + assert.deepEqual(actions.style, position); + assert.equal(actions.hidden, false); + assert.equal(button.disabled, false); + assert.equal(button.attributes.has("id"), false, "Cloning must not duplicate the template's id"); + assert.deepEqual(attempts[0].selectionChildren, [actions]); + }); +} + +test("selection controls update in place and hide when no selected text is visible", async () => { + const { controls } = mount(); + attempts[0].resolve(); + await settle(); + selectionEvent(attempts[0]); + const { actions, button } = controls[0]; + selectionEvent(attempts[0], { selection: { status: "pending", text: null } }); + assert.equal(actions.hidden, false); + assert.equal(button.disabled, true); + selectionEvent(attempts[0], { viewport: { pending: true } }); + assert.equal(button.disabled, true); + for (const change of [ + { selection: { status: "none" } }, + { selection: { status: "invalidated" } }, + { connected: false }, + { rects: [{ left: 0, top: 700, width: 80, height: 20 }] }, + { canvasSize: { width: 0, height: 0 } }, + ]) { + selectionEvent(attempts[0], change); + assert.equal(actions.hidden, true); + } + selectionEvent(attempts[0]); + assert.equal(actions.hidden, false); + document.activeElement = button; + Object.defineProperty(actions, "hidden", { + set(value) { + if (value) { + document.activeElement = document.body; + } + }, + }); + selectionEvent(attempts[0], { selection: { status: "none" } }); + assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(controls.length, 1); +}); + +test("copy uses the public authoritative action without claiming primary or clearing selection", async () => { + const { controls } = mount(); + attempts[0].resolve(); + await settle(); + const copy = Promise.withResolvers(); + const calls = []; + selectionEvent(attempts[0], { runAction: (...args) => { calls.push(args); return copy.promise; } }); + const { actions, button, copyIcon, copiedIcon, status } = controls[0]; + const pointer = new Event("pointerdown", { cancelable: true }); + actions.dispatchEvent(pointer); + assert.equal(pointer.defaultPrevented, true); + button.dispatchEvent(new Event("click")); + button.dispatchEvent(new Event("click")); + assert.deepEqual(calls, [["copySelection"]]); + assert.equal(button.disabled, false, "Busy copying must not blur keyboard focus"); + assert.equal(button.attributes.get("aria-disabled"), "true"); + assert.equal(button.attributes.get("aria-busy"), "true"); + assert.equal(attempts[0].client.primaryRequests, 0); + copy.resolve(""); + await settle(); + assert.equal(button.disabled, false); + assert.equal(button.attributes.get("aria-disabled"), "false"); + assert.equal(button.attributes.get("aria-busy"), "false"); + assert.equal(copyIcon.hidden, true); + assert.equal(copiedIcon.hidden, false); + assert.equal(button.attributes.get("aria-label"), "Localized copied"); + assert.equal(status.textContent, "Localized copied"); + selectionEvent(attempts[0], { selection: { requestId: 2 } }); + assert.equal(copyIcon.hidden, false); + assert.equal(copiedIcon.hidden, true); + assert.equal(status.textContent, ""); +}); + +test("selection controls clamp within a small canvas and follow updated CSS-pixel geometry", () => { + const { controls } = mount(); + selectionEvent(attempts[0], { + rects: [{ left: 0, top: 20, width: 48, height: 20 }], + canvasSize: { width: 48, height: 40 }, + }); + assert.deepEqual(controls[0].actions.style, { left: "16px", top: "0px" }); + selectionEvent(attempts[0], { + rects: [{ left: 0, top: 0, width: 145.25, height: 32.5 }], + canvasSize: { width: 1291.5, height: 775 }, + }); + assert.deepEqual(controls[0].actions.style, { left: "151.25px", top: "38.5px" }); + assert.equal(controls.length, 1); +}); + +test("copy failures remain local and a successful retry clears the error", async () => { + const { id, controls } = mount(); + attempts[0].resolve(); + await settle(); + selectionEvent(attempts[0], { runAction: () => Promise.reject(new Error("Clipboard denied")) }); + controls[0].button.dispatchEvent(new Event("click")); + await settle(); + assert.equal(terminal.getToolbarState(id).error, "input-failed"); + assert.equal(controls[0].button.disabled, false); + assert.equal(attempts.length, 1); + assert.equal(timers.size, 0); + selectionEvent(attempts[0]); + controls[0].button.dispatchEvent(new Event("click")); + await settle(); + assert.equal(terminal.getToolbarState(id).error, null); +}); + +test("changing selection while copying does not show stale feedback", async () => { + const { controls } = mount(); + const copy = Promise.withResolvers(); + selectionEvent(attempts[0], { runAction: () => copy.promise }); + controls[0].button.dispatchEvent(new Event("click")); + selectionEvent(attempts[0], { selection: { requestId: 2, text: "new selection" } }); + copy.resolve("old selection"); + await settle(); + assert.equal(controls[0].status.textContent, ""); + assert.equal(controls[0].copiedIcon.hidden, true); +}); + +test("reconnect removes selection controls, listeners and stale clipboard callbacks", async () => { + const { id, controls } = mount(); + const copy = Promise.withResolvers(); + let calls = 0; + selectionEvent(attempts[0], { runAction: () => { calls++; return copy.promise; } }); + controls[0].button.dispatchEvent(new Event("click")); + terminal.reconnectTerminal(id, "wss://dashboard/api/terminal?resource=next"); + assert.equal(controls[0].actions.removed, true); + controls[0].button.disabled = false; + controls[0].button.dispatchEvent(new Event("click")); + assert.equal(calls, 1); + copy.reject(new Error("Old connection")); + await settle(); + assert.equal(terminal.getToolbarState(id).error, null); + selectionEvent(attempts[1]); + assert.equal(controls.length, 2); + assert.equal(controls[1].actions.removed, false); + terminal.disposeTerminal(id); + assert.equal(controls[1].actions.removed, true); +}); + test("init returns an id while mount waits for its first connected frame", async () => { const { id } = mount(); assert.equal(terminal.getToolbarState(id).connected, false); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index 68cda16e9e2..3d085a87bef 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -5,6 +5,7 @@ using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Model; using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Utils; @@ -583,7 +584,8 @@ public async Task TerminalResource_ViewToggle_RenderedDisplayStylesMatchActiveVi [Fact] public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitialization() { - Services.AddLocalization(); + FluentUISetupHelpers.AddCommonDashboardServices(this); + FluentUISetupHelpers.SetupFluentUIComponents(this); var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); var initTerminal = module.Setup("initTerminal", _ => true); var reconnectTerminal = module.Setup("reconnectTerminal", _ => true); From fcb572b91120cb58a5c769f320e185543a75b347 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Sep 2026 19:21:44 +1000 Subject: [PATCH 054/106] Return terminal focus after copying selected text Dismiss the copied selection and overlay after clipboard success so the next native paste targets the terminal. Preserve selection on failure and ignore stale copy completions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- docs/specs/with-terminal.md | 5 +- .../Components/Controls/TerminalView.razor | 8 +--- .../Controls/TerminalView.razor.css | 12 +---- .../Components/Controls/TerminalView.razor.js | 29 ++---------- .../Controls/TerminalViewTests.cs | 7 +-- .../JavaScript/TerminalView.test.mjs | 46 +++++++++++-------- 6 files changed, 39 insertions(+), 68 deletions(-) diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index f8fdd1e2c07..5aa99fb8a2b 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -140,8 +140,9 @@ appears below and to the right of the last visible selected line, clamping to th canvas edges and moving above the line when there is not enough room below. The dashboard uses Hex1b's public selection overlay and copy action; Hex1b retains ownership of authoritative selection text, history and clipboard handling. -Copying preserves the selection and keyboard focus, with a checkmark and localized -confirmation on success. +After a successful copy, the selection and copy overlay are cleared and focus +returns to the terminal, ready for Cmd+V or Ctrl+V. A failed copy leaves the +selection available for retry. HMP checkpoints retain uploaded Kitty image data even when an animation temporarily removes its placements. They also preserve partially received ANSI diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index a5f7191baad..9c77605f24e 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -6,13 +6,9 @@ - - + aria-label="@ControlsLoc[nameof(Resources.ControlsStrings.GridValueCopyToClipboard)]"> + -
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css index ec3beefd51a..925d941cdf1 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css @@ -80,8 +80,7 @@ pointer-events: auto; } -.terminal-view ::deep .terminal-selection-actions[hidden], -.terminal-view ::deep .terminal-selection-actions [hidden] { +.terminal-view ::deep .terminal-selection-actions[hidden] { display: none; } @@ -104,15 +103,6 @@ cursor: progress; } -.terminal-view ::deep .terminal-selection-status { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} - @media (forced-colors: active) { .terminal-container ::deep ::part(selection-highlight) { background: Highlight; diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 2ed602b65a1..db43ec22e51 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -144,9 +144,6 @@ function selectionCopyPosition(rects, canvasSize, width, height) { function createSelectionUI(state, current) { let actions; let button; - let copyIcon; - let copiedIcon; - let status; let detail; let copying = false; @@ -159,14 +156,6 @@ function createSelectionUI(state, current) { button.setAttribute("aria-busy", String(busy)); } - function resetFeedback() { - copyIcon.hidden = false; - copiedIcon.hidden = true; - button.title = button.dataset.copyLabel; - button.setAttribute("aria-label", button.dataset.copyLabel); - status.textContent = ""; - } - return event => { // Claim only the built-in Copy button, not highlights, clipboard state // or Return to live. Keep controls inside the public overlay so clicks @@ -182,9 +171,6 @@ function createSelectionUI(state, current) { actions = state.selectionTemplate.firstElementChild.cloneNode(true); button = actions.querySelector("fluent-button"); button.removeAttribute("id"); - copyIcon = actions.querySelector("[data-copy-icon]"); - copiedIcon = actions.querySelector("[data-copied-icon]"); - status = actions.querySelector("[role=status]"); const signal = event.detail.signal; actions.addEventListener("pointerdown", e => { // Retain terminal focus for pointer copying; keyboard users @@ -196,7 +182,6 @@ function createSelectionUI(state, current) { return; } const requestId = detail.selection.requestId; - resetFeedback(); copying = true; updateButtonState(); void detail.runAction("copySelection").then(() => { @@ -204,11 +189,11 @@ function createSelectionUI(state, current) { detail.selection.requestId !== requestId) { return; } - copyIcon.hidden = true; - copiedIcon.hidden = false; - button.title = button.dataset.copiedLabel; - button.setAttribute("aria-label", button.dataset.copiedLabel); - status.textContent = button.dataset.copiedLabel; + // Dismiss only the copied selection, after clipboard success, + // and return input focus so the next paste goes to the PTY. + actions.hidden = true; + state.client.clearSelection(); + state.client.focus(); if (state.error === "input-failed") { state.error = null; notifyToolbar(state); @@ -227,10 +212,6 @@ function createSelectionUI(state, current) { signal.addEventListener("abort", () => actions.remove(), { once: true }); event.detail.overlay.append(actions); } - if (!detail || detail.selection.requestId !== event.detail.selection.requestId || - detail.selection.text !== event.detail.selection.text || event.detail.selection.status !== "valid") { - resetFeedback(); - } detail = event.detail; const selectable = detail.connected && ["valid", "pending"].includes(detail.selection.status); const hadFocus = actions.contains(document.activeElement); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index dcf2b46e166..7f82262fd83 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -32,11 +32,8 @@ public void SelectionTemplate_ProvidesLocalizedFluentCopyControl() var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "shell")); var button = cut.Find("div[hidden] .terminal-selection-copy"); Assert.Equal(Resources.ControlsStrings.GridValueCopyToClipboard, button.GetAttribute("aria-label")); - Assert.Equal(Resources.ControlsStrings.GridValueCopyToClipboard, button.GetAttribute("data-copy-label")); - Assert.Equal(Resources.ControlsStrings.GridValueCopied, button.GetAttribute("data-copied-label")); - Assert.Equal(2, button.QuerySelectorAll("svg").Length); - Assert.True(cut.Find("[data-copied-icon]").HasAttribute("hidden")); - Assert.Equal("polite", cut.Find(".terminal-selection-status").GetAttribute("aria-live")); + Assert.Equal(Resources.ControlsStrings.GridValueCopyToClipboard, button.GetAttribute("title")); + Assert.Single(button.QuerySelectorAll("svg")); var invocation = Assert.Single(initialization.Invocations); Assert.IsType(invocation.Arguments[4]); } diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index a1a336a5b52..9a8b894fee2 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -73,6 +73,7 @@ beforeEach(() => { sizingCalls: [], primaryRequests: 0, focusCalls: 0, + selectionClears: 0, selectionRefreshes: 0, disposed: false, dispose() { this.disposed = true; }, @@ -83,7 +84,8 @@ beforeEach(() => { this.sizingCalls.push(sizing); options.onSizingChange(sizing); }, - focus() { this.focusCalls++; }, + focus() { this.focusCalls++; document.activeElement = this.element; }, + clearSelection() { this.selectionClears++; }, refreshSelectionUI() { this.selectionRefreshes++; }, }; const attempt = { @@ -124,23 +126,18 @@ afterEach(async () => { function selectionControl() { const button = Object.assign(new EventTarget(), { - dataset: { copyLabel: "Localized copy", copiedLabel: "Localized copied" }, attributes: new Map([["id", "template-button"]]), setAttribute(name, value) { this.attributes.set(name, value); }, removeAttribute(name) { this.attributes.delete(name); }, }); - const copyIcon = {}; - const copiedIcon = {}; - const status = {}; - const nodes = { "fluent-button": button, "[data-copy-icon]": copyIcon, - "[data-copied-icon]": copiedIcon, "[role=status]": status }; + const nodes = { "fluent-button": button }; const actions = Object.assign(new EventTarget(), { style: {}, offsetWidth: 32, offsetHeight: 32, removed: false, querySelector(selector) { return nodes[selector]; }, contains(element) { return element === button; }, remove() { this.removed = true; }, }); - return { actions, button, copyIcon, copiedIcon, status }; + return { actions, button }; } function selectionEvent(attempt, overrides = {}) { @@ -261,17 +258,18 @@ test("selection controls update in place and hide when no selected text is visib assert.equal(controls.length, 1); }); -test("copy uses the public authoritative action without claiming primary or clearing selection", async () => { +test("copy dismisses the copied selection and returns focus for immediate terminal paste", async () => { const { controls } = mount(); attempts[0].resolve(); await settle(); const copy = Promise.withResolvers(); const calls = []; selectionEvent(attempts[0], { runAction: (...args) => { calls.push(args); return copy.promise; } }); - const { actions, button, copyIcon, copiedIcon, status } = controls[0]; + const { actions, button } = controls[0]; const pointer = new Event("pointerdown", { cancelable: true }); actions.dispatchEvent(pointer); assert.equal(pointer.defaultPrevented, true); + document.activeElement = button; button.dispatchEvent(new Event("click")); button.dispatchEvent(new Event("click")); assert.deepEqual(calls, [["copySelection"]]); @@ -279,19 +277,20 @@ test("copy uses the public authoritative action without claiming primary or clea assert.equal(button.attributes.get("aria-disabled"), "true"); assert.equal(button.attributes.get("aria-busy"), "true"); assert.equal(attempts[0].client.primaryRequests, 0); + assert.equal(attempts[0].client.selectionClears, 0); + assert.equal(attempts[0].client.focusCalls, 0); + assert.equal(actions.hidden, false); copy.resolve(""); await settle(); assert.equal(button.disabled, false); assert.equal(button.attributes.get("aria-disabled"), "false"); assert.equal(button.attributes.get("aria-busy"), "false"); - assert.equal(copyIcon.hidden, true); - assert.equal(copiedIcon.hidden, false); - assert.equal(button.attributes.get("aria-label"), "Localized copied"); - assert.equal(status.textContent, "Localized copied"); + assert.equal(actions.hidden, true); + assert.equal(attempts[0].client.selectionClears, 1); + assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(document.activeElement, attempts[0].client.element); selectionEvent(attempts[0], { selection: { requestId: 2 } }); - assert.equal(copyIcon.hidden, false); - assert.equal(copiedIcon.hidden, true); - assert.equal(status.textContent, ""); + assert.equal(actions.hidden, false); }); test("selection controls clamp within a small canvas and follow updated CSS-pixel geometry", () => { @@ -318,15 +317,21 @@ test("copy failures remain local and a successful retry clears the error", async await settle(); assert.equal(terminal.getToolbarState(id).error, "input-failed"); assert.equal(controls[0].button.disabled, false); + assert.equal(controls[0].actions.hidden, false); + assert.equal(attempts[0].client.selectionClears, 0); + assert.equal(attempts[0].client.focusCalls, 0); assert.equal(attempts.length, 1); assert.equal(timers.size, 0); selectionEvent(attempts[0]); controls[0].button.dispatchEvent(new Event("click")); await settle(); assert.equal(terminal.getToolbarState(id).error, null); + assert.equal(controls[0].actions.hidden, true); + assert.equal(attempts[0].client.selectionClears, 1); + assert.equal(attempts[0].client.focusCalls, 1); }); -test("changing selection while copying does not show stale feedback", async () => { +test("changing selection while copying does not dismiss the new selection or steal focus", async () => { const { controls } = mount(); const copy = Promise.withResolvers(); selectionEvent(attempts[0], { runAction: () => copy.promise }); @@ -334,8 +339,9 @@ test("changing selection while copying does not show stale feedback", async () = selectionEvent(attempts[0], { selection: { requestId: 2, text: "new selection" } }); copy.resolve("old selection"); await settle(); - assert.equal(controls[0].status.textContent, ""); - assert.equal(controls[0].copiedIcon.hidden, true); + assert.equal(controls[0].actions.hidden, false); + assert.equal(attempts[0].client.selectionClears, 0); + assert.equal(attempts[0].client.focusCalls, 0); }); test("reconnect removes selection controls, listeners and stale clipboard callbacks", async () => { From 94cc39b88a9b5739b894fde1915095a0ee61bbc6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 10 Sep 2026 12:51:06 +1000 Subject: [PATCH 055/106] Update paired Hex1b terminal packages to alpha.1547 Refresh the complete published browser distribution and version documentation. Drain complete WebSocket snapshots in the disconnect regression test rather than assuming a snapshot fits one receive buffer. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6019354f-1f14-4f5b-9068-1179bc66b58e --- Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 2 +- src/Aspire.Dashboard/package-lock.json | 8 +- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 10 +- .../wwwroot/js/hex1b-web-terminal/README.md | 159 +++++++++++++++++- .../js/hex1b-web-terminal/dist/protocol.d.ts | 1 + .../hex1b-web-terminal/dist/protocol.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/protocol.js | 26 +++ .../hex1b-web-terminal/dist/protocol.js.map | 2 +- .../dist/terminal-worker.js | 4 +- .../dist/terminal-worker.js.map | 2 +- .../js/hex1b-web-terminal/dist/types.d.ts | 40 +++++ .../js/hex1b-web-terminal/dist/types.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/types.js.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.d.ts | 6 +- .../dist/web-terminal.d.ts.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.js | 30 +++- .../dist/web-terminal.js.map | 2 +- .../hex1b-web-terminal/dist/wire-types.d.ts | 8 +- .../dist/wire-types.d.ts.map | 2 +- .../hex1b-web-terminal/dist/wire-types.js.map | 2 +- .../js/hex1b-web-terminal/package.json | 2 +- .../JavaScript/TerminalView.test.mjs | 2 +- .../Terminal/TerminalWebSocketTests.cs | 12 +- 25 files changed, 300 insertions(+), 32 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1ed458c0a34..d5de8192e5a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -113,7 +113,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 5aa99fb8a2b..d43aaafe1bd 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -119,7 +119,7 @@ stream. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.167.0-alpha.1522.1.3085d8b`. HWT1 is experimental state transfer +exactly `0.167.0-alpha.1547.1.798b26c`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 71f1f555279..85c5633ecf0 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1522.1.3085d8b" + "@hex1b/web-terminal": "0.167.0-alpha.1547.1.798b26c" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.167.0-alpha.1522.1.3085d8b", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1522.1.3085d8b.tgz", - "integrity": "sha512-VECjNkISPXsGiQark9rxNH4l9wCQdCpuY8CmRNFv3BD7aIDvntdGcfUHzjDtiyI9KDmqGTtTo0UZseqgWGB30A==", + "version": "0.167.0-alpha.1547.1.798b26c", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1547.1.798b26c.tgz", + "integrity": "sha512-1eBEnzQoF25E9TXjWZLd+0196hR8bgDxqIHJP2M+9JeTLWbr/ToeAdH0gI+REkPn6aNpSiz3/Mi+Qf7QYe+g9w==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index 30e7f0bbdc0..f1fd85c92b7 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1522.1.3085d8b" + "@hex1b/web-terminal": "0.167.0-alpha.1547.1.798b26c" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index 0d91f38734a..2e68a9f99e5 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -14,9 +14,9 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1522.1.3085d8b**, +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1547.1.798b26c**, paired with the Hex1b NuGet build from commit -`3085d8bd20c7579f27e873e98b740daf8cf57a11`. The client and server use the evolving +`798b26c8a297e3060bb9e3a509f76668be6b9022`. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. @@ -100,8 +100,10 @@ preserves link destinations when a browser attaches or reconnects. See [the replay fix](https://github.com/mitchdenny/hex1b/pull/493). The public API supports auto/fixed sizing, primary requests, keyboard and mouse -input, paste/copy, selection, and producer-backed history. It has no terminal -theme setter, search API, clear-buffer API, or title-change callback. Terminal +input, paste/copy, selection, and producer-backed history. It exposes workload +title, progress, and shell-integration state with change callbacks; the dashboard +does not yet consume these and still labels the terminal with the resource name. +It has no terminal theme setter, search API, or clear-buffer API. Terminal colors and content are server-authoritative; inspection UI uses the package's theme defaults/tokens. The previous terminal hardcoded dark xterm colors rather than offering a theme control. Search, filtering, clearing the log display, and diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md index 0221fe78dc4..91618adbf82 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md @@ -137,6 +137,8 @@ workers, fonts, and the intended WebSocket endpoint. | `sizing` | `{ mode: "auto", fontSize?: number }` or `{ mode: "fixed", columns, rows, fontSize?: number }`. | | `readOnly` | Disable application input while retaining history inspection and selection. | | `label` | Accessible label for the terminal's hidden keyboard input. | +| `onTitleChange` | Initial authoritative workload title, then distinct presented changes; see below. | +| `onProgressChange`, `onShellIntegrationChange` | Initial authoritative activity, then distinct presented changes for host-owned chrome. | | `inputBindings`, `onInput`, `actions` | Per-view input policy and custom actions. | | `onSelectionUI` | Synchronous, cancelable UI notification hook. | @@ -147,7 +149,7 @@ Font size is an integer from 8–32, defaulting to 16. Import `MIN_FONT_SIZE` an ownership. `requestPrimary()` explicitly requests ownership; inspect `peer` or `onRoleChange` to observe the result. -The handle exposes `geometry`, `peer`, `connected`, `stats`, `screenText`, +The handle exposes `geometry`, `peer`, `connected`, `title`, `progress`, `shellIntegration`, `stats`, `screenText`, `sizing`, `viewport`, `selection`, `inputBindings`, and `inputContext`. Metrics start empty; check optional fields before using them. History may be unavailable, and selection can be unavailable, none, pending, valid, or @@ -155,8 +157,159 @@ invalidated. Narrow `viewport.available` and `selection.status` before using their state-specific values. `screenText` reflects the presented viewport, not an independently reconstructed ANSI buffer. -Callbacks include `onGeometry`, `onRoleChange`, `onSizingChange`, `onStats`, -`onViewportChange`, `onSelectionChange`, `onStatus`, and `onInputError`. +Callbacks include `onGeometry`, `onRoleChange`, `onTitleChange`, `onSizingChange`, `onStats`, +`onProgressChange`, `onShellIntegrationChange`, `onViewportChange`, `onSelectionChange`, `onStatus`, and `onInputError`. + +### Workload titles + +The read-only `terminal.title` is the current presented workload title. An empty +string means unset or explicitly cleared; choose your own fallback. The optional +`onTitleChange(title)` callback runs once with the first authoritative presented +value, **including `""`, before mount resolves**. The getter is updated before +the callback. Later notifications report only distinct presented values. Identical +updates, same-title resyncs, cursor blinking, and statistics do not notify again. +Intermediate workload changes can coalesce; this is not an event for every OSC +sequence. + +```ts +import { WebTerminal } from "@hex1b/web-terminal"; + +const container = document.getElementById("terminal"); +const header = document.getElementById("terminal-header"); +if (!container || !header) throw new Error("Missing terminal elements"); +const resourceName = "Build service"; + +const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", + onTitleChange(title) { + header.textContent = title || resourceName; + } +}); +console.log(terminal.title); +``` + +The callback uses elements and fallback text captured **before** mounting, not +the still-pending `terminal` result. The component does not change `document.title`, +your header, or the input's accessible `label` automatically. There is no title +subscription method or DOM title event. + +Titles are normalized by the core to at most 4,096 UTF-16 code units, with C0, +DEL, and C1 controls removed, malformed surrogates replaced with U+FFFD, and +truncation at a Unicode scalar boundary. They remain **untrusted text**: markup +and bidi characters are preserved. Use `textContent`, not `innerHTML`; apply your +own presentation and bidi policies. + +OSC 0 and OSC 2 set or explicitly clear the window title; OSC 1 is icon-only. +Use `ESC ] 0 ; text BEL` or `ESC ] 2 ; text BEL`; `ESC \` (ST) may replace BEL. +The UTF-8 input path also accepts Unicode C1 OSC (`U+009D`) and ST (`U+009C`). +Semicolons within `text` are literal. Existing OSC 22/23 saved-title extensions +update the same state, including after a late HMP1 attachment. +RIS, soft reset, screen clearing, and buffer switching preserve the title and +existing saved-title behavior. History inspection retains the current workload +title rather than a title associated with an old row. + +Disconnect and disposal retain the last known title without a synthetic clear. +Disposal (including abort) stops title callbacks. Attach a new view to reconnect; +it receives its own initial current title. Preliminary disconnected relay frames +do not trigger the initial notification. Callbacks run directly like the other +state callbacks; thrown host errors are not swallowed or retried. + +The required title field needs the matching server build. Missing or malformed +title metadata fails the connection; an older server is not silently treated as +an empty title. +The per-title bound is not a limit on all parser buffering or saved-stack depth. +An HMP1 snapshot with too much saved title state fails its 16 MiB replay limit +rather than silently discarding saved titles. + +### Application progress and shell activity + +`terminal.progress` exposes OSC 9;4 state as `{ state, percentage }`. +The states are `"none"`, `"normal"`, `"error"`, `"indeterminate"`, and `"warning"`. +Normal/error/warning percentages are integers from 0 through 100. None and +indeterminate have `percentage: null`; none means the host should hide its indicator. +This is application-reported progress, not inferred from output or CPU activity. +It is independent of `ProgressWidget`, which draws inside terminal cells. + +`terminal.shellIntegration` exposes OSC 133 as `{ phase, lastExitCode }`. +The phases are `"unknown"`, `"prompt"` (A), `"commandLine"` (B), +`"executing"` (C), and `"finished"` (D). B means input after the prompt, **not** +command execution. Unknown does not mean idle. `lastExitCode` is a signed +32-bit integer, or null when no status was reported; null is not success. +A/B/C preserve the last reported result, and D replaces it, including clearing +it to null when the shell omits its status. No command text, history, or output +locations are retained by these APIs. + +Both getters return defensive copies. Their callbacks receive the first +authoritative presented state before mount resolves, then distinct presented +changes. Both getters are updated before either activity callback. Callbacks +use the same direct, synchronous host-callback convention as title changes; +host exceptions are not swallowed or retried. + +Frames coalesce: the browser might see only Finished for a fast command, or +miss an entire command whose final state is unchanged. These callbacks are +**current-state notifications, not a lossless start/finish event stream**. +Resync/replay never invent commands, unchanged state does not notify again, +and a new mount receives its own baseline. + +This example creates optional chrome outside the terminal: + +```ts +import { WebTerminal } from "@hex1b/web-terminal"; + +const status = document.createElement("span"); +const progress = document.createElement("progress"); +progress.max = 100; +progress.hidden = true; +const container = document.createElement("div"); +container.style.cssText = "width:800px;height:480px"; +document.body.append(status, progress, container); + +const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", + onProgressChange(value) { + progress.hidden = value.state === "none"; + progress.dataset.state = value.state; // Host CSS can distinguish error/warning. + if (value.percentage === null) progress.removeAttribute("value"); + else progress.value = value.percentage; + }, + onShellIntegrationChange(value) { + status.textContent = value.phase + + (value.lastExitCode === null ? "" : ` (last exit ${value.lastExitCode})`); + }, + onStats(stats) { + if (!stats.connected) { + progress.hidden = true; + status.textContent = "Disconnected"; + } + } +}); +console.log(terminal.progress, terminal.shellIntegration); +``` + +No title, document chrome, or progress UI is changed automatically by the +component. The sample endpoint must be supplied by your application. +Callbacks can run before the `terminal` variable is assigned; use their +arguments during initial mounting. + +RIS resets progress to None and shell integration to Unknown. Soft reset, +screen clearing, resize, and buffer switches preserve them. OSC 9;4 state 0 +clears only progress; a shell completion does not implicitly clear it. +Disconnect, process exit, and disposal retain the last reported values +without inventing a completion or progress clear. Check `connected` before +showing active chrome, and remount to reconnect. Disposal stops callbacks. +Snapshots and historical viewports carry current activity, not activity at +the time a particular row was printed. + +The core accepts BEL, ESC-backslash ST, and decoded Unicode C1 terminators +through its UTF-8 input path. Determinate progress requires unsigned decimal +0-100; clear/indeterminate allow an omitted percentage and ignore its optional +value. OSC 133 supports the basic A/B/C forms and D with an optional signed +decimal exit status (an empty field also means no status). Missing required, +malformed, overflowed, excess, or unsupported arguments do not change state. +The raw-output presentation path still forwards the original sequences to +supporting outer terminals. Required activity metadata needs the matching +server build; invalid/missing wire fields fail the connection, not silently +fall back to default state. ## Input and clipboard diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts index 917551e65ee..940cbef6cbc 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts @@ -3,6 +3,7 @@ export declare const LIMITS: Readonly<{ commandBytes: number; frameBytes: number; metadataBytes: number; + titleUnits: 4096; cells: 262144; images: 4096; placements: 16384; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map index 94b53346fd5..b45ec78089f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,eAAe,EAAiB,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIlH,eAAO,MAAM,MAAM;;;;;;;;EAQjB,CAAC;AAKH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGxD;AAyBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,GAAG,IAAI,CAqC1H;AA+GD,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,CAiD1D;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAY9G"} \ No newline at end of file +{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,eAAe,EAAiB,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIlH,eAAO,MAAM,MAAM;;;;;;;;;EASjB,CAAC;AAKH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGxD;AAyBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,GAAG,IAAI,CAqC1H;AAsID,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,CAiD1D;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAY9G"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js index f24b438eb4c..53d710d863c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js @@ -4,6 +4,7 @@ export const LIMITS = Object.freeze({ commandBytes: 64 * 1024, frameBytes: 96 * 1024 * 1024, metadataBytes: 8 * 1024 * 1024, + titleUnits: 4096, cells: 262144, images: 4096, placements: 16384, @@ -98,6 +99,31 @@ function validateMetadata(metadata) { } integer(metadata.revision, "revision", 1); integer(metadata.baseRevision, "base revision"); + if (typeof metadata.title !== "string" || metadata.title.length > LIMITS.titleUnits || + /[\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(metadata.title)) { + throw new Error("Invalid terminal title"); + } + const progress = metadata.progress; + if (!isRecord(progress) || typeof progress.state !== "string" || + !["none", "normal", "error", "indeterminate", "warning"].includes(progress.state)) { + throw new Error("Invalid terminal progress"); + } + if (progress.state === "none" || progress.state === "indeterminate") { + if (progress.percentage !== null) + throw new Error("Unexpected terminal progress percentage"); + } + else { + integer(progress.percentage, "terminal progress percentage", 0, 100); + } + const shell = metadata.shellIntegration; + if (!isRecord(shell) || typeof shell.phase !== "string" || + !["unknown", "prompt", "commandLine", "executing", "finished"].includes(shell.phase)) { + throw new Error("Invalid terminal shell integration"); + } + if (shell.lastExitCode !== null) + integer(shell.lastExitCode, "terminal shell exit code", -2147483648, 2147483647); + if (shell.phase === "unknown" && shell.lastExitCode !== null) + throw new Error("Unknown terminal shell phase has an exit code"); const columns = integer(metadata.columns, "columns", 1, 1024); const rows = integer(metadata.rows, "rows", 1, 512); if (typeof metadata.mouseTracking !== "number" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) { diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map index fa04673c658..cddc434aa8d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map @@ -1 +1 @@ -{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FrameMetadata, HistoryMetadata, SelectionText, TerminalCell, TerminalFrame } from \"./wire-types.js\";\nimport { isRecord } from \"./validation.js\";\n\n// Binary validation is deliberately independent of the GPU and the transport.\nexport const LIMITS = Object.freeze({\n commandBytes: 64 * 1024,\n frameBytes: 96 * 1024 * 1024,\n metadataBytes: 8 * 1024 * 1024,\n cells: 262144,\n images: 4096,\n placements: 16384,\n textureBytes: 256 * 1024 * 1024,\n});\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\nconst utf8Encoder = new TextEncoder();\n\nexport function assertCommandSize(command: unknown): void {\n if (utf8Encoder.encode(JSON.stringify(command)).byteLength > LIMITS.commandBytes)\n throw new RangeError(\"Terminal input exceeds the 64 KiB message limit. Send a smaller amount of text.\");\n}\n\nfunction integer(value: unknown, name: string, min = 0, max = Number.MAX_SAFE_INTEGER): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max) {\n throw new Error(`Invalid ${name}: ${String(value)}`);\n }\n return value;\n}\n\nfunction array(value: unknown, name: string, limit: number): asserts value is unknown[] {\n if (!Array.isArray(value) || value.length > limit) throw new Error(`Invalid ${name}`);\n}\n\nfunction key(value: unknown): asserts value is string {\n if (typeof value !== \"string\" || !value.length || value.length > 1024) {\n throw new Error(\"Invalid image key\");\n }\n}\n\nfunction rowId(value: unknown, name: string): asserts value is string {\n if (typeof value !== \"string\" || !/^[1-9][0-9]{0,18}$/u.test(value) || BigInt(value) > 9223372036854775807n) {\n throw new Error(`Invalid ${name}`);\n }\n}\n\nexport function validateHistory(history: unknown, columns: number, rows: number): asserts history is HistoryMetadata | null {\n if (history === null) return;\n if (!isRecord(history)) throw new Error(\"Missing history metadata\");\n rowId(history.generation, \"history generation\");\n if (history.buffer !== \"main\" && history.buffer !== \"alternate\") throw new Error(\"Invalid history buffer\");\n const totalRows = integer(history.totalRows, \"history total rows\", rows, 2147483647);\n const liveTop = integer(history.liveTop, \"history live top\", 0, totalRows - rows);\n if (liveTop !== totalRows - rows) throw new Error(\"Inconsistent history extent\");\n integer(history.top, \"history top\", 0, liveTop);\n if (typeof history.following !== \"boolean\" || (history.following && history.top !== history.liveTop)) {\n throw new Error(\"Invalid history following state\");\n }\n integer(history.requestId, \"viewport request id\");\n array(history.rowIds, \"viewport row ids\", rows);\n if (history.rowIds.length !== rows || new Set(history.rowIds).size !== rows) throw new Error(\"Invalid viewport row ids\");\n for (const id of history.rowIds) rowId(id, \"viewport row id\");\n const selection = history.selection;\n if (!isRecord(selection) || typeof selection.status !== \"string\" ||\n ![\"none\", \"valid\", \"invalidated\"].includes(selection.status)) throw new Error(\"Invalid selection status\");\n integer(selection.requestId, \"selection request id\");\n if (typeof selection.mode !== \"string\" || ![\"character\", \"word\", \"line\", \"rectangle\"].includes(selection.mode)) throw new Error(\"Invalid selection mode\");\n array(selection.ranges, \"selection ranges\", rows);\n let previousRow = -1;\n for (const range of selection.ranges) {\n if (!isRecord(range)) throw new Error(\"Invalid selection range\");\n const row = integer(range.row, \"selection range row\", previousRow + 1, rows - 1);\n const startColumn = integer(range.startColumn, \"selection start column\", 0, columns - 1);\n integer(range.endColumn, \"selection end column\", startColumn + 1, columns);\n previousRow = row;\n }\n validateSelectionText(selection);\n if (selection.status !== \"valid\" && selection.ranges.length) throw new Error(\"Inactive selection has highlight ranges\");\n if (history.copy !== null) {\n if (!isRecord(history.copy)) throw new Error(\"Missing copy metadata\");\n integer(history.copy.requestId, \"copy request id\", 1);\n validateSelectionText(history.copy);\n }\n}\n\nfunction validateSelectionText(selection: Record): asserts selection is Record & SelectionText {\n if (typeof selection.status !== \"string\" || ![\"none\", \"valid\", \"invalidated\"].includes(selection.status) ||\n (selection.status === \"valid\"\n ? typeof selection.text !== \"string\" || selection.text.length > LIMITS.metadataBytes\n : selection.text !== null)) {\n throw new Error(\"Invalid selection text\");\n }\n}\n\nfunction validateMetadata(metadata: unknown): asserts metadata is FrameMetadata {\n if (!isRecord(metadata) || metadata.version !== 1 || typeof metadata.full !== \"boolean\") {\n throw new Error(\"Unsupported frame metadata version\");\n }\n integer(metadata.revision, \"revision\", 1);\n integer(metadata.baseRevision, \"base revision\");\n const columns = integer(metadata.columns, \"columns\", 1, 1024);\n const rows = integer(metadata.rows, \"rows\", 1, 512);\n if (typeof metadata.mouseTracking !== \"number\" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) {\n throw new Error(\"Unsupported mouse tracking mode\");\n }\n if (!isRecord(metadata.peer) || typeof metadata.peer.isPrimary !== \"boolean\") throw new Error(\"Invalid peer state\");\n for (const field of [\"id\", \"primaryId\"]) {\n const id = metadata.peer[field];\n if (id !== null && (typeof id !== \"string\" || !id.length || id.length > 256)) {\n throw new Error(`Invalid peer ${field}`);\n }\n }\n if (metadata.peer.id !== null && metadata.peer.isPrimary !== (metadata.peer.id === metadata.peer.primaryId)) {\n throw new Error(\"Inconsistent primary peer state\");\n }\n integer(columns * rows, \"cell count\", 1, LIMITS.cells);\n validateHistory(metadata.history, columns, rows);\n array(metadata.hyperlinks, \"hyperlinks\", columns * rows);\n let previousLinkEnd = 0;\n for (const link of metadata.hyperlinks) {\n if (!isRecord(link)) throw new Error(\"Invalid hyperlink\");\n const row = integer(link.row, \"hyperlink row\", 0, rows - 1);\n const start = integer(link.startColumn, \"hyperlink start column\", 0, columns - 1);\n const end = integer(link.endColumn, \"hyperlink end column\", start + 1, columns);\n if (row * columns + start < previousLinkEnd) throw new Error(\"Unordered or overlapping hyperlinks\");\n previousLinkEnd = row * columns + end;\n if (typeof link.uri !== \"string\" || !link.uri.length || link.uri.length > LIMITS.metadataBytes)\n throw new Error(\"Invalid hyperlink URI\");\n }\n if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) {\n throw new Error(\"This spike requires server geometry of 10 × 20 logical pixels\");\n }\n for (const field of [\"defaultBackground\", \"defaultForeground\"]) {\n if (metadata[field] !== undefined) integer(metadata[field], field, 0, 0xffffffff);\n }\n if (!isRecord(metadata.cursor) || typeof metadata.cursor.visible !== \"boolean\") throw new Error(\"Invalid cursor\");\n integer(metadata.cursor.x, \"cursor x\", -1, 1024);\n integer(metadata.cursor.y, \"cursor y\", -1, 512);\n const shapes = [\"Default\", \"BlinkingBlock\", \"SteadyBlock\", \"BlinkingUnderline\", \"SteadyUnderline\", \"BlinkingBar\", \"SteadyBar\"];\n if (typeof metadata.cursor.shape === \"string\") metadata.cursor.shape = shapes.indexOf(metadata.cursor.shape);\n integer(metadata.cursor.shape, \"cursor shape\", 0, 6);\n array(metadata.images, \"images\", LIMITS.images);\n array(metadata.retainedImages, \"retained image keys\", LIMITS.images);\n array(metadata.placements, \"placements\", LIMITS.placements);\n array(metadata.warnings, \"warnings\", 256);\n if (metadata.warnings.some(w => typeof w !== \"string\")) throw new Error(\"Invalid warning\");\n if (!isRecord(metadata.stats)) throw new Error(\"Invalid server metrics\");\n for (const field of [\"workloadBytes\", \"outputBatches\", \"captureMs\", \"elapsedMs\"]) {\n const metric = metadata.stats[field];\n if (typeof metric !== \"number\" || !Number.isFinite(metric) || metric < 0) {\n throw new Error(`Invalid server metric ${field}`);\n }\n }\n const retained = new Set();\n for (const imageKey of metadata.retainedImages) {\n key(imageKey);\n if (retained.has(imageKey)) throw new Error(\"Duplicate retained image key\");\n retained.add(imageKey);\n }\n const imageKeys = new Set();\n let decodedImageBytes = 0;\n for (const image of metadata.images) {\n if (!isRecord(image)) throw new Error(\"Invalid image\");\n key(image.key);\n if (imageKeys.has(image.key) || !retained.has(image.key)) throw new Error(\"Inconsistent new image keys\");\n imageKeys.add(image.key);\n const width = integer(image.width, \"image width\", 1, 16384);\n const height = integer(image.height, \"image height\", 1, 16384);\n const byteLength = integer(image.byteLength, \"image byte length\", 1, LIMITS.frameBytes);\n if (image.format !== \"rgba\" && image.format !== \"png\") throw new Error(\"Unsupported image format\");\n if (image.format === \"rgba\" && byteLength !== width * height * 4) {\n throw new Error(\"RGBA image size mismatch\");\n }\n decodedImageBytes += width * height * 4;\n if (decodedImageBytes > LIMITS.textureBytes) throw new Error(\"New images exceed decoded texture budget\");\n }\n for (const placement of metadata.placements) {\n if (!isRecord(placement)) throw new Error(\"Invalid placement\");\n key(placement.key);\n if (!retained.has(placement.key)) throw new Error(\"Placement references an unretained image\");\n if (placement.kind !== \"kgp\" && placement.kind !== \"sixel\") throw new Error(\"Invalid placement kind\");\n for (const field of [\"x\", \"y\", \"width\", \"height\", \"sourceX\", \"sourceY\", \"sourceWidth\", \"sourceHeight\", \"clipX\", \"clipY\", \"clipWidth\", \"clipHeight\", \"z\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || !Number.isFinite(coordinate) || Math.abs(coordinate) > 2147483648) {\n throw new Error(`Invalid placement ${field}`);\n }\n }\n for (const field of [\"width\", \"height\", \"sourceWidth\", \"sourceHeight\", \"clipWidth\", \"clipHeight\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || coordinate < 0) throw new Error(`Negative placement ${field}`);\n }\n }\n}\n\n/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */\nexport function decodeFrame(buffer: unknown): TerminalFrame {\n if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.frameBytes) {\n throw new Error(\"Invalid or oversized binary frame\");\n }\n const view = new DataView(buffer);\n let offset = 0;\n const requireBytes = (count: number) => {\n if (count < 0 || count > view.byteLength - offset) throw new Error(\"Truncated HWT1 frame\");\n };\n const u32 = () => { requireBytes(4); const n = view.getUint32(offset, true); offset += 4; return n; };\n if (u32() !== 0x31545748) throw new Error(\"Unsupported frame magic (expected HWT1)\");\n const metadataLength = integer(u32(), \"metadata length\", 2, LIMITS.metadataBytes);\n requireBytes(metadataLength);\n const metadata: unknown = JSON.parse(utf8.decode(new Uint8Array(buffer, offset, metadataLength)));\n offset += metadataLength;\n validateMetadata(metadata);\n const cellCount = metadata.columns * metadata.rows;\n const changedCount = integer(u32(), \"changed cell count\", 0, cellCount);\n if (changedCount > Math.floor((view.byteLength - offset) / 22)) throw new Error(\"Truncated cell records\");\n if (metadata.full && changedCount !== cellCount) throw new Error(\"Incomplete full frame\");\n const cells = [];\n const seen = new Set();\n for (let i = 0; i < changedCount; i++) {\n requireBytes(22);\n const index = u32();\n if (index >= cellCount || seen.has(index)) throw new Error(\"Invalid or duplicate cell index\");\n seen.add(index);\n const foreground = u32();\n const background = u32();\n const underlineColor = u32();\n const attributes = view.getUint16(offset, true);\n const width = view.getUint8(offset + 2);\n const underlineStyle = view.getUint8(offset + 3);\n const textLength = view.getUint16(offset + 4, true);\n offset += 6;\n if (underlineStyle > 5) throw new Error(\"Unsupported underline style\");\n requireBytes(textLength);\n const text = utf8.decode(new Uint8Array(buffer, offset, textLength));\n offset += textLength;\n cells.push({ index, foreground, background, underlineColor, attributes, width, underlineStyle, text });\n }\n const imageBytes = metadata.images.reduce((total, image) => total + image.byteLength, 0);\n if (imageBytes !== view.byteLength - offset) throw new Error(\"Image payload length mismatch\");\n const images = metadata.images.map(image => {\n const bytes = new Uint8Array(buffer, offset, image.byteLength);\n offset += image.byteLength;\n return { ...image, bytes };\n });\n return { metadata, cells, images };\n}\n\n/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */\nexport function screenText(cells: readonly (TerminalCell | undefined)[], columns: number, rows: number): string {\n const lines = [];\n for (let y = 0; y < rows; y++) {\n let line = \"\";\n for (let x = 0; x < columns; x++) {\n const cell = cells[y * columns + x];\n if (!cell || cell.width === 0) continue;\n line += cell.attributes & 64 ? \" \".repeat(cell.width) : (cell.text || \" \");\n }\n lines.push(line.replace(/ +$/u, \"\"));\n }\n return lines.join(\"\\n\");\n}\n"]} \ No newline at end of file +{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FrameMetadata, HistoryMetadata, SelectionText, TerminalCell, TerminalFrame } from \"./wire-types.js\";\nimport { isRecord } from \"./validation.js\";\n\n// Binary validation is deliberately independent of the GPU and the transport.\nexport const LIMITS = Object.freeze({\n commandBytes: 64 * 1024,\n frameBytes: 96 * 1024 * 1024,\n metadataBytes: 8 * 1024 * 1024,\n titleUnits: 4096,\n cells: 262144,\n images: 4096,\n placements: 16384,\n textureBytes: 256 * 1024 * 1024,\n});\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\nconst utf8Encoder = new TextEncoder();\n\nexport function assertCommandSize(command: unknown): void {\n if (utf8Encoder.encode(JSON.stringify(command)).byteLength > LIMITS.commandBytes)\n throw new RangeError(\"Terminal input exceeds the 64 KiB message limit. Send a smaller amount of text.\");\n}\n\nfunction integer(value: unknown, name: string, min = 0, max = Number.MAX_SAFE_INTEGER): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max) {\n throw new Error(`Invalid ${name}: ${String(value)}`);\n }\n return value;\n}\n\nfunction array(value: unknown, name: string, limit: number): asserts value is unknown[] {\n if (!Array.isArray(value) || value.length > limit) throw new Error(`Invalid ${name}`);\n}\n\nfunction key(value: unknown): asserts value is string {\n if (typeof value !== \"string\" || !value.length || value.length > 1024) {\n throw new Error(\"Invalid image key\");\n }\n}\n\nfunction rowId(value: unknown, name: string): asserts value is string {\n if (typeof value !== \"string\" || !/^[1-9][0-9]{0,18}$/u.test(value) || BigInt(value) > 9223372036854775807n) {\n throw new Error(`Invalid ${name}`);\n }\n}\n\nexport function validateHistory(history: unknown, columns: number, rows: number): asserts history is HistoryMetadata | null {\n if (history === null) return;\n if (!isRecord(history)) throw new Error(\"Missing history metadata\");\n rowId(history.generation, \"history generation\");\n if (history.buffer !== \"main\" && history.buffer !== \"alternate\") throw new Error(\"Invalid history buffer\");\n const totalRows = integer(history.totalRows, \"history total rows\", rows, 2147483647);\n const liveTop = integer(history.liveTop, \"history live top\", 0, totalRows - rows);\n if (liveTop !== totalRows - rows) throw new Error(\"Inconsistent history extent\");\n integer(history.top, \"history top\", 0, liveTop);\n if (typeof history.following !== \"boolean\" || (history.following && history.top !== history.liveTop)) {\n throw new Error(\"Invalid history following state\");\n }\n integer(history.requestId, \"viewport request id\");\n array(history.rowIds, \"viewport row ids\", rows);\n if (history.rowIds.length !== rows || new Set(history.rowIds).size !== rows) throw new Error(\"Invalid viewport row ids\");\n for (const id of history.rowIds) rowId(id, \"viewport row id\");\n const selection = history.selection;\n if (!isRecord(selection) || typeof selection.status !== \"string\" ||\n ![\"none\", \"valid\", \"invalidated\"].includes(selection.status)) throw new Error(\"Invalid selection status\");\n integer(selection.requestId, \"selection request id\");\n if (typeof selection.mode !== \"string\" || ![\"character\", \"word\", \"line\", \"rectangle\"].includes(selection.mode)) throw new Error(\"Invalid selection mode\");\n array(selection.ranges, \"selection ranges\", rows);\n let previousRow = -1;\n for (const range of selection.ranges) {\n if (!isRecord(range)) throw new Error(\"Invalid selection range\");\n const row = integer(range.row, \"selection range row\", previousRow + 1, rows - 1);\n const startColumn = integer(range.startColumn, \"selection start column\", 0, columns - 1);\n integer(range.endColumn, \"selection end column\", startColumn + 1, columns);\n previousRow = row;\n }\n validateSelectionText(selection);\n if (selection.status !== \"valid\" && selection.ranges.length) throw new Error(\"Inactive selection has highlight ranges\");\n if (history.copy !== null) {\n if (!isRecord(history.copy)) throw new Error(\"Missing copy metadata\");\n integer(history.copy.requestId, \"copy request id\", 1);\n validateSelectionText(history.copy);\n }\n}\n\nfunction validateSelectionText(selection: Record): asserts selection is Record & SelectionText {\n if (typeof selection.status !== \"string\" || ![\"none\", \"valid\", \"invalidated\"].includes(selection.status) ||\n (selection.status === \"valid\"\n ? typeof selection.text !== \"string\" || selection.text.length > LIMITS.metadataBytes\n : selection.text !== null)) {\n throw new Error(\"Invalid selection text\");\n }\n}\n\nfunction validateMetadata(metadata: unknown): asserts metadata is FrameMetadata {\n if (!isRecord(metadata) || metadata.version !== 1 || typeof metadata.full !== \"boolean\") {\n throw new Error(\"Unsupported frame metadata version\");\n }\n integer(metadata.revision, \"revision\", 1);\n integer(metadata.baseRevision, \"base revision\");\n if (typeof metadata.title !== \"string\" || metadata.title.length > LIMITS.titleUnits ||\n /[\\u0000-\\u001f\\u007f-\\u009f\\ud800-\\udfff]/u.test(metadata.title)) {\n throw new Error(\"Invalid terminal title\");\n }\n const progress = metadata.progress;\n if (!isRecord(progress) || typeof progress.state !== \"string\" ||\n ![\"none\", \"normal\", \"error\", \"indeterminate\", \"warning\"].includes(progress.state)) {\n throw new Error(\"Invalid terminal progress\");\n }\n if (progress.state === \"none\" || progress.state === \"indeterminate\") {\n if (progress.percentage !== null) throw new Error(\"Unexpected terminal progress percentage\");\n } else {\n integer(progress.percentage, \"terminal progress percentage\", 0, 100);\n }\n const shell = metadata.shellIntegration;\n if (!isRecord(shell) || typeof shell.phase !== \"string\" ||\n ![\"unknown\", \"prompt\", \"commandLine\", \"executing\", \"finished\"].includes(shell.phase)) {\n throw new Error(\"Invalid terminal shell integration\");\n }\n if (shell.lastExitCode !== null)\n integer(shell.lastExitCode, \"terminal shell exit code\", -2147483648, 2147483647);\n if (shell.phase === \"unknown\" && shell.lastExitCode !== null)\n throw new Error(\"Unknown terminal shell phase has an exit code\");\n const columns = integer(metadata.columns, \"columns\", 1, 1024);\n const rows = integer(metadata.rows, \"rows\", 1, 512);\n if (typeof metadata.mouseTracking !== \"number\" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) {\n throw new Error(\"Unsupported mouse tracking mode\");\n }\n if (!isRecord(metadata.peer) || typeof metadata.peer.isPrimary !== \"boolean\") throw new Error(\"Invalid peer state\");\n for (const field of [\"id\", \"primaryId\"]) {\n const id = metadata.peer[field];\n if (id !== null && (typeof id !== \"string\" || !id.length || id.length > 256)) {\n throw new Error(`Invalid peer ${field}`);\n }\n }\n if (metadata.peer.id !== null && metadata.peer.isPrimary !== (metadata.peer.id === metadata.peer.primaryId)) {\n throw new Error(\"Inconsistent primary peer state\");\n }\n integer(columns * rows, \"cell count\", 1, LIMITS.cells);\n validateHistory(metadata.history, columns, rows);\n array(metadata.hyperlinks, \"hyperlinks\", columns * rows);\n let previousLinkEnd = 0;\n for (const link of metadata.hyperlinks) {\n if (!isRecord(link)) throw new Error(\"Invalid hyperlink\");\n const row = integer(link.row, \"hyperlink row\", 0, rows - 1);\n const start = integer(link.startColumn, \"hyperlink start column\", 0, columns - 1);\n const end = integer(link.endColumn, \"hyperlink end column\", start + 1, columns);\n if (row * columns + start < previousLinkEnd) throw new Error(\"Unordered or overlapping hyperlinks\");\n previousLinkEnd = row * columns + end;\n if (typeof link.uri !== \"string\" || !link.uri.length || link.uri.length > LIMITS.metadataBytes)\n throw new Error(\"Invalid hyperlink URI\");\n }\n if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) {\n throw new Error(\"This spike requires server geometry of 10 × 20 logical pixels\");\n }\n for (const field of [\"defaultBackground\", \"defaultForeground\"]) {\n if (metadata[field] !== undefined) integer(metadata[field], field, 0, 0xffffffff);\n }\n if (!isRecord(metadata.cursor) || typeof metadata.cursor.visible !== \"boolean\") throw new Error(\"Invalid cursor\");\n integer(metadata.cursor.x, \"cursor x\", -1, 1024);\n integer(metadata.cursor.y, \"cursor y\", -1, 512);\n const shapes = [\"Default\", \"BlinkingBlock\", \"SteadyBlock\", \"BlinkingUnderline\", \"SteadyUnderline\", \"BlinkingBar\", \"SteadyBar\"];\n if (typeof metadata.cursor.shape === \"string\") metadata.cursor.shape = shapes.indexOf(metadata.cursor.shape);\n integer(metadata.cursor.shape, \"cursor shape\", 0, 6);\n array(metadata.images, \"images\", LIMITS.images);\n array(metadata.retainedImages, \"retained image keys\", LIMITS.images);\n array(metadata.placements, \"placements\", LIMITS.placements);\n array(metadata.warnings, \"warnings\", 256);\n if (metadata.warnings.some(w => typeof w !== \"string\")) throw new Error(\"Invalid warning\");\n if (!isRecord(metadata.stats)) throw new Error(\"Invalid server metrics\");\n for (const field of [\"workloadBytes\", \"outputBatches\", \"captureMs\", \"elapsedMs\"]) {\n const metric = metadata.stats[field];\n if (typeof metric !== \"number\" || !Number.isFinite(metric) || metric < 0) {\n throw new Error(`Invalid server metric ${field}`);\n }\n }\n const retained = new Set();\n for (const imageKey of metadata.retainedImages) {\n key(imageKey);\n if (retained.has(imageKey)) throw new Error(\"Duplicate retained image key\");\n retained.add(imageKey);\n }\n const imageKeys = new Set();\n let decodedImageBytes = 0;\n for (const image of metadata.images) {\n if (!isRecord(image)) throw new Error(\"Invalid image\");\n key(image.key);\n if (imageKeys.has(image.key) || !retained.has(image.key)) throw new Error(\"Inconsistent new image keys\");\n imageKeys.add(image.key);\n const width = integer(image.width, \"image width\", 1, 16384);\n const height = integer(image.height, \"image height\", 1, 16384);\n const byteLength = integer(image.byteLength, \"image byte length\", 1, LIMITS.frameBytes);\n if (image.format !== \"rgba\" && image.format !== \"png\") throw new Error(\"Unsupported image format\");\n if (image.format === \"rgba\" && byteLength !== width * height * 4) {\n throw new Error(\"RGBA image size mismatch\");\n }\n decodedImageBytes += width * height * 4;\n if (decodedImageBytes > LIMITS.textureBytes) throw new Error(\"New images exceed decoded texture budget\");\n }\n for (const placement of metadata.placements) {\n if (!isRecord(placement)) throw new Error(\"Invalid placement\");\n key(placement.key);\n if (!retained.has(placement.key)) throw new Error(\"Placement references an unretained image\");\n if (placement.kind !== \"kgp\" && placement.kind !== \"sixel\") throw new Error(\"Invalid placement kind\");\n for (const field of [\"x\", \"y\", \"width\", \"height\", \"sourceX\", \"sourceY\", \"sourceWidth\", \"sourceHeight\", \"clipX\", \"clipY\", \"clipWidth\", \"clipHeight\", \"z\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || !Number.isFinite(coordinate) || Math.abs(coordinate) > 2147483648) {\n throw new Error(`Invalid placement ${field}`);\n }\n }\n for (const field of [\"width\", \"height\", \"sourceWidth\", \"sourceHeight\", \"clipWidth\", \"clipHeight\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || coordinate < 0) throw new Error(`Negative placement ${field}`);\n }\n }\n}\n\n/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */\nexport function decodeFrame(buffer: unknown): TerminalFrame {\n if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.frameBytes) {\n throw new Error(\"Invalid or oversized binary frame\");\n }\n const view = new DataView(buffer);\n let offset = 0;\n const requireBytes = (count: number) => {\n if (count < 0 || count > view.byteLength - offset) throw new Error(\"Truncated HWT1 frame\");\n };\n const u32 = () => { requireBytes(4); const n = view.getUint32(offset, true); offset += 4; return n; };\n if (u32() !== 0x31545748) throw new Error(\"Unsupported frame magic (expected HWT1)\");\n const metadataLength = integer(u32(), \"metadata length\", 2, LIMITS.metadataBytes);\n requireBytes(metadataLength);\n const metadata: unknown = JSON.parse(utf8.decode(new Uint8Array(buffer, offset, metadataLength)));\n offset += metadataLength;\n validateMetadata(metadata);\n const cellCount = metadata.columns * metadata.rows;\n const changedCount = integer(u32(), \"changed cell count\", 0, cellCount);\n if (changedCount > Math.floor((view.byteLength - offset) / 22)) throw new Error(\"Truncated cell records\");\n if (metadata.full && changedCount !== cellCount) throw new Error(\"Incomplete full frame\");\n const cells = [];\n const seen = new Set();\n for (let i = 0; i < changedCount; i++) {\n requireBytes(22);\n const index = u32();\n if (index >= cellCount || seen.has(index)) throw new Error(\"Invalid or duplicate cell index\");\n seen.add(index);\n const foreground = u32();\n const background = u32();\n const underlineColor = u32();\n const attributes = view.getUint16(offset, true);\n const width = view.getUint8(offset + 2);\n const underlineStyle = view.getUint8(offset + 3);\n const textLength = view.getUint16(offset + 4, true);\n offset += 6;\n if (underlineStyle > 5) throw new Error(\"Unsupported underline style\");\n requireBytes(textLength);\n const text = utf8.decode(new Uint8Array(buffer, offset, textLength));\n offset += textLength;\n cells.push({ index, foreground, background, underlineColor, attributes, width, underlineStyle, text });\n }\n const imageBytes = metadata.images.reduce((total, image) => total + image.byteLength, 0);\n if (imageBytes !== view.byteLength - offset) throw new Error(\"Image payload length mismatch\");\n const images = metadata.images.map(image => {\n const bytes = new Uint8Array(buffer, offset, image.byteLength);\n offset += image.byteLength;\n return { ...image, bytes };\n });\n return { metadata, cells, images };\n}\n\n/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */\nexport function screenText(cells: readonly (TerminalCell | undefined)[], columns: number, rows: number): string {\n const lines = [];\n for (let y = 0; y < rows; y++) {\n let line = \"\";\n for (let x = 0; x < columns; x++) {\n const cell = cells[y * columns + x];\n if (!cell || cell.width === 0) continue;\n line += cell.attributes & 64 ? \" \".repeat(cell.width) : (cell.text || \" \");\n }\n lines.push(line.replace(/ +$/u, \"\"));\n }\n return lines.join(\"\\n\");\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js index bb765c2a87c..255f3f493e2 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js @@ -123,7 +123,9 @@ async function drawFrame() { type: "geometry", columns: metadata.columns, rows: metadata.rows, cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight, mouseTracking: metadata.mouseTracking, peer: metadata.peer, - history: metadata.history, revision: frame.revision, text, hyperlinks: metadata.hyperlinks + history: metadata.history, revision: frame.revision, title: metadata.title, + progress: metadata.progress, shellIntegration: metadata.shellIntegration, + text, hyperlinks: metadata.hyperlinks }); send({ type: "ack", revision: frame.revision }); emitStats(text); diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map index 47b888ed8dd..428131abf3f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map @@ -1 +1 @@ -{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1011, \"Browser renderer failed\");\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, text, hyperlinks: metadata.hyperlinks\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n socket.addEventListener(\"error\", () => fail(new Error(\"WebSocket connection failed; verify the demo server is running\")));\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"disconnected\" });\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file +{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1011, \"Browser renderer failed\");\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, title: metadata.title,\n progress: metadata.progress, shellIntegration: metadata.shellIntegration,\n text, hyperlinks: metadata.hyperlinks\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n socket.addEventListener(\"error\", () => fail(new Error(\"WebSocket connection failed; verify the demo server is running\")));\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"disconnected\" });\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts index 17a3ff20478..84ba68bd19f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts @@ -267,6 +267,21 @@ export interface SelectionUIDetail extends SelectionUIState { readonly rects: readonly Readonly[]; } export type SelectionUIEvent = CustomEvent; +/** Application-reported OSC 9;4 indicator, independent of shell execution. */ +export type TerminalProgressState = "none" | "normal" | "error" | "indeterminate" | "warning"; +/** Immutable current progress; a hidden or indeterminate indicator has no percentage. */ +export interface TerminalProgress { + readonly state: TerminalProgressState; + readonly percentage: number | null; +} +/** Last reported OSC 133 phase. Unknown does not mean idle; commandLine is not execution. */ +export type TerminalShellIntegrationPhase = "unknown" | "prompt" | "commandLine" | "executing" | "finished"; +/** Current shell phase and latest reported completion status, not command history. */ +export interface TerminalShellIntegration { + readonly phase: TerminalShellIntegrationPhase; + /** Null means no reported status, not success. Preserved across the next prompt/command. */ + readonly lastExitCode: number | null; +} export interface WebTerminalOptions extends InputPolicyOptions { url: string | URL; /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */ @@ -283,6 +298,25 @@ export interface WebTerminalOptions extends InputPolicyOptions { onGeometry?: (geometry: TerminalGeometry) => void; onSizingChange?: (sizing: TerminalSizingState) => void; onRoleChange?: (peer: TerminalPeer) => void; + /** + * Receives the first authoritative presented title (including "") before mount resolves, + * then distinct presented changes. The title getter is updated first. Titles are untrusted + * text; render with textContent, not HTML. No notifications after disposal. + */ + onTitleChange?: (title: string) => void; + /** + * Receives the first authoritative presented progress before mount resolves, then distinct + * presented changes. Both activity getters update before either callback. Intermediate + * states may coalesce; this is not a callback for every OSC sequence. None hides the indicator. + * No notifications after disposal; connection loss does not manufacture a progress clear. + */ + onProgressChange?: (progress: TerminalProgress) => void; + /** + * Receives the first authoritative presented shell state before mount resolves, then distinct + * presented changes. This is not a lossless command-start/finish stream: entire commands may + * occur between frames. Replays provide current state, never synthetic command executions. + */ + onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void; onStats?: (stats: TerminalStats, text: string | undefined) => void; onViewportChange?: (viewport: TerminalViewport) => void; onSelectionChange?: (selection: TerminalSelection) => void; @@ -296,6 +330,12 @@ export interface WebTerminalHandle { readonly geometry: TerminalGeometry; readonly peer: TerminalPeer; readonly connected: boolean; + /** Current presented workload title, or "" when unset/cleared. Retained on disconnect/dispose. */ + readonly title: string; + /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */ + readonly progress: TerminalProgress; + /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */ + readonly shellIntegration: TerminalShellIntegration; readonly stats: TerminalStats; readonly screenText: string; readonly sizing: TerminalSizingState; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map index fa459df5618..2c9b7b74b1b 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map index c52ad84c24b..9af45425be8 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\n/** Application-reported OSC 9;4 indicator, independent of shell execution. */\nexport type TerminalProgressState = \"none\" | \"normal\" | \"error\" | \"indeterminate\" | \"warning\";\n/** Immutable current progress; a hidden or indeterminate indicator has no percentage. */\nexport interface TerminalProgress {\n readonly state: TerminalProgressState;\n readonly percentage: number | null;\n}\n/** Last reported OSC 133 phase. Unknown does not mean idle; commandLine is not execution. */\nexport type TerminalShellIntegrationPhase = \"unknown\" | \"prompt\" | \"commandLine\" | \"executing\" | \"finished\";\n/** Current shell phase and latest reported completion status, not command history. */\nexport interface TerminalShellIntegration {\n readonly phase: TerminalShellIntegrationPhase;\n /** Null means no reported status, not success. Preserved across the next prompt/command. */\n readonly lastExitCode: number | null;\n}\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n /**\n * Receives the first authoritative presented title (including \"\") before mount resolves,\n * then distinct presented changes. The title getter is updated first. Titles are untrusted\n * text; render with textContent, not HTML. No notifications after disposal.\n */\n onTitleChange?: (title: string) => void;\n /**\n * Receives the first authoritative presented progress before mount resolves, then distinct\n * presented changes. Both activity getters update before either callback. Intermediate\n * states may coalesce; this is not a callback for every OSC sequence. None hides the indicator.\n * No notifications after disposal; connection loss does not manufacture a progress clear.\n */\n onProgressChange?: (progress: TerminalProgress) => void;\n /**\n * Receives the first authoritative presented shell state before mount resolves, then distinct\n * presented changes. This is not a lossless command-start/finish stream: entire commands may\n * occur between frames. Replays provide current state, never synthetic command executions.\n */\n onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n /** Current presented workload title, or \"\" when unset/cleared. Retained on disconnect/dispose. */\n readonly title: string;\n /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */\n readonly progress: TerminalProgress;\n /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */\n readonly shellIntegration: TerminalShellIntegration;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts index b277ed2e0f7..9fcd77a5c10 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts @@ -1,4 +1,4 @@ -import type { CopySelectionOptions, InputActionHandler, InputBinding, TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport, WebTerminalHandle, WebTerminalOptions } from "./types.js"; +import type { CopySelectionOptions, InputActionHandler, InputBinding, TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport, TerminalProgress, TerminalShellIntegration, WebTerminalHandle, WebTerminalOptions } from "./types.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; /** * First-party HWT1 client. Owns only the element it appends, not the caller's @@ -13,6 +13,10 @@ export declare class WebTerminal implements WebTerminalHandle { get geometry(): TerminalGeometry; get peer(): TerminalPeer; get connected(): boolean; + /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */ + get title(): string; + get progress(): TerminalProgress; + get shellIntegration(): TerminalShellIntegration; get stats(): TerminalStats; get screenText(): string; get sizing(): TerminalSizingState; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map index 128d5a36191..b9f7bddd47f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js index ff165346f7d..b2f60b94849 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js @@ -46,6 +46,11 @@ export class WebTerminal { #readyTimer; #stats = {}; #screenText = ""; + #title = ""; + #hasTitle = false; + #progress = { state: "none", percentage: null }; + #shellIntegration = { phase: "unknown", lastExitCode: null }; + #hasActivity = false; #history; #highlights; #inspection; @@ -106,6 +111,10 @@ export class WebTerminal { get geometry() { return { ...this.#geometry }; } get peer() { return { ...this.#peer }; } get connected() { return this.#connected; } + /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */ + get title() { return this.#title; } + get progress() { return { ...this.#progress }; } + get shellIntegration() { return { ...this.#shellIntegration }; } get stats() { return { ...this.#stats }; } get screenText() { return this.#screenText; } get sizing() { return { ...this.#sizing }; } @@ -323,6 +332,24 @@ export class WebTerminal { if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) { this.#options.onRoleChange?.(this.peer); } + if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) { + const titleChanged = !this.#hasTitle || this.#title !== message.title; + const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state || + this.#progress.percentage !== message.progress.percentage; + const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase || + this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode; + this.#title = message.title; + this.#hasTitle = true; + this.#progress = { ...message.progress }; + this.#shellIntegration = { ...message.shellIntegration }; + this.#hasActivity = true; + if (titleChanged) + this.#options.onTitleChange?.(this.#title); + if (!this.#disposed && progressChanged) + this.#options.onProgressChange?.(this.progress); + if (!this.#disposed && shellChanged) + this.#options.onShellIntegrationChange?.(this.shellIntegration); + } } else if (message.type === "history") { this.#screenText = message.text; @@ -332,7 +359,8 @@ export class WebTerminal { this.#stats = message.stats; if (message.text !== undefined) this.#screenText = message.text; - if (message.stats.revision > 0 && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) { + if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected && + (this.#peer.id !== null || this.#peer.isPrimary)) { clearTimeout(this.#readyTimer); this.#ready.resolve(this); } diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map index 6397e43f22d..fae35655d53 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,\n openHyperlink: uri => {\n try { window.open(uri, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n }\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"disconnected\") {\n this.#disconnect();\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#connected) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#options.readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try { return this.#policy.resolve(Object.freeze(input), this.inputContext); }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#options.readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file +{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n TerminalProgress, TerminalShellIntegration, WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #title = \"\";\n #hasTitle = false;\n #progress: TerminalProgress = { state: \"none\", percentage: null };\n #shellIntegration: TerminalShellIntegration = { phase: \"unknown\", lastExitCode: null };\n #hasActivity = false;\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */\n get title(): string { return this.#title; }\n get progress(): TerminalProgress { return { ...this.#progress }; }\n get shellIntegration(): TerminalShellIntegration { return { ...this.#shellIntegration }; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,\n openHyperlink: uri => {\n try { window.open(uri, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n }\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"disconnected\") {\n this.#disconnect();\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n const titleChanged = !this.#hasTitle || this.#title !== message.title;\n const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state ||\n this.#progress.percentage !== message.progress.percentage;\n const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase ||\n this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode;\n this.#title = message.title;\n this.#hasTitle = true;\n this.#progress = { ...message.progress };\n this.#shellIntegration = { ...message.shellIntegration };\n this.#hasActivity = true;\n if (titleChanged) this.#options.onTitleChange?.(this.#title);\n if (!this.#disposed && progressChanged) this.#options.onProgressChange?.(this.progress);\n if (!this.#disposed && shellChanged) this.#options.onShellIntegrationChange?.(this.shellIntegration);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected &&\n (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#connected) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#options.readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try { return this.#policy.resolve(Object.freeze(input), this.inputContext); }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#options.readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts index 12f68c23878..cf2554cc618 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts @@ -1,4 +1,4 @@ -import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel } from "./types.js"; +import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration } from "./types.js"; export type SelectionText = { status: "valid"; text: string; @@ -75,6 +75,9 @@ export interface FrameMetadata extends TerminalGeometry { baseRevision: number; peer: TerminalPeer; history: HistoryMetadata | null; + title: string; + progress: TerminalProgress; + shellIntegration: TerminalShellIntegration; defaultBackground?: number; defaultForeground?: number; cursor: { @@ -212,6 +215,9 @@ export type WorkerOutputMessage = { peer: TerminalPeer; history: HistoryMetadata | null; revision: number; + title: string; + progress: TerminalProgress; + shellIntegration: TerminalShellIntegration; text: string; hyperlinks: HyperlinkRange[]; } & TerminalGeometry) | { diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map index fe5f2dfde00..e1a49adbd7b 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map index 6c4355dd457..9072c6a7317 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" | \"disconnected\" }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file +{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n title: string;\n progress: TerminalProgress;\n shellIntegration: TerminalShellIntegration;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" | \"disconnected\" }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration;\n text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index 8af24c071c0..d7abae64dcd 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.167.0-alpha.1522.1.3085d8b", + "version": "0.167.0-alpha.1547.1.798b26c", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 9a8b894fee2..ccdf8a7d63d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -599,7 +599,7 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.167.0-alpha.1522.1.3085d8b"); + assert.equal(version, "0.167.0-alpha.1547.1.798b26c"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs index 069908be5f9..8c85fa2cc70 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -323,9 +323,15 @@ public async Task BrowserView_ProducerDisconnectClosesBrowserWhileWaitingForAckn await host.StartAsync(timeout.Token); using var browser = await host.ConnectBrowserAsync(timeout.Token); var buffer = new byte[64 * 1024]; - var initial = await browser.ReceiveAsync(buffer, timeout.Token); - Assert.Equal(WebSocketMessageType.Binary, initial.MessageType); - Assert.True(initial.EndOfMessage); + // A snapshot can exceed one receive buffer. Drain the complete message without + // acknowledging it so the producer disconnect happens while the next frame waits. + WebSocketReceiveResult initial; + do + { + initial = await browser.ReceiveAsync(buffer, timeout.Token); + Assert.Equal(WebSocketMessageType.Binary, initial.MessageType); + } + while (!initial.EndOfMessage); await host.Presentation.DisposeAsync(); From dcd134847e068437c555e697b8e7e7291634c3cb Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 10 Sep 2026 21:31:20 +1000 Subject: [PATCH 056/106] Complete terminal rendering, fitting, and tape automation Adopt paired Hex1b lifecycle and read-only APIs, preserve font size while fitting docked and detached terminals, and add CLI tape playback with playground examples. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- Directory.Packages.props | 2 +- NuGet.config | 4 + docs/specs/with-terminal.md | 108 ++++- playground/Terminals/repl-basics.tape | 29 ++ playground/Terminals/shell-basics.tape | 29 ++ src/Aspire.Cli/Aspire.Cli.csproj | 3 + .../Commands/TerminalAttachCommand.cs | 89 +--- src/Aspire.Cli/Commands/TerminalCommand.cs | 5 +- .../Commands/TerminalResourceResolver.cs | 84 ++++ .../Commands/TerminalTapeCommand.cs | 21 + .../Commands/TerminalTapePlayCommand.cs | 250 ++++++++++++ src/Aspire.Cli/KnownFeatures.cs | 2 +- src/Aspire.Cli/Program.cs | 3 + .../TerminalCommandStrings.Designer.cs | 188 +++++++++ .../Resources/TerminalCommandStrings.resx | 87 ++++ .../xlf/TerminalCommandStrings.cs.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.de.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.es.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.fr.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.it.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.ja.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.ko.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.pl.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.pt-BR.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.ru.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.tr.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.zh-Hans.xlf | 127 ++++++ .../xlf/TerminalCommandStrings.zh-Hant.xlf | 127 ++++++ .../Components/Controls/TerminalView.razor | 10 +- .../Components/Controls/TerminalView.razor.cs | 48 ++- .../Components/Controls/TerminalView.razor.js | 212 +++++----- .../Dialogs/InteractionsInputDialog.razor | 1 + .../Components/Layout/TerminalDock.razor | 4 +- .../Components/Layout/TerminalDock.razor.cs | 11 +- .../Components/Pages/ConsoleLogs.razor.cs | 3 +- .../Components/Pages/TerminalWindow.razor | 5 +- .../Components/Pages/TerminalWindow.razor.cs | 8 +- .../Model/TerminalWindowLauncher.cs | 11 + .../Terminal/TerminalWebSocketProxy.cs | 119 +++--- src/Aspire.Dashboard/package-lock.json | 8 +- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 19 +- .../wwwroot/js/hex1b-web-terminal/README.md | 106 ++++- .../dist/terminal-worker.js | 13 +- .../dist/terminal-worker.js.map | 2 +- .../js/hex1b-web-terminal/dist/types.d.ts | 28 ++ .../js/hex1b-web-terminal/dist/types.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/types.js.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.d.ts | 3 + .../dist/web-terminal.d.ts.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.js | 88 +++- .../dist/web-terminal.js.map | 2 +- .../hex1b-web-terminal/dist/wire-types.d.ts | 7 +- .../dist/wire-types.d.ts.map | 2 +- .../hex1b-web-terminal/dist/wire-types.js.map | 2 +- .../js/hex1b-web-terminal/package.json | 2 +- .../Commands/TerminalCommandTests.cs | 25 +- .../Commands/TerminalTapePlayCommandTests.cs | 383 ++++++++++++++++++ ...olvesTextOutputBesideRootTape.verified.txt | 62 +++ .../TerminalCommandTestServices.cs | 41 ++ .../TestServices/TerminalTapeTestHost.cs | 133 ++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 3 + .../Controls/TerminalViewTests.cs | 78 +++- .../Dialogs/InteractionsInputDialogTests.cs | 2 + .../JavaScript/TerminalView.test.mjs | 277 ++++++++++--- .../Layout/TerminalDockTests.cs | 47 +++ .../Pages/ConsoleLogsTerminalTests.cs | 37 ++ .../Pages/TerminalWindowTests.cs | 26 ++ .../Shared/TerminalSetupHelpers.cs | 4 +- .../Playwright/TerminalDockTests.cs | 62 ++- .../Integration/Playwright/TerminalTests.cs | 63 ++- .../Shared/TerminalTestHost.cs | 13 +- .../Terminal/TerminalWebSocketTests.cs | 179 ++++++-- 73 files changed, 4231 insertions(+), 481 deletions(-) create mode 100644 playground/Terminals/repl-basics.tape create mode 100644 playground/Terminals/shell-basics.tape create mode 100644 src/Aspire.Cli/Commands/TerminalResourceResolver.cs create mode 100644 src/Aspire.Cli/Commands/TerminalTapeCommand.cs create mode 100644 src/Aspire.Cli/Commands/TerminalTapePlayCommand.cs create mode 100644 src/Aspire.Cli/Resources/TerminalCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/TerminalCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.zh-Hant.xlf create mode 100644 tests/Aspire.Cli.Tests/Commands/TerminalTapePlayCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Snapshots/TerminalTapePlayCommandTests.ResolvesTextOutputBesideRootTape.verified.txt create mode 100644 tests/Aspire.Cli.Tests/TestServices/TerminalCommandTestServices.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TerminalTapeTestHost.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index fdf6480e92e..703c5dd5189 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -116,7 +116,7 @@ - + diff --git a/NuGet.config b/NuGet.config index 81c39d8cc61..42f26e6b585 100644 --- a/NuGet.config +++ b/NuGet.config @@ -22,8 +22,12 @@ + + + + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 3597a510b56..6e7a788f7e8 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -124,32 +124,32 @@ HMP byte stream from `AttachTerminal`, tunneled over the existing dashboard gRPC connection. Closing a viewer releases only that attachment; the creator continues to own the terminal. -The paired web client does not expose WebSocket close details or a native -completed-terminal state. As a temporary workaround, an authoritative gRPC -`Ended` notification leaves the browser connection in place until the user -closes the view. Input is no longer forwarded, and no Aspire-specific messages -are injected into HWT. This also avoids automatic reconnect attempts against a -completed process. Completion before the initial handshake can leave an empty -view; preserving the final screen and displaying an explicit ended indicator -are not guaranteed by this workaround. If the native mount times out before -its first frame, the adapter asks its existing Blazor component whether this -view has received authoritative completion before deciding to retry. An -unavailable Blazor circuit surfaces an error requiring explicit retry rather -than guessing. An actual transport failure remains retryable rather than -being treated as completion. +An authoritative gRPC `Ended` notification closes the viewer's WebSocket with +Aspire's private application close code `4000`, including completion before the +initial HMP handshake. This is an Aspire endpoint contract, not a Hex1b close +code or an Aspire-specific HWT message. The browser observes it through native +`onClose` and leaves the tab or dialog visible without reconnecting. Completion +before the first frame can leave an empty view; an already mounted view keeps +its last available projection, without guaranteeing a final frame. Normal +closure (`1000`), abnormal transport loss (`1006`), close reason strings, and +`wasClean` do not indicate producer completion and remain retryable. Each component registers a separate input policy with the dashboard and passes its opaque `viewId` with the WebSocket URL. Changes to an interaction's disabled state update that policy before updating browser input behavior. The bridge -rejects input, pointer, paste, resize, and primary-role requests for read-only -views while allowing output, acknowledgements, selection, copy, and history. +applies `Hwt1PresentationAdapter.IsReadOnly` before dispatching each complete +command, delegating validation and input gating to Hex1b. The browser uses +`setReadOnly` without remounting; native gating also cancels held pointers, +queued gestures and pending clipboard pastes, including direct paste/action +calls. Read-only views retain output, acknowledgements, selection, copy, and +history while connected. Already accepted or in-flight commands cannot be recalled. This is a per-view presentation policy, not a new user authorization boundary: it does not lock the terminal, its creator's automation, or other viewers. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.167.0-alpha.1547.1.798b26c`. HWT1 is experimental state transfer +exactly `0.167.0-alpha.1549.1.496ccf5`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. @@ -213,7 +213,10 @@ rendered inside the toolbar's options (⋯) `AspireMenuButton`: The terminal frame keeps font decrease/increase buttons, the current font size, and the live columns-by-rows selector together in its bottom-right -footer. The selector offers Fit mode and predefined terminal dimensions, +footer. A separate Fit button switches to container-sized rows and columns +without changing font size, and is disabled while the view is already the +auto-sized primary. The selector offers predefined terminal dimensions +and displays the current grid, keeping both sizing operations available without opening the page options menu. A terminal starts at 132×50. A viewer adopts the producer's current dimensions. Ordinary keyboard and paste input do not take resize control; @@ -222,19 +225,88 @@ changing the grid. The bottom-left footer hint advertises F6, which m keyboard focus from terminal input to the footer controls; Shift+F6 moves focus to the preceding dashboard control. +Dock panes, interaction dialogs and detached windows automatically fit when +opened. A detached window takes primary once, carrying the originating view's +selected font size rather than its grid dimensions. Its font preference can +then change independently of the opener. The active dock pane requests resize +control when revealed or returned from a detached window; inactive panes do not take it. +Container resizing then changes rows and columns, not the selected font size. +Read-only views cannot take resize control, and a view that loses primary to +another viewer does not automatically reclaim it. + +Hex1b enforces a minimum grid of 20 columns by 10 rows. If the container is too +small for that grid at the selected font size, the renderer still scales the +text down to fit. + The console log stream is now subscribed to for terminal-enabled resources too (previously it was suppressed), which is what makes the Console view non-empty for a `WithTerminal()` resource. ## CLI -`aspire terminal [--replica N]` (`Aspire.Cli/Commands/TerminalCommand.cs`) +`aspire terminal attach [--replica N]` (`Aspire.Cli/Commands/TerminalAttachCommand.cs`) opens its own `Hmp1WorkloadAdapter` against the consumer UDS path returned by `IBackchannel.GetTerminalInfoAsync(resource, replica)` and renders frames into the host terminal via Hex1b's `Hex1bTerminal`. When the resource has more than one replica and the CLI is interactive, it prompts for a selection; in non-interactive mode the `--replica` flag is required. +### Tape playback + +`aspire terminal tape play --tape-file ` uses Hex1b's +`TapeParser` and `TapePlayer` to execute a VHS `.tape` script against an +existing resource terminal. It requires the same `features.terminalCommandsEnabled` feature +flag as `terminal attach` and `terminal ps`. + +```sh +aspire terminal tape play shell --tape-file ./probe.tape +aspire terminal tape play shell --tape-file ./probe.tape --replica 1 --apphost ./AppHost/AppHost.csproj --timeout 30 +``` + +For example, against an idle Bash shell: + +```text +Set TypingSpeed 0 +Set WaitTimeout 10s +Wait+Line /[$#>]$/ +Type "printf 'ASPIRE_TAPE_%s\n' ready" +Enter +Wait+Screen /ASPIRE_TAPE_ready/ +``` + +Wait for meaningful application output rather than merely the echoed input. +Here the expected marker is deliberately not contiguous in the typed command. +Exit code zero means the tape completed, not that every shell command succeeded. + +The command prints the final plain-text screen to stdout; discovery messages, +warnings, and source-located diagnostics go to stderr. A comment-only or empty +tape reads the current screen after the initial producer snapshot has arrived. +A failed tape command prints its failure screen and returns a nonzero exit code. +`--timeout` defaults to 120 seconds and bounds the HMP connection and playback using +cancellation; capture finalization and cleanup may continue after cancellation. +An overall timeout returns exit code 17, and user cancellation returns 130. + +Playback connects as a secondary HMP peer. It does not request primary ownership, +resize the producer, create a new shell, or stop the resource on completion or +failure. Other viewers and input sources can remain attached; input is not +exclusive, so coordinate playback with other users. A disconnected transport +fails playback rather than retrying potentially non-idempotent input. + +Supported commands follow the pinned Hex1b tape implementation: `Type`, keys and +chords, `Sleep`, `Wait` / `Wait+Line` / `Wait+Screen`, timing and wait settings, +`Source`, text `Output`, and `Hide` / `Show`. For example, +`Wait+Screen@5s /Ready/` sets that wait's timeout. This is not full VHS media or +presentation compatibility: screenshots/video, clipboard actions, scrolling, +font/pixel-size settings, and shell-launch/environment settings are rejected +during preflight before any input is sent. + +`Source` and `.txt` / `.ascii` `Output` paths resolve relative to the root tape +file's directory on the **CLI machine**, including sources nested in other +directories. Output parent directories must already exist, and existing output +files are not overwritten. Included tapes must have the `.tape` extension; +their own `Output` directives are ignored. Text `Output` records per-command +screens, unlike stdout's final screen. + ## DCP integration For each replica of a `WithTerminal()` resource, DCP allocates a pseudo-terminal diff --git a/playground/Terminals/repl-basics.tape b/playground/Terminals/repl-basics.tape new file mode 100644 index 00000000000..79c47648408 --- /dev/null +++ b/playground/Terminals/repl-basics.tape @@ -0,0 +1,29 @@ +# Run from playground/Terminals against an idle playground REPL: +# aspire terminal tape play repl --replica 0 --tape-file repl-basics.tape +# Records screens to repl-basics.txt in this directory. +# Move any previous recording before replaying; existing files are not overwritten. + +Output repl-basics.txt +Set TypingSpeed 50ms +Set WaitTimeout 10s + +Wait+Line /repl#[0-9]+>$/ +Type "help" +Enter +Wait+Screen /Available commands:/ + +Wait+Line /repl#[0-9]+>$/ +Type "whoami" +Enter +Wait+Screen /repl pid [0-9]+/ + +Wait+Line /repl#[0-9]+>$/ +Type "size" +Enter +Wait+Screen /[0-9]+ cols x [0-9]+ rows/ + +Wait+Line /repl#[0-9]+>$/ +Type "time" +Enter +Wait+Screen /[0-9]+-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+/ +Wait+Line /repl#[0-9]+>$/ diff --git a/playground/Terminals/shell-basics.tape b/playground/Terminals/shell-basics.tape new file mode 100644 index 00000000000..9601931a59e --- /dev/null +++ b/playground/Terminals/shell-basics.tape @@ -0,0 +1,29 @@ +# Run from playground/Terminals against the idle Bash resource (Linux/macOS): +# aspire terminal tape play shell --tape-file shell-basics.tape +# Records screens to shell-basics.txt in this directory. +# Move any previous recording before replaying; existing files are not overwritten. + +Output shell-basics.txt +Set TypingSpeed 30ms +Set WaitTimeout 10s + +Wait+Line /[$#>]$/ +Type "printf 'Hello from %s\n' 'Aspire tape playback'" +Enter +Wait+Screen /Hello from Aspire tape playback/ + +Wait+Line /[$#>]$/ +Type "printf 'Arithmetic: 7 + 5 = %s\n' $((7 + 5))" +Enter +Wait+Screen /Arithmetic: 7 [+] 5 = 12/ + +Wait+Line /[$#>]$/ +Type "printf 'Working %s: ' directory; pwd" +Enter +Wait+Screen /Working directory:/ + +Wait+Line /[$#>]$/ +Type "printf 'Sequence %s\n' complete" +Enter +Wait+Screen /Sequence complete/ +Wait+Line /[$#>]$/ diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index f335d1f59b2..df6accc82dd 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -17,6 +17,9 @@ emits code that touches them when we combine our serializer context with McpJsonUtilities.DefaultOptions in BackchannelJsonSerializerContext.cs. Suppress until MCP graduates these types. --> $(NoWarn);CS1591;MCPEXP001 + + $(NoWarn);NU1902;NU1903 true diff --git a/src/Aspire.Cli/Commands/TerminalAttachCommand.cs b/src/Aspire.Cli/Commands/TerminalAttachCommand.cs index 60a0dd19a99..72943e2d368 100644 --- a/src/Aspire.Cli/Commands/TerminalAttachCommand.cs +++ b/src/Aspire.Cli/Commands/TerminalAttachCommand.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Diagnostics; using System.Globalization; using System.Net.Sockets; using Aspire.Cli.Backchannel; @@ -36,6 +35,7 @@ internal sealed class TerminalAttachCommand : BaseCommand private readonly IInteractionService _interactionService; private readonly AppHostConnectionResolver _connectionResolver; + private readonly TerminalResourceResolver _terminalResolver; private readonly ILogger _logger; private static readonly Argument s_resourceArgument = new("resource") @@ -58,6 +58,7 @@ internal sealed class TerminalAttachCommand : BaseCommand public TerminalAttachCommand( AppHostConnectionResolver connectionResolver, + TerminalResourceResolver terminalResolver, ILogger logger, CommonCommandServices services) : base("attach", "Attach the local terminal to an interactive PTY session for a resource.", services) @@ -65,6 +66,7 @@ public TerminalAttachCommand( _interactionService = services.InteractionService; _logger = logger; _connectionResolver = connectionResolver; + _terminalResolver = terminalResolver; Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); @@ -108,45 +110,14 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.Failure(CliExitCodes.AppHostIncompatible); } - var snapshots = await _interactionService.ShowStatusAsync( - "Looking up resource...", - async () => await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false)); - - var matches = ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).ToList(); - if (matches.Count == 0) - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, - "Resource '{0}' was not found.", resourceName)); - return CommandResult.Failure(CliExitCodes.InvalidCommand); - } - - // For replicated resources, all snapshots share the same DisplayName which - // matches the parent resource name (the one carrying the TerminalAnnotation). - // Fall back to Name for non-replicated resources where DisplayName is null/equal. - var canonicalName = !string.IsNullOrEmpty(matches[0].DisplayName) - ? matches[0].DisplayName! - : matches[0].Name; - - var info = await _interactionService.ShowStatusAsync( - "Discovering terminal sessions...", - async () => await connection.GetTerminalInfoAsync(canonicalName, cancellationToken).ConfigureAwait(false)); - - if (!info.IsAvailable || info.Replicas is null || info.Replicas.Length == 0) + var (canonicalName, replica) = await _terminalResolver.ResolveAsync( + connection, resourceName, requestedReplica, cancellationToken).ConfigureAwait(false); + if (replica is null) { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, - "Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started.", - canonicalName)); return CommandResult.Failure(CliExitCodes.InvalidCommand); } - var (replica, selectionError) = await SelectReplicaAsync(info.Replicas, requestedReplica, canonicalName, cancellationToken).ConfigureAwait(false); - if (selectionError != CliExitCodes.Success) - { - return CommandResult.Failure(selectionError); - } - Debug.Assert(replica is not null, "SelectReplicaAsync returns a non-null replica when error == Success."); - - if (!replica!.IsAlive) + if (!replica.IsAlive) { _interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, @@ -206,50 +177,4 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.Success(); } } - - private async Task<(TerminalReplicaInfo? Replica, int ErrorExitCode)> SelectReplicaAsync( - TerminalReplicaInfo[] replicas, - int? requestedReplica, - string canonicalName, - CancellationToken cancellationToken) - { - if (requestedReplica.HasValue) - { - var match = Array.Find(replicas, r => r.ReplicaIndex == requestedReplica.Value); - if (match is null) - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, - "Replica index {0} is not available for resource '{1}'. Available indices: {2}.", - requestedReplica.Value, - canonicalName, - string.Join(", ", replicas.Select(r => r.ReplicaIndex.ToString(CultureInfo.InvariantCulture))))); - return (null, CliExitCodes.InvalidCommand); - } - return (match, CliExitCodes.Success); - } - - if (replicas.Length == 1) - { - return (replicas[0], CliExitCodes.Success); - } - - if (Console.IsInputRedirected || Console.IsOutputRedirected) - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, - "Resource '{0}' has {1} replicas. Pass --replica to choose one in non-interactive mode.", - canonicalName, - replicas.Length)); - return (null, CliExitCodes.InvalidCommand); - } - - var picked = await _interactionService.PromptForSelectionAsync( - string.Format(CultureInfo.CurrentCulture, "Select a replica of '{0}' to attach to:", canonicalName), - replicas, - r => r.IsAlive - ? string.Format(CultureInfo.CurrentCulture, "{0} (running)", r.Label) - : string.Format(CultureInfo.CurrentCulture, "{0} (exited code={1})", r.Label, r.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "unknown"), - cancellationToken: cancellationToken).ConfigureAwait(false); - - return (picked, CliExitCodes.Success); - } } diff --git a/src/Aspire.Cli/Commands/TerminalCommand.cs b/src/Aspire.Cli/Commands/TerminalCommand.cs index 00652609a90..18c9a5a1889 100644 --- a/src/Aspire.Cli/Commands/TerminalCommand.cs +++ b/src/Aspire.Cli/Commands/TerminalCommand.cs @@ -7,7 +7,7 @@ namespace Aspire.Cli.Commands; /// /// Parent command for terminal operations on resources registered with WithTerminal(). -/// Contains subcommands for attaching to interactive terminal sessions. +/// Contains subcommands for listing, attaching to and scripting terminal sessions. /// internal sealed class TerminalCommand : BaseCommand { @@ -16,14 +16,17 @@ internal sealed class TerminalCommand : BaseCommand public TerminalCommand( TerminalAttachCommand attachCommand, TerminalPsCommand psCommand, + TerminalTapeCommand tapeCommand, CommonCommandServices services) : base("terminal", "Manage interactive terminal sessions for resources.", services) { ArgumentNullException.ThrowIfNull(attachCommand); ArgumentNullException.ThrowIfNull(psCommand); + ArgumentNullException.ThrowIfNull(tapeCommand); Subcommands.Add(attachCommand); Subcommands.Add(psCommand); + Subcommands.Add(tapeCommand); } protected override bool UpdateNotificationsEnabled => false; diff --git a/src/Aspire.Cli/Commands/TerminalResourceResolver.cs b/src/Aspire.Cli/Commands/TerminalResourceResolver.cs new file mode 100644 index 00000000000..cc5980eaf86 --- /dev/null +++ b/src/Aspire.Cli/Commands/TerminalResourceResolver.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; + +namespace Aspire.Cli.Commands; + +/// +/// Resolves resource names and replicas consistently for interactive and scripted terminal commands. +/// +internal sealed class TerminalResourceResolver(IInteractionService interactionService) +{ + public async Task<(string ResourceName, TerminalReplicaInfo? Replica)> ResolveAsync( + IAppHostAuxiliaryBackchannel connection, + string resourceName, + int? requestedReplica, + CancellationToken cancellationToken) + { + var snapshots = await interactionService.ShowStatusAsync( + TerminalCommandStrings.LookingUpResource, + async () => await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false)); + + var matches = ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).ToList(); + if (matches.Count == 0) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, + TerminalCommandStrings.ResourceNotFound, resourceName)); + return (resourceName, null); + } + + // Replicas share the parent DisplayName carrying WithTerminal(), rather than their individual names. + var canonicalName = !string.IsNullOrEmpty(matches[0].DisplayName) + ? matches[0].DisplayName! + : matches[0].Name; + var info = await interactionService.ShowStatusAsync( + TerminalCommandStrings.DiscoveringSessions, + async () => await connection.GetTerminalInfoAsync(canonicalName, cancellationToken).ConfigureAwait(false)); + + if (!info.IsAvailable || info.Replicas is not { Length: > 0 } replicas) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, + TerminalCommandStrings.TerminalUnavailable, canonicalName)); + return (canonicalName, null); + } + + if (requestedReplica is { } index) + { + var match = Array.Find(replicas, r => r.ReplicaIndex == index); + if (match is null) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, + TerminalCommandStrings.ReplicaNotFound, index, canonicalName, + string.Join(", ", replicas.Select(r => r.ReplicaIndex.ToString(CultureInfo.InvariantCulture))))); + } + return (canonicalName, match); + } + + if (replicas.Length == 1) + { + return (canonicalName, replicas[0]); + } + + if (Console.IsInputRedirected || Console.IsOutputRedirected) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, + TerminalCommandStrings.ReplicaRequired, canonicalName, replicas.Length)); + return (canonicalName, null); + } + + var picked = await interactionService.PromptForSelectionAsync( + string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.SelectReplica, canonicalName), + replicas, + r => r.IsAlive + ? string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.ReplicaRunning, r.Label) + : string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.ReplicaExited, + r.Label, r.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "unknown"), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return (canonicalName, picked); + } +} diff --git a/src/Aspire.Cli/Commands/TerminalTapeCommand.cs b/src/Aspire.Cli/Commands/TerminalTapeCommand.cs new file mode 100644 index 00000000000..b014a81c53d --- /dev/null +++ b/src/Aspire.Cli/Commands/TerminalTapeCommand.cs @@ -0,0 +1,21 @@ +// 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.Resources; + +namespace Aspire.Cli.Commands; + +internal sealed class TerminalTapeCommand : BaseCommand +{ + internal override HelpGroup HelpGroup => HelpGroup.Monitoring; + + public TerminalTapeCommand(TerminalTapePlayCommand playCommand, CommonCommandServices services) + : base("tape", TerminalCommandStrings.TapeDescription, services) + { + Subcommands.Add(playCommand); + } + + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + => Task.FromResult(CommandResult.DisplayHelp()); +} diff --git a/src/Aspire.Cli/Commands/TerminalTapePlayCommand.cs b/src/Aspire.Cli/Commands/TerminalTapePlayCommand.cs new file mode 100644 index 00000000000..8571011443e --- /dev/null +++ b/src/Aspire.Cli/Commands/TerminalTapePlayCommand.cs @@ -0,0 +1,250 @@ +// 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 System.Globalization; +using System.Net.Sockets; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Hex1b; +using Hex1b.Automation; +using Hex1b.Tokens; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +/// +/// Plays a tape against an existing resource terminal without owning its process or dimensions. +/// +internal sealed class TerminalTapePlayCommand : BaseCommand +{ + // CancellationTokenSource's underlying timer accepts at most uint.MaxValue - 1 milliseconds. + private const int MaximumTimeoutSeconds = (int)((uint.MaxValue - 1) / 1000); + internal override HelpGroup HelpGroup => HelpGroup.Monitoring; + + private readonly AppHostConnectionResolver _connectionResolver; + private readonly TerminalResourceResolver _terminalResolver; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly Argument _resourceArgument = new("resource") + { + Description = TerminalCommandStrings.ResourceArgumentDescription + }; + private readonly Option _tapeFileOption = new("--tape-file") + { + Description = TerminalCommandStrings.TapeFileDescription, + Required = true + }; + private readonly Option _replicaOption = new("--replica", "-r") + { + Description = TerminalCommandStrings.ReplicaOptionDescription + }; + private readonly OptionWithLegacy _appHostOption = + new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription); + private readonly Option _timeoutOption = new("--timeout") + { + Description = TerminalCommandStrings.TapeTimeoutDescription, + DefaultValueFactory = _ => 120 + }; + + public TerminalTapePlayCommand( + AppHostConnectionResolver connectionResolver, + TerminalResourceResolver terminalResolver, + ILogger logger, + TimeProvider timeProvider, + CommonCommandServices services) : base("play", TerminalCommandStrings.TapePlayDescription, services) + { + _connectionResolver = connectionResolver; + _terminalResolver = terminalResolver; + _logger = logger; + _timeProvider = timeProvider; + Arguments.Add(_resourceArgument); + Options.Add(_tapeFileOption); + Options.Add(_replicaOption); + Options.Add(_appHostOption); + Options.Add(_timeoutOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity("terminal tape play"); + // The final screen is the command's stdout payload; discovery, warnings and failures belong on stderr. + InteractionService.Console = ConsoleOutput.Error; + var resourceName = parseResult.GetValue(_resourceArgument)!; + var tapePath = parseResult.GetValue(_tapeFileOption)!; + var timeoutSeconds = parseResult.GetValue(_timeoutOption); + if (string.IsNullOrWhiteSpace(resourceName)) + { + return CommandResult.Failure(CliExitCodes.InvalidCommand, TerminalCommandStrings.ResourceRequired); + } + if (timeoutSeconds is <= 0 or > MaximumTimeoutSeconds) + { + return CommandResult.Failure(CliExitCodes.InvalidCommand, + string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapeTimeoutInvalid, MaximumTimeoutSeconds)); + } + + FileInfo file; + TapeDocument tape; + try + { + file = new FileInfo(Path.GetFullPath(tapePath, ExecutionContext.WorkingDirectory.FullName)); + tape = await new TapeParser().ParseAsync(file, cancellationToken).ConfigureAwait(false); + } + catch (TapeParseException ex) + { + DisplayDiagnostics(ex.Diagnostics); + return CommandResult.Failure(CliExitCodes.InvalidCommand); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return CommandResult.Failure(CliExitCodes.InvalidCommand, + string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapeFileReadFailed, tapePath, ex.Message)); + } + + var connectionResult = await _connectionResolver.ResolveConnectionAsync( + parseResult.GetValue(_appHostOption), + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, TerminalCommandStrings.TapeSelectAppHostAction), + SharedCommandStrings.AppHostNotRunning, + cancellationToken).ConfigureAwait(false); + if (!connectionResult.Success) + { + return CommandResult.FromExitCode(AppHostConnectionResultHandler.DisplayFailureAsError( + connectionResult, InteractionService, CliExitCodes.FailedToFindProject)); + } + if (!connectionResult.Connection.SupportsTerminalsV1) + { + return CommandResult.Failure(CliExitCodes.AppHostIncompatible, TerminalCommandStrings.TerminalIncompatible); + } + + var (canonicalName, replica) = await _terminalResolver.ResolveAsync( + connectionResult.Connection, resourceName, parseResult.GetValue(_replicaOption), cancellationToken).ConfigureAwait(false); + if (replica is null) + { + return CommandResult.Failure(CliExitCodes.InvalidCommand); + } + if (!replica.IsAlive) + { + return CommandResult.Failure(CliExitCodes.FailedToExecuteResourceCommand, + string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapeReplicaExited, replica.ReplicaIndex, canonicalName)); + } + + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds), _timeProvider); + using var playback = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token); + var disconnected = 0; + try + { + await using var adapter = new Hmp1WorkloadAdapter(new Hmp1ClientOptions + { + StreamFactory = async ct => await Hmp1Transports.ConnectUnixSocket(replica.ConsumerUdsPath, ct).ConfigureAwait(false), + DefaultRole = Hmp1Role.Secondary, + DisplayName = $"aspire-tape:{Environment.ProcessId}", + OnDisconnected = _ => + { + Interlocked.Exchange(ref disconnected, 1); + // HMP can ignore writes after disconnection. Cancel the player instead of reporting a + // successful tape whose input never reached the resource. + playback.Cancel(); + return Task.CompletedTask; + } + }); + await adapter.ConnectAsync(playback.Token).ConfigureAwait(false); + + var initialScreen = new InitialScreenFilter(); + // A preconnected workload starts the mirror's pumps during Build(). No local process is created, + // and no scrollback is enabled: VHS Wait+Screen must inspect this mirror's visible screen. + await using var terminal = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithWorkload(adapter) + .WithDimensions(adapter.RemoteWidth, adapter.RemoteHeight) + .AddPresentationFilter(initialScreen) + .Build(); + await initialScreen.Ready.WaitAsync(playback.Token).ConfigureAwait(false); + + var player = new TapePlayer(); + var options = new TapePlaybackOptions + { + WorkingDirectory = file.DirectoryName + }; + var validation = await player.ValidateAsync(tape, terminal, options, playback.Token).ConfigureAwait(false); + DisplayDiagnostics(validation.Diagnostics); + if (!validation.CanExecute) + { + return CommandResult.Failure(CliExitCodes.InvalidCommand); + } + + using var result = await player.PlayAsync(tape, terminal, options, playback.Token).ConfigureAwait(false); + playback.Token.ThrowIfCancellationRequested(); + DisplayDiagnostics(result.Diagnostics.Except(validation.Diagnostics)); + InteractionService.DisplayRawText(result.FinalSnapshot.GetScreenText(), ConsoleOutput.Standard); + return CommandResult.Success(); + } + catch (TapeValidationException ex) + { + DisplayDiagnostics(ex.Diagnostics); + return CommandResult.Failure(CliExitCodes.InvalidCommand); + } + catch (TapePlaybackException ex) + { + InteractionService.DisplayRawText(ex.TerminalText, ConsoleOutput.Standard); + // The native diagnostic already contains the failing source span and command context. + return CommandResult.Failure(CliExitCodes.FailedToExecuteResourceCommand, ex.Message); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + return CommandResult.Failure(CliExitCodes.WaitTimeout, + string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapeTimeout, timeoutSeconds)); + } + catch (OperationCanceledException) when (Volatile.Read(ref disconnected) != 0) + { + return CommandResult.Failure(CliExitCodes.FailedToExecuteResourceCommand, TerminalCommandStrings.TapeConnectionClosed); + } + catch (Exception ex) when (ex is IOException or SocketException or TimeoutException) + { + _logger.LogDebug(ex, "Terminal tape connection failed for {ResourceName}, replica {ReplicaIndex}.", canonicalName, replica.ReplicaIndex); + return CommandResult.Failure(CliExitCodes.FailedToExecuteResourceCommand, + string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapePlaybackFailed, ex.Message)); + } + } + + private void DisplayDiagnostics(IEnumerable diagnostics) + { + foreach (var diagnostic in diagnostics) + { + var span = diagnostic.Span; + InteractionService.DisplayRawText( + FormattableString.Invariant($"{span.SourceName}:{span.Line}:{span.Column}: {diagnostic.Severity} {diagnostic.Code}: {diagnostic.Message}"), + ConsoleOutput.Error); + } + } + + private sealed class InitialScreenFilter : IHex1bTerminalPresentationFilter + { + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task Ready => _ready.Task; + + public ValueTask> OnOutputAsync( + IReadOnlyList appliedTokens, TimeSpan elapsed, CancellationToken cancellationToken = default) + { + // This pin exposes no public initial-replay barrier. A fresh headless HMP mirror first calls its + // presentation filters after committing the authoritative screen, even when it is empty. + // https://github.com/mitchdenny/hex1b/blob/496ccf508470eed8744dbe46675e3d26928e8c91/src/Hex1b/Hmp1/Hex1bTerminal.Hmp1Replay.cs#L117-L149 + _ready.TrySetResult(); + return ValueTask.FromResult>(appliedTokens.Select(t => t.Token).ToArray()); + } + + public ValueTask OnSessionStartAsync(int width, int height, DateTimeOffset timestamp, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + public ValueTask OnInputAsync(IReadOnlyList tokens, TimeSpan elapsed, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + public ValueTask OnResizeAsync(int width, int height, TimeSpan elapsed, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + public ValueTask OnSessionEndAsync(TimeSpan elapsed, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } +} diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 6ff9ae19190..3b99b915120 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -101,7 +101,7 @@ internal static class KnownFeatures [TerminalCommandsEnabled] = new( TerminalCommandsEnabled, - "(Experimental) Enable the 'aspire terminal' command group ('aspire terminal ps', 'aspire terminal attach'). Used in conjunction with the experimental WithTerminal() API (ASPIRETERMINAL001). Hidden by default while the API surface is in preview.", + "(Experimental) Enable the 'aspire terminal' command group ('aspire terminal ps', 'aspire terminal attach', 'aspire terminal tape play'). Used in conjunction with the experimental WithTerminal() API (ASPIRETERMINAL001). Hidden by default while the API surface is in preview.", DefaultValue: false), [PolyglotIntegrationFilterEnabled] = new( diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index ec78032ed48..6cbae89daf7 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -623,8 +623,11 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); 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/TerminalCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TerminalCommandStrings.Designer.cs new file mode 100644 index 00000000000..b864a8ec8a5 --- /dev/null +++ b/src/Aspire.Cli/Resources/TerminalCommandStrings.Designer.cs @@ -0,0 +1,188 @@ +//------------------------------------------------------------------------------ +// +// 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 TerminalCommandStrings { + + private static System.Resources.ResourceManager resourceMan; + private static System.Globalization.CultureInfo resourceCulture; + + internal TerminalCommandStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + resourceMan = new System.Resources.ResourceManager("Aspire.Cli.Resources.TerminalCommandStrings", typeof(TerminalCommandStrings).Assembly); + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + public static string LookingUpResource { + get { + return ResourceManager.GetString("LookingUpResource", resourceCulture); + } + } + + public static string ResourceNotFound { + get { + return ResourceManager.GetString("ResourceNotFound", resourceCulture); + } + } + + public static string DiscoveringSessions { + get { + return ResourceManager.GetString("DiscoveringSessions", resourceCulture); + } + } + + public static string TerminalUnavailable { + get { + return ResourceManager.GetString("TerminalUnavailable", resourceCulture); + } + } + + public static string ReplicaNotFound { + get { + return ResourceManager.GetString("ReplicaNotFound", resourceCulture); + } + } + + public static string ReplicaRequired { + get { + return ResourceManager.GetString("ReplicaRequired", resourceCulture); + } + } + + public static string SelectReplica { + get { + return ResourceManager.GetString("SelectReplica", resourceCulture); + } + } + + public static string ReplicaRunning { + get { + return ResourceManager.GetString("ReplicaRunning", resourceCulture); + } + } + + public static string ReplicaExited { + get { + return ResourceManager.GetString("ReplicaExited", resourceCulture); + } + } + + public static string TapeDescription { + get { + return ResourceManager.GetString("TapeDescription", resourceCulture); + } + } + + public static string TapePlayDescription { + get { + return ResourceManager.GetString("TapePlayDescription", resourceCulture); + } + } + + public static string ResourceArgumentDescription { + get { + return ResourceManager.GetString("ResourceArgumentDescription", resourceCulture); + } + } + + public static string ResourceRequired { + get { + return ResourceManager.GetString("ResourceRequired", resourceCulture); + } + } + + public static string TapeFileDescription { + get { + return ResourceManager.GetString("TapeFileDescription", resourceCulture); + } + } + + public static string ReplicaOptionDescription { + get { + return ResourceManager.GetString("ReplicaOptionDescription", resourceCulture); + } + } + + public static string TapeSelectAppHostAction { + get { + return ResourceManager.GetString("TapeSelectAppHostAction", resourceCulture); + } + } + + public static string TerminalIncompatible { + get { + return ResourceManager.GetString("TerminalIncompatible", resourceCulture); + } + } + + public static string TapeFileReadFailed { + get { + return ResourceManager.GetString("TapeFileReadFailed", resourceCulture); + } + } + + public static string TapeTimeoutInvalid { + get { + return ResourceManager.GetString("TapeTimeoutInvalid", resourceCulture); + } + } + + public static string TapeReplicaExited { + get { + return ResourceManager.GetString("TapeReplicaExited", resourceCulture); + } + } + + public static string TapePlaybackFailed { + get { + return ResourceManager.GetString("TapePlaybackFailed", resourceCulture); + } + } + + public static string TapeConnectionClosed { + get { + return ResourceManager.GetString("TapeConnectionClosed", resourceCulture); + } + } + + public static string TapeTimeoutDescription { + get { + return ResourceManager.GetString("TapeTimeoutDescription", resourceCulture); + } + } + + public static string TapeTimeout { + get { + return ResourceManager.GetString("TapeTimeout", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/TerminalCommandStrings.resx b/src/Aspire.Cli/Resources/TerminalCommandStrings.resx new file mode 100644 index 00000000000..382d9a60139 --- /dev/null +++ b/src/Aspire.Cli/Resources/TerminalCommandStrings.resx @@ -0,0 +1,87 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Looking up resource... + + + Resource '{0}' was not found. + + + Discovering terminal sessions... + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + Select a replica of '{0}' to attach to: + + + {0} (running) + + + {0} (exited code={1}) + + + Run VHS tape scripts against resource terminals. + + + Play a VHS tape against a running resource terminal and print its final screen. + + + The name of the resource whose terminal runs the tape. + + + A resource name is required. + + + The path to the VHS .tape file to play. + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + play a terminal tape + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + Could not read tape file '{0}': {1} + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + Tape playback failed: {0} + + + The terminal connection closed before tape playback completed. + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + Terminal tape playback did not finish within {0} seconds. + + + The timeout must be between 1 and {0} seconds. + + diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.cs.xlf new file mode 100644 index 00000000000..6e2d16820d1 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.cs.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.de.xlf new file mode 100644 index 00000000000..aa1e73cdfb4 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.de.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.es.xlf new file mode 100644 index 00000000000..1b26ede8485 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.es.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.fr.xlf new file mode 100644 index 00000000000..65050f1ae5f --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.fr.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.it.xlf new file mode 100644 index 00000000000..09411aa8b31 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.it.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ja.xlf new file mode 100644 index 00000000000..0ff90498960 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ja.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ko.xlf new file mode 100644 index 00000000000..84f71fb9c4d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ko.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.pl.xlf new file mode 100644 index 00000000000..1c76f7f43e0 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.pl.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..3f3346d7fbd --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.pt-BR.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ru.xlf new file mode 100644 index 00000000000..6d0e906b221 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.ru.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.tr.xlf new file mode 100644 index 00000000000..c152fb4599d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.tr.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..faf79999896 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.zh-Hans.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..4ecb557b4d5 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TerminalCommandStrings.zh-Hant.xlf @@ -0,0 +1,127 @@ + + + + + + Discovering terminal sessions... + Discovering terminal sessions... + + + + Looking up resource... + Looking up resource... + + + + {0} (exited code={1}) + {0} (exited code={1}) + + + + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + Replica index {0} is not available for resource '{1}'. Available indices: {2}. + + + + The 0-based replica index. Required for replicated resources in non-interactive mode. + The 0-based replica index. Required for replicated resources in non-interactive mode. + + + + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + Resource '{0}' has {1} replicas. Pass --replica <index> to choose one in non-interactive mode. + + + + {0} (running) + {0} (running) + + + + The name of the resource whose terminal runs the tape. + The name of the resource whose terminal runs the tape. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + A resource name is required. + A resource name is required. + + + + Select a replica of '{0}' to attach to: + Select a replica of '{0}' to attach to: + + + + The terminal connection closed before tape playback completed. + The terminal connection closed before tape playback completed. + + + + Run VHS tape scripts against resource terminals. + Run VHS tape scripts against resource terminals. + + + + The path to the VHS .tape file to play. + The path to the VHS .tape file to play. + + + + Could not read tape file '{0}': {1} + Could not read tape file '{0}': {1} + + + + Play a VHS tape against a running resource terminal and print its final screen. + Play a VHS tape against a running resource terminal and print its final screen. + + + + Tape playback failed: {0} + Tape playback failed: {0} + + + + Replica {0} of '{1}' has exited. A tape requires a live terminal. + Replica {0} of '{1}' has exited. A tape requires a live terminal. + + + + play a terminal tape + play a terminal tape + + + + Terminal tape playback did not finish within {0} seconds. + Terminal tape playback did not finish within {0} seconds. + + + + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + Maximum time in seconds to connect and play the tape. Defaults to 120 seconds. + + + + The timeout must be between 1 and {0} seconds. + The timeout must be between 1 and {0} seconds. + + + + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + The connected AppHost does not support 'aspire terminal'. Update Aspire.Hosting to 13.4 or later. + + + + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + Resource '{0}' is not available for terminal attachment. Make sure the resource was registered with '.WithTerminal()' and that the terminal host has started. + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index a262d0c5f96..670cf0d7f6b 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -47,10 +47,14 @@ OnClick="@(() => SetFontSizeAsync(_state.FontPx + 1))">+ @if (ShowDimensionsPicker) { - @(FitLabel ?? Loc[nameof(Resources.ConsoleLogs.TerminalToolbarGridSizeAuto)]) + } diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 595de39f9a2..49e19ed04ef 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -24,6 +24,7 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable private int _connectedGeneration = -1; private string? _connectedEndpoint; private bool _appliedReadOnly; + private bool _appliedAutoFit; private bool _initializationFailed; private string? _failedEndpoint; private bool _disposed; @@ -80,15 +81,28 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable public bool Chromeless { get; set; } /// Gets or sets the per-surface key for page-lifetime font-size persistence. - /// Dock panes and detached windows use separate keys even when they show the same terminal. + /// Detached windows seed their font from the opener without sharing live font preferences. [Parameter] public string? SizeMemoryKey { get; set; } + /// Gets or sets the initial font size in CSS pixels when this surface has no remembered preference. + /// Null uses the terminal's default. Changing this value does not override a mounted view's font. + [Parameter] + public int? InitialFontSize { get; set; } + + /// Gets the selected font size, or the initial preference before the first state notification. + public int? FontSize => _state.FontPx > 0 ? _state.FontPx : InitialFontSize; + /// Gets or sets whether the footer offers fixed-resolution presets. Defaults to true. /// The font stepper remains available on surfaces sized by a splitter or dialog. [Parameter] public bool ShowDimensionsPicker { get; set; } = true; + /// Gets or sets whether opening this surface fits its grid to the container while preserving font size. + /// Set this only for the active dock pane. Read-only views do not take resize control. + [Parameter] + public bool AutoFit { get; set; } + /// Raised when the terminal's role, dimensions, font or connection state changes. [Parameter] public EventCallback OnToolbarStateChanged { get; set; } @@ -153,6 +167,13 @@ private async Task ReconcileAsync() _appliedReadOnly = readOnly; continue; } + if (_terminalId != 0 && _appliedAutoFit != AutoFit) + { + var autoFit = AutoFit; + await _jsModule!.InvokeVoidAsync("setAutoFit", _terminalId, autoFit); + _appliedAutoFit = autoFit; + continue; + } break; } } @@ -204,6 +225,7 @@ private async Task InitializeTerminalCoreAsync(string endpoint) _selfRef ??= DotNetObjectReference.Create(this); _connectedGeneration = -1; var readOnly = ReadOnly; + var autoFit = AutoFit; _terminalId = await _jsModule.InvokeAsync( "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef, new TerminalViewOptions @@ -212,7 +234,9 @@ private async Task InitializeTerminalCoreAsync(string endpoint) ReadOnly = readOnly, Chromeless = Chromeless, ShowDimensions = ShowDimensionsPicker, + AutoFit = autoFit, SizeMemoryKey = SizeMemoryKey, + InitialFontSize = InitialFontSize, Label = Loc[nameof(Resources.ConsoleLogs.TerminalInputLabel)], DecreaseFontSize = DecreaseFontSizeLabel ?? Loc[nameof(Resources.ConsoleLogs.TerminalToolbarDecreaseFontSize)], IncreaseFontSize = IncreaseFontSizeLabel ?? Loc[nameof(Resources.ConsoleLogs.TerminalToolbarIncreaseFontSize)], @@ -221,6 +245,7 @@ private async Task InitializeTerminalCoreAsync(string endpoint) FocusControlsHint = FocusControlsHintLabel ?? Loc[nameof(Resources.ConsoleLogs.TerminalFocusControlsHint)], }, _selectionTemplateElement, _footerElement); _appliedReadOnly = readOnly; + _appliedAutoFit = autoFit; if (!_disposed) { _sizePresets = await GetSizePresetsAsync(); @@ -296,13 +321,6 @@ private void ReleaseViewSession() _sessionEndpoint = null; } - /// Checks authoritative completion before retrying a failed native connection. - /// The opaque identifier of the view requesting the check. - /// True when the view ended or has been released; otherwise false. - [JSInvokable] - public bool IsTerminalEnded(string viewId) => - _disposed || _viewSession is null || _viewSession.Id != viewId || _viewSession.Ended.IsCompletedSuccessfully; - /// Updates this view's chrome and forwards the current terminal state to its host. /// The generation-tagged state supplied by the JS adapter. [JSInvokable] @@ -336,6 +354,14 @@ public async Task OnTerminalStateChanged(TerminalToolbarState state) /// The preset key, or auto. public Task SetSizeModeAsync(string sizeKey) => InvokeTerminalAsync("setSizeModeFromHost", sizeKey); + /// Fits the terminal grid to its container without changing the selected font size. + public Task FitToContainerAsync() => InvokeTerminalAsync("fitToContainer"); + + private IReadOnlyList DisplayedSizePresets => _state.Cols > 0 && _state.Rows > 0 && + !_sizePresets.Any(p => p.Value == _state.SizeKey) + ? [new(_state.SizeKey, $"{_state.Cols}\u00d7{_state.Rows}", _state.Cols, _state.Rows), .. _sizePresets] + : _sizePresets; + /// Gets the supported grid presets from the JS adapter. /// The available preset values and dimensions. public async Task> GetSizePresetsAsync() @@ -482,8 +508,12 @@ public sealed record TerminalViewOptions public bool Chromeless { get; init; } /// Whether fixed-resolution presets are offered. public bool ShowDimensions { get; init; } = true; + /// Whether opening the active surface requests automatic grid sizing at the current font size. + public bool AutoFit { get; init; } /// The per-surface key for remembering the font size. public string? SizeMemoryKey { get; init; } + /// The initial font size when no per-surface preference has been remembered. + public int? InitialFontSize { get; init; } /// The accessible label for the terminal's keyboard input. public required string Label { get; init; } /// The accessible decrease-font-size label. @@ -527,6 +557,8 @@ public sealed record TerminalToolbarState public bool CanIncreaseFontSize { get; init; } /// Whether grid presets are available. public bool SizeSelectEnabled { get; init; } + /// Whether fitting is available and the view is not already the auto-sized primary. + public bool FitEnabled { get; init; } /// The server-authoritative grid width. public int Cols { get; init; } /// The server-authoritative grid height. diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index b23625f86c2..56ef9704130 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -9,9 +9,10 @@ let nextId = 1; const DEFAULT_FONT_SIZE = 13; const RECONNECT_BACKOFF_MS = [500, 1000, 2000, 4000, 5000]; const MAX_RECONNECT_ATTEMPTS = 30; -const COMPLETION_CHECK_TIMEOUT_MS = 5000; +// Aspire's WebSocket endpoint sends this private-use code only after authoritative +// producer completion. It is not an HWT message or a Hex1b-defined close code. +const TERMINAL_ENDED_CLOSE_CODE = 4000; const SIZE_PRESETS = [ - { value: "auto", label: "Auto", cols: 0, rows: 0 }, { value: "80x24", label: "80×24", cols: 80, rows: 24 }, { value: "80x30", label: "80×30", cols: 80, rows: 30 }, { value: "100x30", label: "100×30", cols: 100, rows: 30 }, @@ -101,67 +102,36 @@ function connectionFailed(state, generation, error) { state.connected = false; state.peer = { id: null, primaryId: null, isPrimary: false }; state.pendingSizing = null; - void finishConnectionFailure(state, generation, error); + console.warn("Dashboard terminal connection failed.", error); + state.error = "mount-failed"; + releaseClient(state); + scheduleReconnect(state, generation); + notifyToolbar(state); } -async function finishConnectionFailure(state, generation, error) { - // The package times out before its first frame even if the server intentionally - // retains an ended view's socket. Consult the existing per-view registration, - // through Blazor, rather than parsing private error strings or HWT payloads. - const ended = await checkTerminalEnded(state); - if (!isCurrent(state, generation)) { +function connectionClosed(state, generation, details) { + if (!isCurrent(state, generation) || state.ended) { return; } - state.failurePending = false; - if (ended === true) { - state.ended = true; - state.error = null; - state.waitingForVisibility = false; - cancelReconnect(state); - if (!state.client) { - state.controller?.abort(); - } - // A completed view is kept in place until closed. After an unsuccessful - // first mount this can be empty; there is no promise of a final screen. - } else { - console.warn("Dashboard terminal connection failed.", error); - state.error = ended === false ? "mount-failed" : "disconnected"; - if (ended === false) { - releaseClient(state); - scheduleReconnect(state, generation); - } - // If the Blazor circuit cannot confirm lifecycle state, preserve the view - // and offer explicit retry instead of guessing and looping indefinitely. + if (details.code !== TERMINAL_ENDED_CLOSE_CODE) { + // Normal closure (1000), missing close frames (1006), reasons and wasClean + // describe transport state, never whether the producer has completed. + connectionFailed(state, generation, new Error(`Terminal WebSocket closed (${details.code}).`)); + return; } + state.ended = true; + state.connected = false; + state.pendingSizing = null; + state.autoFitPending = false; + state.waitingForVisibility = false; + state.error = null; + cancelReconnect(state); + state.client?.setReadOnly(true); + // Keep an already mounted projection until the user dismisses the view. + // A close before the first frame leaves an empty container, not a fake frame. notifyToolbar(state); } -async function checkTerminalEnded(state) { - if (!state.dotNetRef || !state.viewId) { - return false; - } - const controller = new AbortController(); - state.completionCheck = controller; - let timeout; - const cancelled = new Promise(resolve => { - controller.signal.addEventListener("abort", () => resolve(null), { once: true }); - timeout = setTimeout(() => resolve(null), COMPLETION_CHECK_TIMEOUT_MS); - }); - try { - return await Promise.race([ - Promise.resolve().then(() => state.dotNetRef.invokeMethodAsync("IsTerminalEnded", state.viewId)) - .then(value => typeof value === "boolean" ? value : null, () => null), - cancelled, - ]); - } finally { - clearTimeout(timeout); - controller.abort(); - if (state.completionCheck === controller) { - state.completionCheck = null; - } - } -} - function inputFailed(state, error) { console.warn("Dashboard terminal input failed.", error); state.error = "input-failed"; @@ -299,46 +269,14 @@ function focusControls(state, reverse) { return true; } -function inputPolicy(state, input, context) { +function inputPolicy(state, input) { // Input interception is a public package hook and reaches shadow-root keyboard // input without querying the client's private textarea or swallowing F6 in the PTY. if (input.type === "key" && input.key === "F6" && !input.ctrl && !input.alt && !input.meta) { return focusControls(state, input.shift) ? InputRoute.Consume : InputRoute.Browser; } - if (state.readOnly || state.ended) { - // The server independently enforces this per-view restriction, including a - // paste or drag that started before this flag changed. It is a presentation - // policy, not user authorization. All inspection actions below are public. - if (input.type === "key") { - const key = input.key.toLowerCase(); - if ((input.ctrl || input.meta) && key === "c" && context.selection.status === "valid") { - return { action: "copySelection" }; - } - if (input.key === "Escape" && context.selection.active) { - return { action: "clearSelection" }; - } - if (input.shift && ["PageUp", "PageDown"].includes(input.key)) { - return { action: "scrollLines", args: input.key === "PageUp" ? -20 : 20 }; - } - return input.ctrl || input.meta ? InputRoute.Browser : InputRoute.Consume; - } - if (input.type === "text" || input.type === "paste") { - return InputRoute.Consume; - } - if (input.type === "wheel") { - return { action: "scrollLines", args: Math.sign(input.deltaY) * 3 }; - } - if (input.type === "pointer") { - if (input.button === "right") { - return context.selection.status === "valid" ? { action: "copySelection" } : InputRoute.Consume; - } - if (input.button === "middle" || (context.mouseCaptured && !input.shift && !context.historical)) { - return InputRoute.Consume; - } - // Ordinary selection, or Shift-drag in mouse-capturing applications, stays native. - return InputRoute.Continue; - } - } + // Hex1b's live read-only policy owns keyboard, IME, pointer, paste and sizing + // gating, including queued gestures and direct clipboard/action API calls. return InputRoute.Continue; } @@ -347,7 +285,6 @@ function connectClient(state) { return; } cancelReconnect(state); - state.completionCheck?.abort(); state.failurePending = false; const generation = ++state.generation; releaseClient(state); @@ -355,6 +292,7 @@ function connectClient(state) { state.geometry = null; state.connected = false; state.pendingSizing = null; + state.autoFitPending = state.autoFit; state.waitingForVisibility = false; notifyToolbar(state); // A hidden Console view must not spend the package's first-frame timeout. @@ -376,17 +314,20 @@ async function mountClient(state, generation, controller) { signal: controller.signal, label: state.options.label, sizing: state.sizing, - // The package's flag is mount-only. Keep it writable so the per-view - // server policy and public input interceptor can change without reconnecting. - readOnly: false, - onInput: (input, context) => inputPolicy(state, input, context), + readOnly: state.readOnly, + onInput: input => inputPolicy(state, input), + onClose(details) { + if (current()) { + connectionClosed(state, generation, details); + } + }, onSelectionUI: createSelectionUI(state, current), // The package chooses WebGL2 on ordinary HTTP/unavailable WebGPU; // unexpected initialization and runtime rendering errors still surface. // https://github.com/mitchdenny/hex1b/pull/491 renderer: "auto", onStatus(message, level) { - if (!current() || level !== "error") { + if (!current() || state.ended || level !== "error") { return; } if (state.client?.connected) { @@ -417,7 +358,7 @@ async function mountClient(state, generation, controller) { } }, onInputError(error) { - if (current()) { + if (current() && !state.ended) { inputFailed(state, error); } }, @@ -427,6 +368,12 @@ async function mountClient(state, generation, controller) { return; } state.client = client; + // Policy can change while mount is waiting for its first frame. + client.setReadOnly(state.readOnly || state.ended); + if (state.ended) { + notifyToolbar(state); + return; + } state.connected = client.connected; state.peer = client.peer; state.geometry = client.geometry; @@ -438,6 +385,7 @@ async function mountClient(state, generation, controller) { client.focus(); } state.restoreFocus = false; + applyAutoFit(state); applyPendingSizing(state); notifyToolbar(state); } catch (error) { @@ -477,18 +425,30 @@ function changeSizing(state, sizing) { if (state.peer.isPrimary) { applyPendingSizing(state); } else { - // Only explicit sizing gestures request authority, never ordinary keyboard, paste or mouse input. + // Opening an auto-fit surface or explicitly sizing it requests authority; + // ordinary keyboard, paste and mouse input never do. requestPrimaryFromHost(state.id); } } +function applyAutoFit(state) { + if (!state.autoFitPending || state.readOnly || state.ended || !state.client?.connected || !isVisible(state)) { + return; + } + // Request once on opening/activation, not on role notifications: another + // viewer taking primary must not cause the two views to fight over the grid. + state.autoFitPending = false; + fitToContainer(state.id); +} + export function initTerminal(element, wsUrl, dotNetRef, options, selectionTemplate, footer) { const id = nextId++; - const fontSize = rememberedFontSizes.get(options.sizeMemoryKey) ?? DEFAULT_FONT_SIZE; + const fontSize = rememberedFontSizes.get(options.sizeMemoryKey) ?? + (Number.isFinite(options.initialFontSize) ? clampFontSize(options.initialFontSize) : DEFAULT_FONT_SIZE); const state = { id, element, wsUrl, dotNetRef, options, selectionTemplate, footer, - viewId: new URL(wsUrl).searchParams.get("viewId") ?? options.viewId, readOnly: !!options.readOnly, + autoFit: !!options.autoFit, client: null, controller: null, disposed: false, @@ -498,6 +458,7 @@ export function initTerminal(element, wsUrl, dotNetRef, options, selectionTempla geometry: null, sizing: { mode: "auto", fontSize }, pendingSizing: null, + autoFitPending: false, error: null, generation: 0, attempts: 0, @@ -506,7 +467,6 @@ export function initTerminal(element, wsUrl, dotNetRef, options, selectionTempla lastToolbarJson: null, waitingForVisibility: false, restoreFocus: false, - completionCheck: null, failurePending: false, listeners: new AbortController(), }; @@ -520,6 +480,8 @@ export function initTerminal(element, wsUrl, dotNetRef, options, selectionTempla state.observer = new ResizeObserver(() => { if (state.waitingForVisibility && !state.disposed && !state.ended && isVisible(state)) { connectClient(state); + } else if (!state.disposed) { + applyAutoFit(state); } }); state.observer.observe(element); @@ -534,7 +496,6 @@ export function reconnectTerminal(id, wsUrl) { return state?.generation ?? 0; } state.wsUrl = wsUrl; - state.viewId = new URL(wsUrl).searchParams.get("viewId"); state.ended = false; state.attempts = 0; state.error = null; @@ -555,7 +516,6 @@ export function disposeTerminal(id) { } state.observer.disconnect(); state.listeners.abort(); - state.completionCheck?.abort(); releaseClient(state); state.dotNetRef = null; terminals.delete(id); @@ -571,29 +531,59 @@ export function setReadOnly(id, readOnly) { return; } state.readOnly = readOnly; + state.client?.setReadOnly(readOnly || state.ended); if (readOnly) { state.pendingSizing = null; + } else { + state.autoFitPending = state.autoFit; + applyAutoFit(state); } notifyToolbar(state); } +export function setAutoFit(id, autoFit) { + const state = terminals.get(id); + if (!state || state.autoFit === autoFit) { + return; + } + state.autoFit = autoFit; + state.autoFitPending = autoFit; + if (!autoFit) { + state.pendingSizing = null; + } + applyAutoFit(state); +} + +export function fitToContainer(id) { + const state = terminals.get(id); + if (state) { + changeSizing(state, { mode: "auto", fontSize: state.sizing.fontSize }); + } +} + +function clampFontSize(fontSize) { + return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, Math.round(fontSize))); +} + export function setFontSizeFromHost(id, fontSize) { const state = terminals.get(id); if (!state || !Number.isFinite(fontSize)) { return; } - changeSizing(state, { mode: "auto", fontSize: Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, Math.round(fontSize))) }); + changeSizing(state, { mode: "auto", fontSize: clampFontSize(fontSize) }); } export function setSizeModeFromHost(id, sizeKey) { + if (sizeKey === "auto") { + fitToContainer(id); + return; + } const state = terminals.get(id); const preset = SIZE_PRESETS.find(p => p.value === sizeKey); - if (!state || !preset || (state.options.showDimensions === false && sizeKey !== "auto")) { + if (!state || !preset || state.options.showDimensions === false) { return; } - changeSizing(state, preset.value === "auto" - ? { mode: "auto", fontSize: state.sizing.fontSize } - : { mode: "fixed", columns: preset.cols, rows: preset.rows, fontSize: state.sizing.fontSize }); + changeSizing(state, { mode: "fixed", columns: preset.cols, rows: preset.rows, fontSize: state.sizing.fontSize }); } export function requestPrimaryFromHost(id) { @@ -626,12 +616,15 @@ export function getToolbarState(id) { status: !connected ? "connecting" : isPrimary ? "primary" : state.peer.primaryId === null ? "no-primary" : "viewer", connected, isPrimary, canTakeControl, sizeMode: state.sizing.mode === "auto" ? "font" : "fixed", - sizeKey: state.sizing.mode === "auto" ? "auto" : `${state.sizing.columns}x${state.sizing.rows}`, + sizeKey: state.sizing.mode === "auto" + ? state.geometry ? `${state.geometry.columns}x${state.geometry.rows}` : "" + : `${state.sizing.columns}x${state.sizing.rows}`, fontPx: state.sizing.fontSize, fontControlsEnabled, canDecreaseFontSize: fontControlsEnabled && state.sizing.fontSize > MIN_FONT_SIZE, canIncreaseFontSize: fontControlsEnabled && state.sizing.fontSize < MAX_FONT_SIZE, sizeSelectEnabled: !state.readOnly && (isPrimary || canTakeControl), + fitEnabled: !state.readOnly && (isPrimary || canTakeControl) && !(isPrimary && state.sizing.mode === "auto"), cols: state.geometry?.columns ?? 0, rows: state.geometry?.rows ?? 0, error: state.error, @@ -672,6 +665,7 @@ export function refreshLayout(id) { if (state.waitingForVisibility) { connectClient(state); } else { + applyAutoFit(state); // The package observes this container; revealing a view must not reconnect or discard its history. state.client?.refreshSelectionUI(); } diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor index 2c3ce148f90..ca2c5a0ac2e 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor @@ -251,6 +251,7 @@
diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 912b943bbba..44e8a422bbc 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -117,9 +117,11 @@ } else { - }
diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 43592ce8895..d805695f1ec 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; using Grpc.Core; @@ -31,6 +32,7 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener private const int MaximumHeightPx = 1200; private readonly List _terminals = []; + private readonly Dictionary _terminalViews = new(StringComparer.Ordinal); private readonly CancellationTokenSource _cts = new(); private readonly string _elementIdPrefix = $"terminal-dock-{Guid.NewGuid():N}"; @@ -219,7 +221,8 @@ private async Task DetachActiveAsync() try { var url = NavigationManager.ToAbsoluteUri($"/terminal-window/apphost/{Uri.EscapeDataString(terminalId)}").ToString(); - var result = await WindowLauncher.OpenAsync(terminalId, url).ConfigureAwait(true); + var fontSize = _terminalViews.TryGetValue(terminalId, out var view) ? view.FontSize : null; + var result = await WindowLauncher.OpenAsync(terminalId, url, fontSize).ConfigureAwait(true); if (result is TerminalWindowOpenResult.Blocked) { @@ -229,6 +232,7 @@ private async Task DetachActiveAsync() else { _detachedTerminalIds.Add(terminalId); + _terminalViews.Remove(terminalId); } StateHasChanged(); @@ -361,6 +365,10 @@ await InvokeAsync(async () => // Recovery snapshots replace all prior state, including terminals removed while offline. endedTerminalIds.AddRange(_detachedTerminalIds.Where(id => !_terminals.Any(t => t.TerminalId == id))); _detachedTerminalIds.ExceptWith(endedTerminalIds); + foreach (var id in _terminalViews.Keys.Where(id => !_terminals.Any(t => t.TerminalId == id)).ToArray()) + { + _terminalViews.Remove(id); + } } else if (update.KindCase == WatchTerminalsUpdate.KindOneofCase.Change && Apply(update.Change.ChangeType, update.Change.Terminal) is { } endedTerminalId) @@ -413,6 +421,7 @@ await InvokeAsync(async () => break; case TerminalChangeType.Removed: + _terminalViews.Remove(descriptor.TerminalId); if (index >= 0) { _terminals.RemoveAt(index); diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index db589742c8a..1453de45ee6 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -1391,7 +1391,8 @@ private async Task OpenTerminalWindowAsync() var path = $"/terminal-window/resource/{Uri.EscapeDataString(resourceName)}/{_terminalReplicaIndex}"; var result = await TerminalWindowLauncher.OpenAsync( key: $"resource:{resourceName}:{_terminalReplicaIndex}", - url: NavigationManager.ToAbsoluteUri(path).ToString()).ConfigureAwait(true); + url: NavigationManager.ToAbsoluteUri(path).ToString(), + fontSize: _terminalViewRef?.FontSize).ConfigureAwait(true); if (result is TerminalWindowOpenResult.Blocked) { diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor index dc87dcffe04..154716f29f5 100644 --- a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor @@ -8,8 +8,7 @@ @_title -@* The terminal is the whole page. There is no nav, no toolbar and no terminal chrome, so the grid gets the entire - window and refits as the user resizes it. *@ +@* Without dashboard navigation or a page toolbar, the terminal fills the window and fits its available space. *@
@if (_ended) { @@ -20,6 +19,8 @@ }
diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs index 104d5915d44..8eb7de98809 100644 --- a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs @@ -17,8 +17,8 @@ namespace Aspire.Dashboard.Components.Pages; /// reloaded or closed. /// /// -/// The window is the terminal's whole viewport, so resizing it resizes the grid — that is the reason to detach in the -/// first place, and it comes for free from the chromeless fit layout plus the existing resize observer. +/// Opening the window requests primary once and fits the grid at the opener's selected font size. While primary, +/// the window resizes the grid to its viewport without changing that font size. /// /// public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable @@ -51,6 +51,10 @@ public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable [Parameter] public int ReplicaIndex { get; set; } + /// Gets or sets the font size carried from the terminal's originating surface. + [SupplyParameterFromQuery(Name = "fontSize")] + public int? FontSize { get; set; } + [Inject] public required IDashboardClient DashboardClient { get; init; } diff --git a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs index 6f600bda773..0608d57ac3e 100644 --- a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs +++ b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.JSInterop; namespace Aspire.Dashboard.Model; @@ -79,16 +81,25 @@ public TerminalWindowLauncher(IJSRuntime js, Func onWindowClosed) /// An opaque, page-stable identifier for the terminal — a dock terminal id, or a resource name and replica index. /// /// The dashboard URL that renders the detached terminal. + /// The originating view's font size, or null if the view has not reported one yet. /// Requested window width, in pixels. /// Requested window height, in pixels. public async Task OpenAsync( string key, string url, + int? fontSize, int widthPx = DefaultWindowWidthPx, int heightPx = DefaultWindowHeightPx) { var module = await GetModuleAsync().ConfigureAwait(false); + // Carry only the font preference across browser contexts, not the source grid dimensions: + // the new primary must calculate its own rows and columns from the popup's viewport. + if (fontSize is { } size) + { + url = QueryHelpers.AddQueryString(url, "fontSize", size.ToString(CultureInfo.InvariantCulture)); + } + var result = await module.InvokeAsync( "openTerminalWindow", key, url, widthPx, heightPx, _selfRef).ConfigureAwait(false); diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index 6f695e3e39c..823cd62f4db 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -16,6 +16,9 @@ namespace Aspire.Dashboard.Terminal; ///
internal static class TerminalWebSocketProxy { + // Private Aspire wire contract, not an HWT protocol status: only the AppHost's + // authoritative gRPC Ended notification permits this close code. + private const WebSocketCloseStatus TerminalEndedCloseStatus = (WebSocketCloseStatus)4000; private static readonly TimeSpan s_handshakeTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan s_sendTimeout = TimeSpan.FromMinutes(2); private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(2); @@ -243,10 +246,10 @@ private static async Task HandleConnectionAsync(HttpContext context, Stream upst { session?.MarkEnded(); // A completed AppHost terminal cannot perform HMP's initial replay. - // Keep an empty viewer open until the user closes it, without creating - // a fake workload or adding Aspire messages to the HWT protocol. + // Upgrade and close with the authoritative status without fabricating + // an initial HWT frame or keeping an empty server-side mirror alive. using var endedSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); - await PumpViewAsync(endedSocket, presentation: null, workload: null, upstream, session, logger, context.RequestAborted).ConfigureAwait(false); + await CloseAsync(endedSocket, TerminalEndedCloseStatus, "Terminal ended", receive: null, logger).ConfigureAwait(false); return; } catch (Exception ex) when (ex is IOException or RpcException or InvalidOperationException or @@ -267,7 +270,13 @@ private static async Task HandleConnectionAsync(HttpContext context, Stream upst // Also cover mirror construction and disposal: these run outside the // pump lifetime, but must not escape an already-upgraded request. logger.LogError(ex, "Terminal view failed ({ConnectionId}).", connectionId); - await CloseOutputAsync(socket, WebSocketCloseStatus.InternalServerError, "Terminal view failed", logger).ConfigureAwait(false); + var ended = upstream is GrpcTerminalClientStream { TerminalEnded: true }; + if (ended) + { + session?.MarkEnded(); + } + await CloseAsync(socket, ended ? TerminalEndedCloseStatus : WebSocketCloseStatus.InternalServerError, + ended ? "Terminal ended" : "Terminal view failed", receive: null, logger).ConfigureAwait(false); } finally { @@ -282,7 +291,10 @@ private static async Task BridgeAsync(WebSocket socket, Hmp1WorkloadAdapter work // This mirror belongs only to this browser; disposing it releases the HMP // peer and its transport, never the producer or the creator's terminal. // https://github.com/mitchdenny/hex1b/blob/798b26c/docs/web-terminal.md - var presentation = new Hwt1PresentationAdapter(); + var presentation = new Hwt1PresentationAdapter + { + IsReadOnly = session?.ReadOnly == true || upstream is GrpcTerminalClientStream { TerminalEnded: true } || !workload.IsConnected + }; await using var presentationLifetime = presentation.ConfigureAwait(false); var terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(workload) @@ -293,18 +305,14 @@ private static async Task BridgeAsync(WebSocket socket, Hmp1WorkloadAdapter work await PumpViewAsync(socket, presentation, workload, upstream, session, logger, cancellationToken).ConfigureAwait(false); } - private static async Task PumpViewAsync(WebSocket socket, Hwt1PresentationAdapter? presentation, Hmp1WorkloadAdapter? workload, + private static async Task PumpViewAsync(WebSocket socket, Hwt1PresentationAdapter presentation, Hmp1WorkloadAdapter workload, Stream upstream, TerminalViewSession? session, ILogger logger, CancellationToken cancellationToken) { using var stopping = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var sending = CancellationTokenSource.CreateLinkedTokenSource(stopping.Token); - var send = presentation is null - ? Task.Delay(Timeout.InfiniteTimeSpan, sending.Token) - : SendFramesAsync(socket, presentation, sending.Token); + var send = SendFramesAsync(socket, presentation, sending.Token, stopping.Token); var receive = ReceiveMessagesAsync(socket, presentation, workload, session, upstream as GrpcTerminalClientStream, stopping.Token); - var disconnected = workload is null - ? Task.Delay(Timeout.InfiniteTimeSpan, stopping.Token) - : WaitForTransportDisconnectAsync(workload, upstream, session, logger, stopping.Token); + var disconnected = WaitForTransportDisconnectAsync(workload, upstream, logger, stopping.Token); var tasks = new[] { send, receive, disconnected }; var closeStatus = WebSocketCloseStatus.NormalClosure; var closeReason = "Terminal closed"; @@ -349,14 +357,41 @@ private static async Task PumpViewAsync(WebSocket socket, Hwt1PresentationAdapte } finally { + // A pump failure can race HMP Exit. Let the bounded gRPC drain finish + // before choosing a close code instead of mistaking transport EOF for + // completion (or losing an Ended notification immediately after Exit). + if (workload.DisconnectedTask.IsCompleted && !cancellationToken.IsCancellationRequested) + { + await ObserveTeardownAsync(disconnected, logger).ConfigureAwait(false); + } + // Send the close frame before cancelling ReceiveAsync, which can abort // the socket. Stop and join the binary sender first because WebSocket // permits only one pending send operation, including CloseOutputAsync. try { await sending.CancelAsync().ConfigureAwait(false); - await ObserveTeardownAsync(send, logger).ConfigureAwait(false); - await CloseOutputAsync(socket, closeStatus, closeReason, logger).ConfigureAwait(false); + try + { + // Cancel only the frame wait, not an in-flight socket send: + // cancelling SendAsync also aborts the socket and loses the + // completion close. A stalled browser gets a bounded grace period. + // Finish observing teardown even if the request was cancelled. + await ObserveTeardownAsync(send, logger).WaitAsync(s_closeTimeout, CancellationToken.None).ConfigureAwait(false); + } + catch (TimeoutException) + { + socket.Abort(); + } + + if (upstream is GrpcTerminalClientStream { TerminalEnded: true }) + { + session?.MarkEnded(); + closeStatus = TerminalEndedCloseStatus; + closeReason = "Terminal ended"; + } + + await CloseAsync(socket, closeStatus, closeReason, receive, logger).ConfigureAwait(false); } finally { @@ -373,7 +408,7 @@ private static async Task PumpViewAsync(WebSocket socket, Hwt1PresentationAdapte } private static async Task WaitForTransportDisconnectAsync(Hmp1WorkloadAdapter workload, Stream upstream, - TerminalViewSession? session, ILogger logger, CancellationToken cancellationToken) + ILogger logger, CancellationToken cancellationToken) { await workload.DisconnectedTask.WaitAsync(cancellationToken).ConfigureAwait(false); if (upstream is not GrpcTerminalClientStream grpc) @@ -398,16 +433,6 @@ private static async Task WaitForTransportDisconnectAsync(Hmp1WorkloadAdapter wo logger.LogDebug("Timed out waiting for the AppHost terminal's final transport status."); return; } - - if (grpc.TerminalEnded) - { - session?.MarkEnded(); - // HWT currently has no authoritative ended state. Retain this viewer - // and its last available projection until the user dismisses it. The - // receive loop rejects mutations after this flag is set, but still - // serves local selection/copy/history operations on the mirror. - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } } private static async Task ObserveTeardownAsync(Task task, ILogger logger) @@ -427,7 +452,7 @@ private static async Task ObserveTeardownAsync(Task task, ILogger logger) } } - private static async Task CloseOutputAsync(WebSocket socket, WebSocketCloseStatus status, string reason, ILogger logger) + private static async Task CloseAsync(WebSocket socket, WebSocketCloseStatus status, string reason, Task? receive, ILogger logger) { if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) { @@ -437,7 +462,18 @@ private static async Task CloseOutputAsync(WebSocket socket, WebSocketCloseStatu using var timeout = new CancellationTokenSource(s_closeTimeout); try { - await socket.CloseOutputAsync(status, reason, timeout.Token).ConfigureAwait(false); + if (receive is null) + { + await socket.CloseAsync(status, reason, timeout.Token).ConfigureAwait(false); + } + else + { + await socket.CloseOutputAsync(status, reason, timeout.Token).ConfigureAwait(false); + // Leave the existing receive in charge of the peer's close reply. + // Cancelling it immediately after sending can reset the connection + // before the browser receives the authoritative completion status. + await ObserveTeardownAsync(receive, logger).WaitAsync(timeout.Token).ConfigureAwait(false); + } } catch (Exception ex) when (ex is WebSocketException or OperationCanceledException or InvalidOperationException) { @@ -446,13 +482,15 @@ private static async Task CloseOutputAsync(WebSocket socket, WebSocketCloseStatu } } - private static async Task SendFramesAsync(WebSocket socket, Hwt1PresentationAdapter presentation, CancellationToken cancellationToken) + private static async Task SendFramesAsync(WebSocket socket, Hwt1PresentationAdapter presentation, + CancellationToken frameCancellationToken, CancellationToken cancellationToken) { while (true) { // HWT1 frames are ordered complete binary messages. The adapter handles // acknowledgements and coalesces state while blocked; never drop frames. - var frame = await presentation.ReadFrameAsync(cancellationToken).ConfigureAwait(false); + var frame = await presentation.ReadFrameAsync(frameCancellationToken).ConfigureAwait(false); + frameCancellationToken.ThrowIfCancellationRequested(); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(s_sendTimeout); try @@ -466,8 +504,8 @@ private static async Task SendFramesAsync(WebSocket socket, Hwt1PresentationAdap } } - private static async Task ReceiveMessagesAsync(WebSocket socket, Hwt1PresentationAdapter? presentation, - Hmp1WorkloadAdapter? workload, TerminalViewSession? session, GrpcTerminalClientStream? grpc, CancellationToken cancellationToken) + private static async Task ReceiveMessagesAsync(WebSocket socket, Hwt1PresentationAdapter presentation, + Hmp1WorkloadAdapter workload, TerminalViewSession? session, GrpcTerminalClientStream? grpc, CancellationToken cancellationToken) { // HWT1 commands are UTF-8 JSON, e.g. {"type":"ack","revision":1}. WebSocket // fragmentation can split anywhere, including within a UTF-8 code point. @@ -497,24 +535,11 @@ private static async Task ReceiveMessagesAsync(WebSocket socket, Hwt1Presentatio } while (!result.EndOfMessage); - using var document = JsonDocument.Parse(buffer.AsMemory(0, length)); - var type = document.RootElement.GetProperty("type").GetString(); - var mutatesWorkload = type switch - { - "input" or "paste" or "key" or "mouse" or "resize" or "requestPrimary" => true, - "ack" or "resync" or "viewport" or "selection" or "copy" => false, - _ => throw new InvalidDataException("Unknown terminal command.") - }; - // Read policy after receiving the complete command: a paste or pointer // action started before the component became read-only may arrive later. - // Do not close the view or block ACKs, selection, copying, or output. - if (presentation is null || mutatesWorkload && - (session?.ReadOnly == true || grpc?.TerminalEnded == true || workload?.IsConnected == false)) - { - continue; - } - + // Hex1b owns validation and the mutation gate, including which commands + // remain available for read-only viewing, selection, copying and history. + presentation.IsReadOnly = session?.ReadOnly == true || grpc?.TerminalEnded == true || !workload.IsConnected; await presentation.HandleMessageAsync(buffer.AsMemory(0, length), cancellationToken).ConfigureAwait(false); } } diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 85c5633ecf0..1e543fa778e 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1547.1.798b26c" + "@hex1b/web-terminal": "0.167.0-alpha.1549.1.496ccf5" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.167.0-alpha.1547.1.798b26c", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1547.1.798b26c.tgz", - "integrity": "sha512-1eBEnzQoF25E9TXjWZLd+0196hR8bgDxqIHJP2M+9JeTLWbr/ToeAdH0gI+REkPn6aNpSiz3/Mi+Qf7QYe+g9w==", + "version": "0.167.0-alpha.1549.1.496ccf5", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1549.1.496ccf5.tgz", + "integrity": "sha512-wVSv2UJFDpryRehug4csmQAbt8zG+GdE/vQYfLZHCGzRWicwuYwrY210KdNn1JPRAnR0+KdaeZsAPd0nMQYLjw==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index f1fd85c92b7..002919c4142 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1547.1.798b26c" + "@hex1b/web-terminal": "0.167.0-alpha.1549.1.496ccf5" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index 926dfd2f39f..dde315c3e41 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -18,9 +18,9 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1547.1.798b26c**, +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1549.1.496ccf5**, paired with the Hex1b NuGet build from commit -`798b26c8a297e3060bb9e3a509f76668be6b9022`. The client and server use the evolving +`496ccf508470eed8744dbe46675e3d26928e8c91`. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. @@ -85,6 +85,21 @@ only this view, never the server-side producer. Sizing changes explicitly reques primary when necessary and wait for role confirmation; normal input does not take resize ownership. Public font-size limits are 8–32 pixels. +Native `onClose` reports transport closure even before mounting completes. +Aspire reserves WebSocket close code `4000` for authoritative AppHost producer +completion; normal closure, abnormal disconnects, close reasons and `wasClean` +never imply completion. Completed views stay visible without reconnecting; +other disconnects use bounded retries. No application messages are added to HWT. +The last available projection can remain after completion, but a final frame is +not guaranteed and completion before mounting can leave an empty view. + +The browser's `setReadOnly` and the server's per-presentation +`Hwt1PresentationAdapter.IsReadOnly` enforce live input policy independently. +The component updates server policy before browser UX. Native browser gating +also covers held pointers, queued gestures, direct paste/action calls and +pending clipboard reads. Inspection remains available while the connection is +live; already accepted or in-flight commands cannot be recalled. + Role state comes from the public `onRoleChange` callback's `id`, `primaryId`, and `isPrimary` fields. The backend's direct HMP workload mirror preserves remote primary identity, takeover, and resize authority in this metadata. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md index 91618adbf82..ab29c24d019 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md @@ -51,6 +51,51 @@ terminal.focus(); or disposes a mounted view. Disposal removes only the appended element and its connection, not the container or server-side shared terminal. +### Connection closure and workload completion + +Use `onClose(details)` to observe the browser's actual WebSocket close event. +`TerminalCloseDetails` contains readonly `code`, `reason`, and `wasClean` fields. +The callback runs once with the view already disconnected, **even if the socket +closes before the first HWT frame or authoritative HMP peer state**. A pending +mount rejects after the callback, so capture any host state before calling mount. +The details object is frozen. Reasons are untrusted text; do not render them as HTML. + +```ts +import { WebTerminal, type TerminalCloseDetails } from "@hex1b/web-terminal"; + +const container = document.getElementById("terminal"); +if (!container) throw new Error("Missing terminal container"); +let closed: TerminalCloseDetails | undefined; +try { + const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", + onClose(details) { + closed = details; + console.log("View closed", details.code, details.reason, details.wasClean); + } + }); + terminal.focus(); +} catch (error) { + // closed is set for a transport close, but not for local initialization failure. + console.error("Mount failed", closed, error); +} +``` + +Transport loss is **not workload completion**. Code 1006 means the browser did not +receive a close frame; even code 1000 and `wasClean: true` only describe transport +closure, not successful process exit. Hosts can define an application close-code +contract and send it when their authoritative producer reports completion, +including before any HWT frame exists. Hex1b does not assign workload meaning to +close codes or reason strings. HTTP upgrade failures generally surface as 1006; +browsers do not expose the rejected HTTP response body or status through this API. + +The client never retries automatically. The host decides whether to mount a new +view after transport loss or leave an ended tab/dialog visible. Abort, explicit +disposal, mount timeout, and local initialization/renderer failures do not +synthesize `onClose`; no callback runs after disposal. Callback exceptions are +reported to the host, not swallowed or retried, and do not leave mounting pending. +The API does not retain the producer after exit or promise a final rendered frame. + ### Browser and deployment requirements Use a browser with WebGPU or WebGL2, module workers, transferable OffscreenCanvas, @@ -135,9 +180,10 @@ workers, fonts, and the intended WebSocket endpoint. | `renderer` | `"auto"` (prefer WebGPU), `"webgpu"`, or `"webgl2"`; selected once per mount. | | `font` | One family and optional downloadable font faces; see below. | | `sizing` | `{ mode: "auto", fontSize?: number }` or `{ mode: "fixed", columns, rows, fontSize?: number }`. | -| `readOnly` | Disable application input while retaining history inspection and selection. | +| `readOnly` | Initial per-view input policy; change it later with `setReadOnly(boolean)`. | | `label` | Accessible label for the terminal's hidden keyboard input. | | `onTitleChange` | Initial authoritative workload title, then distinct presented changes; see below. | +| `onClose` | Native WebSocket close details, including pre-mount transport failure; not workload completion. | | `onProgressChange`, `onShellIntegrationChange` | Initial authoritative activity, then distinct presented changes for host-owned chrome. | | `inputBindings`, `onInput`, `actions` | Per-view input policy and custom actions. | | `onSelectionUI` | Synchronous, cancelable UI notification hook. | @@ -149,7 +195,7 @@ Font size is an integer from 8–32, defaulting to 16. Import `MIN_FONT_SIZE` an ownership. `requestPrimary()` explicitly requests ownership; inspect `peer` or `onRoleChange` to observe the result. -The handle exposes `geometry`, `peer`, `connected`, `title`, `progress`, `shellIntegration`, `stats`, `screenText`, +The handle exposes `geometry`, `peer`, `connected`, `readOnly`, `title`, `progress`, `shellIntegration`, `stats`, `screenText`, `sizing`, `viewport`, `selection`, `inputBindings`, and `inputContext`. Metrics start empty; check optional fields before using them. History may be unavailable, and selection can be unavailable, none, pending, valid, or @@ -160,6 +206,62 @@ an independently reconstructed ANSI buffer. Callbacks include `onGeometry`, `onRoleChange`, `onTitleChange`, `onSizingChange`, `onStats`, `onProgressChange`, `onShellIntegrationChange`, `onViewportChange`, `onSelectionChange`, `onStatus`, and `onInputError`. +### Live read-only views + +Call `terminal.setReadOnly(true)` to disable application input on an already +mounted view. `terminal.readOnly`, `inputContext.readOnly`, and selection UI +notifications reflect the new policy. Use `setReadOnly(false)` to re-enable input; +neither call remounts, reconnects, releases the peer's primary role, or changes +the server's current grid. A writable primary resumes automatic sizing requests. +Mutating the original `options.readOnly` after mount has no effect. + +Read-only blocks keyboard/text/IME input, application mouse reports, direct +`paste()`/`pasteClipboard()`, the paste paths of `runAction()`, `resize()`, +`setSizing()`, `requestPrimary()`, and automatic resize requests. Explicit input +methods throw when disabled; DOM application input is not forwarded. Routing +overrides cannot bypass this policy. Custom actions can still run local operations, +but any terminal input method they call remains gated. + +Active pointer capture and queued mouse movement, pending composition, queued +resize, and pending clipboard pastes are cancelled on policy change. Quickly +re-enabling input does not revive a previously pending paste. Commands already +dispatched cannot be recalled. Output, local selection gestures, history +navigation, resync, and copying remain available, including a copy already in +progress. UI notifications follow their usual coalescing rules. + +```ts +import { WebTerminal } from "@hex1b/web-terminal"; + +const container = document.getElementById("terminal"); +const inputEnabled = document.querySelector("#input-enabled"); +if (!container || !inputEnabled) throw new Error("Missing terminal controls"); + +const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", + readOnly: !inputEnabled.checked +}); +inputEnabled.addEventListener("change", () => terminal.setReadOnly(!inputEnabled.checked)); +``` + +**Client policy is not authorization.** Enforce it independently on each server +view with `Hwt1PresentationAdapter.IsReadOnly`, initially or at runtime. For +example, in the host's existing connection setup (C# snippet): + +```csharp +var presentation = new Hwt1PresentationAdapter { IsReadOnly = true }; +// Attach this presentation to the view's terminal and drive its existing transport loops. +// Only trusted host policy should grant writes: +presentation.IsReadOnly = false; +``` + +The adapter ignores producer-mutating browser input, resize, and primary requests +while read-only, but still processes acknowledgements, resync, history, selection, +and copy. This is **per presentation**, not a producer-wide input lock: direct +terminal automation and other authorized viewers continue. A policy change does +not retract a command the adapter already accepted. The host must update both its +server policy and browser UX; client changes do not authorize themselves, and the +server property does not automatically change client UI. + ### Workload titles The read-only `terminal.title` is the current presented workload title. An empty diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js index 255f3f493e2..ef44c33bb53 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js @@ -52,7 +52,7 @@ function fail(error) { stats.connected = false; clearInterval(metricsTimer); clearInterval(blinkTimer); - socket?.close(1011, "Browser renderer failed"); + socket?.close(); emitStats(); postStatus(message, "error"); renderer?.dispose(); @@ -230,19 +230,22 @@ async function initialize(message) { } receiveFrame(event.data).catch(fail); }); - socket.addEventListener("error", () => fail(new Error("WebSocket connection failed; verify the demo server is running"))); + // WebSocket errors are followed by close, which carries the browser's actual status. + // Rejecting mount on error would terminate this worker before that status can be delivered. socket.addEventListener("close", event => { stats.connected = false; if (!failed && !stopped) { stats.gpu = "stopped"; stats.fps = 0; - postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : ""}). Attach another view to reconnect.`, "error"); - emitStats(); stopped = true; clearInterval(metricsTimer); clearInterval(blinkTimer); renderer?.dispose(); - self.postMessage({ type: "disconnected" }); + self.postMessage({ type: "closed", details: { + code: event.code, reason: event.reason, wasClean: event.wasClean + } }); + postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : ""}). Attach another view to reconnect.`, "error"); + emitStats(); } }); metricsTimer = setInterval(() => { diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map index 428131abf3f..77a716e8538 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map @@ -1 +1 @@ -{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1011, \"Browser renderer failed\");\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, title: metadata.title,\n progress: metadata.progress, shellIntegration: metadata.shellIntegration,\n text, hyperlinks: metadata.hyperlinks\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n socket.addEventListener(\"error\", () => fail(new Error(\"WebSocket connection failed; verify the demo server is running\")));\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"disconnected\" });\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file +{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close();\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, title: metadata.title,\n progress: metadata.progress, shellIntegration: metadata.shellIntegration,\n text, hyperlinks: metadata.hyperlinks\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n // WebSocket errors are followed by close, which carries the browser's actual status.\n // Rejecting mount on error would terminate this worker before that status can be delivered.\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"closed\", details: {\n code: event.code, reason: event.reason, wasClean: event.wasClean\n } });\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts index 84ba68bd19f..0df4440263f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts @@ -115,6 +115,15 @@ export type TerminalSelection = ({ copyError: string; }; export type TerminalStatusLevel = "info" | "ready" | "error"; +/** Native WebSocket close details, not an assertion that the terminal workload completed. */ +export interface TerminalCloseDetails { + /** RFC 6455 status reported by the browser, including 1006 for abnormal loss without a close frame. */ + readonly code: number; + /** Peer-provided close reason, or "". Treat as untrusted text. */ + readonly reason: string; + /** Whether the browser observed a clean WebSocket closing handshake, not workload success. */ + readonly wasClean: boolean; +} export type TerminalRendererKind = "webgpu" | "webgl2"; /** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */ export type TerminalRendererPreference = "auto" | TerminalRendererKind; @@ -293,8 +302,18 @@ export interface WebTerminalOptions extends InputPolicyOptions { font?: TerminalFont; sizing?: TerminalSizing; label?: string; + /** Initial per-view input policy. Change it later with setReadOnly; not a server authorization boundary. */ readOnly?: boolean; onStatus?: (message: string, level: TerminalStatusLevel) => void; + /** + * Receives the native WebSocket close details once, including connection failures and closes + * before the first frame. The view is disconnected before this callback; a pending mount + * rejects after notification. No callback is synthesized for abort, disposal, initialization + * failure, or mount timeout, and none runs after disposal. This client never reconnects + * automatically. Interpret application close codes in the host; even 1000 is not proof of + * workload completion. Callback exceptions reach the host and are not retried. + */ + onClose?: (details: TerminalCloseDetails) => void; onGeometry?: (geometry: TerminalGeometry) => void; onSizingChange?: (sizing: TerminalSizingState) => void; onRoleChange?: (peer: TerminalPeer) => void; @@ -330,6 +349,8 @@ export interface WebTerminalHandle { readonly geometry: TerminalGeometry; readonly peer: TerminalPeer; readonly connected: boolean; + /** Whether this view blocks application input, resize, and primary takeover. */ + readonly readOnly: boolean; /** Current presented workload title, or "" when unset/cleared. Retained on disconnect/dispose. */ readonly title: string; /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */ @@ -351,6 +372,13 @@ export interface WebTerminalHandle { copySelection(options?: CopySelectionOptions): Promise; paste(text: string): void; pasteClipboard(): Promise; + /** + * Changes this view's input policy without remounting or changing peer roles. + * Output, history, selection and copying remain available. Cancels active gestures, + * pending composition and clipboard paste; already dispatched commands cannot be recalled. + * Hosts must separately enforce permissions on their per-view Hwt1PresentationAdapter. + */ + setReadOnly(readOnly: boolean): void; focus(): void; requestPrimary(): void; resize(columns: number, rows: number): void; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map index 2c9b7b74b1b..44f5db0b58d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map index 9af45425be8..d02d5a631ef 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\n/** Application-reported OSC 9;4 indicator, independent of shell execution. */\nexport type TerminalProgressState = \"none\" | \"normal\" | \"error\" | \"indeterminate\" | \"warning\";\n/** Immutable current progress; a hidden or indeterminate indicator has no percentage. */\nexport interface TerminalProgress {\n readonly state: TerminalProgressState;\n readonly percentage: number | null;\n}\n/** Last reported OSC 133 phase. Unknown does not mean idle; commandLine is not execution. */\nexport type TerminalShellIntegrationPhase = \"unknown\" | \"prompt\" | \"commandLine\" | \"executing\" | \"finished\";\n/** Current shell phase and latest reported completion status, not command history. */\nexport interface TerminalShellIntegration {\n readonly phase: TerminalShellIntegrationPhase;\n /** Null means no reported status, not success. Preserved across the next prompt/command. */\n readonly lastExitCode: number | null;\n}\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n /**\n * Receives the first authoritative presented title (including \"\") before mount resolves,\n * then distinct presented changes. The title getter is updated first. Titles are untrusted\n * text; render with textContent, not HTML. No notifications after disposal.\n */\n onTitleChange?: (title: string) => void;\n /**\n * Receives the first authoritative presented progress before mount resolves, then distinct\n * presented changes. Both activity getters update before either callback. Intermediate\n * states may coalesce; this is not a callback for every OSC sequence. None hides the indicator.\n * No notifications after disposal; connection loss does not manufacture a progress clear.\n */\n onProgressChange?: (progress: TerminalProgress) => void;\n /**\n * Receives the first authoritative presented shell state before mount resolves, then distinct\n * presented changes. This is not a lossless command-start/finish stream: entire commands may\n * occur between frames. Replays provide current state, never synthetic command executions.\n */\n onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n /** Current presented workload title, or \"\" when unset/cleared. Retained on disconnect/dispose. */\n readonly title: string;\n /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */\n readonly progress: TerminalProgress;\n /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */\n readonly shellIntegration: TerminalShellIntegration;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\n/** Native WebSocket close details, not an assertion that the terminal workload completed. */\nexport interface TerminalCloseDetails {\n /** RFC 6455 status reported by the browser, including 1006 for abnormal loss without a close frame. */\n readonly code: number;\n /** Peer-provided close reason, or \"\". Treat as untrusted text. */\n readonly reason: string;\n /** Whether the browser observed a clean WebSocket closing handshake, not workload success. */\n readonly wasClean: boolean;\n}\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\n/** Application-reported OSC 9;4 indicator, independent of shell execution. */\nexport type TerminalProgressState = \"none\" | \"normal\" | \"error\" | \"indeterminate\" | \"warning\";\n/** Immutable current progress; a hidden or indeterminate indicator has no percentage. */\nexport interface TerminalProgress {\n readonly state: TerminalProgressState;\n readonly percentage: number | null;\n}\n/** Last reported OSC 133 phase. Unknown does not mean idle; commandLine is not execution. */\nexport type TerminalShellIntegrationPhase = \"unknown\" | \"prompt\" | \"commandLine\" | \"executing\" | \"finished\";\n/** Current shell phase and latest reported completion status, not command history. */\nexport interface TerminalShellIntegration {\n readonly phase: TerminalShellIntegrationPhase;\n /** Null means no reported status, not success. Preserved across the next prompt/command. */\n readonly lastExitCode: number | null;\n}\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n /** Initial per-view input policy. Change it later with setReadOnly; not a server authorization boundary. */\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n /**\n * Receives the native WebSocket close details once, including connection failures and closes\n * before the first frame. The view is disconnected before this callback; a pending mount\n * rejects after notification. No callback is synthesized for abort, disposal, initialization\n * failure, or mount timeout, and none runs after disposal. This client never reconnects\n * automatically. Interpret application close codes in the host; even 1000 is not proof of\n * workload completion. Callback exceptions reach the host and are not retried.\n */\n onClose?: (details: TerminalCloseDetails) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n /**\n * Receives the first authoritative presented title (including \"\") before mount resolves,\n * then distinct presented changes. The title getter is updated first. Titles are untrusted\n * text; render with textContent, not HTML. No notifications after disposal.\n */\n onTitleChange?: (title: string) => void;\n /**\n * Receives the first authoritative presented progress before mount resolves, then distinct\n * presented changes. Both activity getters update before either callback. Intermediate\n * states may coalesce; this is not a callback for every OSC sequence. None hides the indicator.\n * No notifications after disposal; connection loss does not manufacture a progress clear.\n */\n onProgressChange?: (progress: TerminalProgress) => void;\n /**\n * Receives the first authoritative presented shell state before mount resolves, then distinct\n * presented changes. This is not a lossless command-start/finish stream: entire commands may\n * occur between frames. Replays provide current state, never synthetic command executions.\n */\n onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n /** Whether this view blocks application input, resize, and primary takeover. */\n readonly readOnly: boolean;\n /** Current presented workload title, or \"\" when unset/cleared. Retained on disconnect/dispose. */\n readonly title: string;\n /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */\n readonly progress: TerminalProgress;\n /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */\n readonly shellIntegration: TerminalShellIntegration;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n /**\n * Changes this view's input policy without remounting or changing peer roles.\n * Output, history, selection and copying remain available. Cancels active gestures,\n * pending composition and clipboard paste; already dispatched commands cannot be recalled.\n * Hosts must separately enforce permissions on their per-view Hwt1PresentationAdapter.\n */\n setReadOnly(readOnly: boolean): void;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts index 9fcd77a5c10..c5789c854f6 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts @@ -13,6 +13,7 @@ export declare class WebTerminal implements WebTerminalHandle { get geometry(): TerminalGeometry; get peer(): TerminalPeer; get connected(): boolean; + get readOnly(): boolean; /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */ get title(): string; get progress(): TerminalProgress; @@ -41,6 +42,8 @@ export declare class WebTerminal implements WebTerminalHandle { /** Sends an explicit paste through the producer's mode-aware input encoder. */ paste(text: string): void; pasteClipboard(): Promise; + /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */ + setReadOnly(readOnly: boolean): void; focus(): void; /** Request HMP1 primary explicitly; peer notifications confirm the result. */ requestPrimary(): void; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map index b9f7bddd47f..c655b2363fe 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js index b2f60b94849..11824ea740d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js @@ -37,11 +37,14 @@ export class WebTerminal { #geometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 }; #peer = { id: null, primaryId: null, isPrimary: false }; #connected = false; + #closed = false; + #readOnly; #disposed = false; #hasGeometry = false; #resizeTimer; #lastRequested; #compositionTimer; + #resetComposition; #ready = Promise.withResolvers(); #readyTimer; #stats = {}; @@ -93,6 +96,9 @@ export class WebTerminal { } constructor(options) { this.#options = options; + if (options.readOnly !== undefined && typeof options.readOnly !== "boolean") + throw new TypeError("readOnly must be a boolean"); + this.#readOnly = options.readOnly ?? false; this.#renderer = normalizeRenderer(options.renderer); if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) && (typeof options.workerUrl !== "string" || !options.workerUrl.trim())) @@ -111,6 +117,7 @@ export class WebTerminal { get geometry() { return { ...this.#geometry }; } get peer() { return { ...this.#peer }; } get connected() { return this.#connected; } + get readOnly() { return this.#readOnly; } /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */ get title() { return this.#title; } get progress() { return { ...this.#progress }; } @@ -191,7 +198,7 @@ export class WebTerminal { element: this.element, overlay: this.#selectionOverlay, button: requiredElement(this.#inspection, ".copy-selection", HTMLButtonElement), signal: this.#listeners.signal, getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry, - canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }), + canvasSize: this.#canvasSize, connected: this.#connected, readOnly: this.#readOnly }), runAction: this.runAction.bind(this), onSelectionUI: this.#options.onSelectionUI, reportError: error => { @@ -217,7 +224,7 @@ export class WebTerminal { } }; this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), { - state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly, + state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: this.#readOnly, selection: this.selection }), begin: (point, selection) => inspect(() => this.#history.begin(point, selection)), extend: point => inspect(() => this.#history.extend(point)), @@ -284,8 +291,19 @@ export class WebTerminal { this.#connected = true; this.#input.disabled = !this.#canInput(); } - else if (message.type === "disconnected") { - this.#disconnect(); + else if (message.type === "closed") { + if (this.#closed) + return; + this.#closed = true; + clearTimeout(this.#readyTimer); + try { + this.#disconnect(); + if (!this.#disposed) + this.#options.onClose?.(Object.freeze({ ...message.details })); + } + finally { + this.#ready.reject(new Error(`Terminal WebSocket closed (${message.details.code}${message.details.reason ? `: ${message.details.reason}` : ""}) before mounting completed`)); + } } else if (message.type === "status") { if (message.level === "error") { @@ -386,13 +404,13 @@ export class WebTerminal { #queueResize(includeFixed = false) { if (this.#sizing.mode === "fixed" && !includeFixed) return; - if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) + if (!this.#canInput() || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return; // Throttle (rather than debounce) so dragging a primary view updates peers live. this.#resizeTimer = setTimeout(() => { this.#resizeTimer = undefined; const grid = this.#fittedGrid(); - if (!grid || !this.#peer.isPrimary || !this.#connected) + if (!grid || !this.#peer.isPrimary || !this.#canInput()) return; const key = `${grid.columns}x${grid.rows}`; if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) @@ -406,6 +424,8 @@ export class WebTerminal { #send(command) { if (!this.#connected || this.#disposed) throw new Error("Terminal view is not connected"); + if (this.#readOnly && ["input", "paste", "key", "mouse", "resize", "requestPrimary"].includes(command.type)) + throw new Error("Terminal view does not accept input"); this.#post({ type: "command", command }); } #inputCommand(command) { @@ -512,20 +532,23 @@ export class WebTerminal { } } #canInput() { - return this.#connected && this.#hasGeometry && !this.#options.readOnly && + return this.#connected && this.#hasGeometry && !this.#readOnly && (this.#peer.id !== null || this.#peer.isPrimary); } get inputContext() { return Object.freeze({ terminal: this, selection: this.selection, viewport: this.viewport, - buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0, + buffer: this.viewport.buffer ?? null, mouseCaptured: !this.#readOnly && this.#geometry.mouseTracking !== 0, historical: !this.viewport.following || this.viewport.pending, - readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer + readOnly: this.#readOnly, connected: this.#connected, peer: this.peer }); } #resolveInput(input) { try { - return this.#policy.resolve(Object.freeze(input), this.inputContext); + const decision = this.#policy.resolve(Object.freeze(input), this.inputContext); + if (this.#readOnly && decision.route === InputRoute.Application) + return { route: input.type === "pointer" || input.type === "wheel" ? InputRoute.Continue : InputRoute.Consume }; + return decision; } catch (error) { this.#actionFailed(error); @@ -570,7 +593,7 @@ export class WebTerminal { try { if (this.selection.active || (this.selection.pending && this.selection.canExtend)) return await this.copySelection({ clear: true }); - if (!this.#options.readOnly) + if (!this.#readOnly) return await this.pasteClipboard(); return; } @@ -605,6 +628,34 @@ export class WebTerminal { this.paste(text); return text; } + /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */ + setReadOnly(readOnly) { + if (typeof readOnly !== "boolean") + throw new TypeError("readOnly must be a boolean"); + if (this.#disposed) + throw new Error("Terminal view is disposed"); + if (this.#readOnly === readOnly) + return; + const inputFocused = document.activeElement === this.element && + (!this.element.shadowRoot?.activeElement || this.element.shadowRoot.activeElement === this.#input); + this.#readOnly = readOnly; + this.#inputSerial++; + this.#resetComposition?.(); + if (this.#input) { + this.#input.value = ""; + this.#input.disabled = !this.#canInput(); + } + // Set the policy before cancelling so pending moves and button releases cannot leak. + this.#mouse?.cancel(); + this.#mouse?.refresh(); + clearTimeout(this.#resizeTimer); + this.#resizeTimer = undefined; + this.#lastRequested = undefined; + this.#queueResize(true); + if (inputFocused) + this.focus(); + this.#selectionUI?.refresh(); + } #forwardInput(input) { if (input.type === "key") { if (input.meta) @@ -645,6 +696,11 @@ export class WebTerminal { const options = { signal: this.#listeners.signal }; let composing = false; let compositionCommit = null; + this.#resetComposition = () => { + composing = false; + compositionCommit = null; + clearTimeout(this.#compositionTimer); + }; this.element.addEventListener("keydown", event => { if (event.defaultPrevented) return; @@ -665,11 +721,15 @@ export class WebTerminal { input.value = ""; }, options); input.addEventListener("compositionstart", () => { + if (!this.#canInput()) + return; composing = true; compositionCommit = null; clearTimeout(this.#compositionTimer); }, options); input.addEventListener("compositionend", event => { + if (!composing) + return; composing = false; // Accommodate browsers placing the final input before or after compositionend. compositionCommit = typeof event.data === "string" ? event.data : input.value; @@ -700,6 +760,8 @@ export class WebTerminal { } /** Request HMP1 primary explicitly; peer notifications confirm the result. */ requestPrimary() { + if (!this.#canInput()) + throw new Error("Terminal view does not accept input"); if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error("Show the terminal container before taking primary"); const grid = this.#fittedGrid(); @@ -716,6 +778,8 @@ export class WebTerminal { /** Request a grid; never reflow locally before the authoritative response. */ resize(columns, rows) { const grid = dimensions(columns, rows); + if (!this.#canInput()) + throw new Error("Terminal view does not accept input"); if (!this.#peer.isPrimary) throw new Error("Only the primary view can request a terminal resize"); this.#send({ type: "resize", ...grid }); @@ -726,6 +790,8 @@ export class WebTerminal { const next = normalizeSizing(sizing, this.#sizing.fontSize); if (!this.#connected || this.#disposed) throw new Error("Terminal view is not connected"); + if (this.#readOnly) + throw new Error("Terminal view does not accept input"); if (!this.#peer.isPrimary) throw new Error("Only the primary view can change terminal sizing"); this.#sizing = next; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map index fae35655d53..a517c320c01 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n TerminalProgress, TerminalShellIntegration, WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #title = \"\";\n #hasTitle = false;\n #progress: TerminalProgress = { state: \"none\", percentage: null };\n #shellIntegration: TerminalShellIntegration = { phase: \"unknown\", lastExitCode: null };\n #hasActivity = false;\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */\n get title(): string { return this.#title; }\n get progress(): TerminalProgress { return { ...this.#progress }; }\n get shellIntegration(): TerminalShellIntegration { return { ...this.#shellIntegration }; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: !!this.#options.readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: !!this.#options.readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,\n openHyperlink: uri => {\n try { window.open(uri, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n }\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"disconnected\") {\n this.#disconnect();\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n const titleChanged = !this.#hasTitle || this.#title !== message.title;\n const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state ||\n this.#progress.percentage !== message.progress.percentage;\n const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase ||\n this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode;\n this.#title = message.title;\n this.#hasTitle = true;\n this.#progress = { ...message.progress };\n this.#shellIntegration = { ...message.shellIntegration };\n this.#hasActivity = true;\n if (titleChanged) this.#options.onTitleChange?.(this.#title);\n if (!this.#disposed && progressChanged) this.#options.onProgressChange?.(this.progress);\n if (!this.#disposed && shellChanged) this.#options.onShellIntegrationChange?.(this.shellIntegration);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected &&\n (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#hasGeometry || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#connected) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#options.readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: !!this.#options.readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try { return this.#policy.resolve(Object.freeze(input), this.inputContext); }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#options.readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file +{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n TerminalProgress, TerminalShellIntegration, WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #closed = false;\n #readOnly: boolean;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #resetComposition: (() => void) | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #title = \"\";\n #hasTitle = false;\n #progress: TerminalProgress = { state: \"none\", percentage: null };\n #shellIntegration: TerminalShellIntegration = { phase: \"unknown\", lastExitCode: null };\n #hasActivity = false;\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n if (options.readOnly !== undefined && typeof options.readOnly !== \"boolean\")\n throw new TypeError(\"readOnly must be a boolean\");\n this.#readOnly = options.readOnly ?? false;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get readOnly(): boolean { return this.#readOnly; }\n /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */\n get title(): string { return this.#title; }\n get progress(): TerminalProgress { return { ...this.#progress }; }\n get shellIntegration(): TerminalShellIntegration { return { ...this.#shellIntegration }; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: this.#readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: this.#readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,\n openHyperlink: uri => {\n try { window.open(uri, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n }\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"closed\") {\n if (this.#closed) return;\n this.#closed = true;\n clearTimeout(this.#readyTimer);\n try {\n this.#disconnect();\n if (!this.#disposed) this.#options.onClose?.(Object.freeze({ ...message.details }));\n } finally {\n this.#ready.reject(new Error(`Terminal WebSocket closed (${message.details.code}${\n message.details.reason ? `: ${message.details.reason}` : \"\"}) before mounting completed`));\n }\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n const titleChanged = !this.#hasTitle || this.#title !== message.title;\n const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state ||\n this.#progress.percentage !== message.progress.percentage;\n const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase ||\n this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode;\n this.#title = message.title;\n this.#hasTitle = true;\n this.#progress = { ...message.progress };\n this.#shellIntegration = { ...message.shellIntegration };\n this.#hasActivity = true;\n if (titleChanged) this.#options.onTitleChange?.(this.#title);\n if (!this.#disposed && progressChanged) this.#options.onProgressChange?.(this.progress);\n if (!this.#disposed && shellChanged) this.#options.onShellIntegrationChange?.(this.shellIntegration);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected &&\n (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#canInput() || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#canInput()) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly && [\"input\", \"paste\", \"key\", \"mouse\", \"resize\", \"requestPrimary\"].includes(command.type))\n throw new Error(\"Terminal view does not accept input\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: !this.#readOnly && this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: this.#readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try {\n const decision = this.#policy.resolve(Object.freeze(input), this.inputContext);\n if (this.#readOnly && decision.route === InputRoute.Application)\n return { route: input.type === \"pointer\" || input.type === \"wheel\" ? InputRoute.Continue : InputRoute.Consume };\n return decision;\n }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */\n setReadOnly(readOnly: boolean): void {\n if (typeof readOnly !== \"boolean\") throw new TypeError(\"readOnly must be a boolean\");\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n if (this.#readOnly === readOnly) return;\n const inputFocused = document.activeElement === this.element &&\n (!this.element.shadowRoot?.activeElement || this.element.shadowRoot.activeElement === this.#input);\n this.#readOnly = readOnly;\n this.#inputSerial++;\n this.#resetComposition?.();\n if (this.#input) {\n this.#input.value = \"\";\n this.#input.disabled = !this.#canInput();\n }\n // Set the policy before cancelling so pending moves and button releases cannot leak.\n this.#mouse?.cancel();\n this.#mouse?.refresh();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#queueResize(true);\n if (inputFocused) this.focus();\n this.#selectionUI?.refresh();\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.#resetComposition = () => {\n composing = false;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n };\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n if (!this.#canInput()) return;\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n if (!composing) return;\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts index cf2554cc618..35fb7c57e52 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts @@ -1,4 +1,4 @@ -import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration } from "./types.js"; +import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration, TerminalCloseDetails } from "./types.js"; export type SelectionText = { status: "valid"; text: string; @@ -205,7 +205,10 @@ export interface WorkerStats extends TerminalStats { history?: HistoryMetadata | null; } export type WorkerOutputMessage = { - type: "connected" | "disconnected"; + type: "connected"; +} | { + type: "closed"; + details: TerminalCloseDetails; } | { type: "status"; message: string; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map index e1a49adbd7b..a503bd32f7f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map index 9072c6a7317..69a48061ee6 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n title: string;\n progress: TerminalProgress;\n shellIntegration: TerminalShellIntegration;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" | \"disconnected\" }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration;\n text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file +{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration,\n TerminalCloseDetails } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n title: string;\n progress: TerminalProgress;\n shellIntegration: TerminalShellIntegration;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" }\n | { type: \"closed\"; details: TerminalCloseDetails }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration;\n text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index d7abae64dcd..b7d621e4567 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.167.0-alpha.1547.1.798b26c", + "version": "0.167.0-alpha.1549.1.496ccf5", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs index 76f3adc1552..bc42afb9f5b 100644 --- a/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TerminalCommandTests.cs @@ -784,30 +784,7 @@ public async Task TerminalPsCommand_WhenNoTerminalsOfEitherKind_ReportsEmpty() TemporaryWorkspace workspace, Action configure, Action? configureOptions = null) - { - var monitor = new TestAuxiliaryBackchannelMonitor(); - var backchannel = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), - ProcessId = 1234 - }, - SupportsTerminalsV1 = true - }; - configure(backchannel); - monitor.AddConnection("socket.hash1", backchannel); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - configureOptions?.Invoke(options); - }); - - return (services.BuildServiceProvider(), backchannel); - } + => TerminalCommandTestServices.CreateProvider(workspace, outputHelper, configure, configureOptions); private static ResourceSnapshot CreateSnapshot(string name, string? displayName = null) { diff --git a/tests/Aspire.Cli.Tests/Commands/TerminalTapePlayCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TerminalTapePlayCommandTests.cs new file mode 100644 index 00000000000..bbbf24cebdd --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TerminalTapePlayCommandTests.cs @@ -0,0 +1,383 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; + +namespace Aspire.Cli.Tests.Commands; + +public class TerminalTapePlayCommandTests(ITestOutputHelper outputHelper) +{ + [Theory] + [InlineData("terminal tape --help", 0)] + [InlineData("terminal tape play --help", 0)] + [InlineData("terminal tape", 1)] + [InlineData("terminal tape play", 1)] + [InlineData("terminal tape play shell", 1)] + [InlineData("terminal tape play --tape-file probe.tape", 1)] + public async Task CommandHierarchy(string arguments, int expectedExitCode) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, + options => options.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]); + using var provider = services.BuildServiceProvider(); + + var result = provider.GetRequiredService().Parse(arguments); + + Assert.Equal(expectedExitCode, await result.InvokeAsync().DefaultTimeout()); + } + + [Fact] + public void RequiresTerminalFeature() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var result = provider.GetRequiredService().Parse("terminal tape play shell --tape-file probe.tape"); + + Assert.NotEmpty(result.Errors); + } + + [Theory] + [InlineData("missing.tape", null, null)] + [InlineData("broken.tape", "NotACommand", ":1:1:")] + public async Task InvalidFileFailsBeforeResourceDiscovery(string path, string? contents, string? diagnosticLocation) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + if (contents is not null) + { + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, path), contents); + } + using var errors = new StringWriter(); + var (provider, backchannel) = TerminalCommandTestServices.CreateProvider(workspace, outputHelper, _ => { }, + options => options.ErrorTextWriter = errors); + using (provider) + { + var result = provider.GetRequiredService().Parse( + ["terminal", "tape", "play", "shell", "--tape-file", path]); + + Assert.Equal(CliExitCodes.InvalidCommand, await result.InvokeAsync().DefaultTimeout()); + Assert.Equal(0, backchannel.GetResourceSnapshotsCallCount); + Assert.Contains(path, errors.ToString()); + if (diagnosticLocation is not null) + { + Assert.Contains(diagnosticLocation, errors.ToString()); + } + } + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("4294968")] + [InlineData("2147483647")] + public async Task InvalidTimeoutFailsBeforeResourceDiscovery(string timeout) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var (provider, backchannel) = TerminalCommandTestServices.CreateProvider(workspace, outputHelper, _ => { }); + using (provider) + { + var result = provider.GetRequiredService().Parse( + ["terminal", "tape", "play", "shell", "--tape-file", "probe.tape", "--timeout", timeout]); + + Assert.Equal(CliExitCodes.InvalidCommand, await result.InvokeAsync().DefaultTimeout()); + Assert.Equal(0, backchannel.GetResourceSnapshotsCallCount); + } + } + + [Fact] + public async Task NoRunningAppHostFails() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "probe.tape"), ""); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, + options => options.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]); + using var provider = services.BuildServiceProvider(); + + var result = provider.GetRequiredService().Parse("terminal tape play shell --tape-file probe.tape"); + + Assert.Equal(CliExitCodes.FailedToFindProject, await result.InvokeAsync().DefaultTimeout()); + } + + [Theory] + [InlineData("incompatible", CliExitCodes.AppHostIncompatible)] + [InlineData("missing", CliExitCodes.InvalidCommand)] + [InlineData("unavailable", CliExitCodes.InvalidCommand)] + [InlineData("exited", CliExitCodes.FailedToExecuteResourceCommand)] + [InlineData("replicas", CliExitCodes.InvalidCommand)] + [InlineData("wrong-replica", CliExitCodes.InvalidCommand)] + public async Task InvalidResourceFailsBeforeConnecting(string scenario, int expectedExitCode) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "probe.tape"), ""); + using var provider = CreateProvider(workspace, null, configure: backchannel => + { + switch (scenario) + { + case "incompatible": + backchannel.SupportsTerminalsV1 = false; + break; + case "missing": + backchannel.ResourceSnapshots = []; + break; + case "unavailable": + backchannel.TerminalInfoResponse = new() { IsAvailable = false }; + break; + case "exited": + backchannel.TerminalInfoResponse = new() + { + IsAvailable = true, + Replicas = [new() { ReplicaIndex = 0, Label = "shell-0", ConsumerUdsPath = "unused", IsAlive = false }] + }; + break; + case "replicas": + backchannel.TerminalInfoResponse = new() + { + IsAvailable = true, + Replicas = + [ + new() { ReplicaIndex = 0, Label = "shell-0", ConsumerUdsPath = "unused", IsAlive = true }, + new() { ReplicaIndex = 1, Label = "shell-1", ConsumerUdsPath = "unused", IsAlive = true } + ] + }; + break; + } + }); + string[] replicaArguments = scenario == "wrong-replica" ? ["--replica", "7"] : []; + var result = provider.GetRequiredService().Parse( + ["terminal", "tape", "play", "shell", "--tape-file", "probe.tape", .. replicaArguments]); + + Assert.Equal(expectedExitCode, await result.InvokeAsync().DefaultTimeout()); + } + + [Theory] + [InlineData("", 80, 24)] + [InlineData("Ready for input", 101, 37)] + public async Task EmptyTapeReadsInitialScreenWithoutResizingOrStoppingProducer(string screen, int width, int height) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(width, height); + if (screen.Length > 0) + { + await host.WriteAsync(screen); + } + var stdout = new TestOutputTextWriter(outputHelper); + using var provider = CreateProvider(workspace, host.SocketPath, stdout); + var directory = workspace.WorkspaceRoot.CreateSubdirectory("tape files"); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "probe.tape"), "# Inspect the current screen."); + var result = provider.GetRequiredService().Parse( + ["terminal", "tape", "play", "shell", "--tape-file", Path.Combine("tape files", "probe.tape"), "--replica", "0"]); + + Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout()); + Assert.Equal(host.GetScreenText().TrimEnd(), string.Join('\n', stdout.Logs).TrimEnd()); + Assert.Equal((width, height), host.GetDimensions()); + Assert.True(host.IsRunning); + await host.WriteAsync("Still running"); + } + + [Fact] + public async Task PlaysInputAndWaitsForResponseFromSourcedTape() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(); + await host.WriteAsync("Ready for input\r\n"); + var stdout = new TestOutputTextWriter(outputHelper); + using var provider = CreateProvider(workspace, host.SocketPath, stdout); + var directory = workspace.WorkspaceRoot.CreateSubdirectory("scripts"); + directory.CreateSubdirectory("nested"); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "play.tape"), """ + Set TypingSpeed 0 + Source nested/input.tape + Wait+Screen /Accepted: hello/ + """); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "nested", "input.tape"), """ + Wait+Screen /Ready for input/ + Type "hello" + Enter + """); + var result = provider.GetRequiredService().Parse( + ["terminal", "tape", "play", "shell", "--tape-file", Path.Combine("scripts", "play.tape")]); + + var play = result.InvokeAsync(); + Assert.Equal("hello\r", await host.ReadInputAsync(6)); + await host.WriteAsync("Accepted: hello\r\n"); + + Assert.Equal(CliExitCodes.Success, await play.DefaultTimeout()); + Assert.Equal(host.GetScreenText().TrimEnd(), string.Join('\n', stdout.Logs).TrimEnd()); + Assert.True(host.IsRunning); + } + + [Fact] + public async Task ResolvesTextOutputBesideRootTape() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(); + await host.WriteAsync("Ready for input"); + using var provider = CreateProvider(workspace, host.SocketPath); + var directory = workspace.WorkspaceRoot.CreateSubdirectory("scripts"); + directory.CreateSubdirectory("nested"); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "play.tape"), + "Output screen.txt\nSource nested/wait.tape"); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "nested", "wait.tape"), + "Output ignored.txt\nWait+Screen /Ready for input/"); + var result = provider.GetRequiredService().Parse( + ["terminal", "tape", "play", "shell", "--tape-file", Path.Combine("scripts", "play.tape")]); + + Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout()); + Assert.Collection(Directory.EnumerateFiles(directory.FullName, "*.txt", SearchOption.AllDirectories), + path => Assert.Equal(Path.Combine(directory.FullName, "screen.txt"), path)); + var capture = await File.ReadAllTextAsync(Path.Combine(directory.FullName, "screen.txt")); + await Verify(capture.Replace(workspace.WorkspaceRoot.FullName, "{workspace}"), "txt"); + } + + [Theory] + [InlineData("Screenshot unsupported.png")] + [InlineData("Set Width 120")] + [InlineData("Env SECRET value")] + [InlineData("Source missing.tape")] + [InlineData("Output missing-directory/screen.txt")] + public async Task InvalidTapePreflightDoesNotSendEarlierInput(string invalidCommand) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(); + using var provider = CreateProvider(workspace, host.SocketPath); + var file = Path.Combine(workspace.WorkspaceRoot.FullName, "play.tape"); + await File.WriteAllTextAsync(file, $"Set TypingSpeed 0\nType \"unexpected\"\n{invalidCommand}"); + var command = provider.GetRequiredService(); + var invalid = command.Parse("terminal tape play shell --tape-file play.tape"); + + Assert.Equal(CliExitCodes.InvalidCommand, await invalid.InvokeAsync().DefaultTimeout()); + + // A later valid tape provides a deterministic input boundary: rejected preflight must not + // leave any "unexpected" bytes ahead of this marker, without relying on a quiet-time delay. + await File.WriteAllTextAsync(file, "Set TypingSpeed 0\nType \"marker\""); + var valid = command.Parse("terminal tape play shell --tape-file play.tape"); + Assert.Equal(CliExitCodes.Success, await valid.InvokeAsync().DefaultTimeout()); + Assert.Equal("marker", await host.ReadInputAsync(6)); + } + + [Fact] + public async Task WaitFailurePrintsCurrentScreenAndSourceLocation() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(); + await host.WriteAsync("Waiting for authentication"); + var stdout = new TestOutputTextWriter(outputHelper); + using var errors = new StringWriter(); + using var provider = CreateProvider(workspace, host.SocketPath, stdout, errors); + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "wait.tape"), + "# Deliberately impossible condition\nWait+Screen@100ms /never_appears/"); + var result = provider.GetRequiredService().Parse("terminal tape play shell --tape-file wait.tape"); + + Assert.Equal(CliExitCodes.FailedToExecuteResourceCommand, await result.InvokeAsync().DefaultTimeout()); + Assert.Equal(host.GetScreenText().TrimEnd(), string.Join('\n', stdout.Logs).TrimEnd()); + Assert.Contains("wait.tape:2:1:", errors.ToString()); + Assert.True(host.IsRunning); + } + + [Fact] + public async Task OverallTimeoutCancelsPlaybackWithoutStoppingProducer() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(); + var time = new FakeTimeProvider(); + using var provider = CreateProvider(workspace, host.SocketPath, + configureOptions: options => options.TimeProvider = time); + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "wait.tape"), + "Set TypingSpeed 0\nType \"started\"\nSleep 60s"); + var result = provider.GetRequiredService().Parse("terminal tape play shell --tape-file wait.tape --timeout 5"); + + var play = result.InvokeAsync(); + Assert.Equal("started", await host.ReadInputAsync(7)); + time.Advance(TimeSpan.FromSeconds(5)); + + Assert.Equal(CliExitCodes.WaitTimeout, await play.DefaultTimeout()); + Assert.True(host.IsRunning); + } + + [Fact] + public async Task UserCancellationLeavesProducerRunning() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(); + using var provider = CreateProvider(workspace, host.SocketPath); + using var cancellation = new CancellationTokenSource(); + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "wait.tape"), + "Set TypingSpeed 0\nType \"started\"\nSleep 60s"); + var result = provider.GetRequiredService().Parse("terminal tape play shell --tape-file wait.tape"); + + var play = result.InvokeAsync(cancellationToken: cancellation.Token); + Assert.Equal("started", await host.ReadInputAsync(7)); + await cancellation.CancelAsync(); + + Assert.Equal(CliExitCodes.Cancelled, await play.DefaultTimeout()); + Assert.True(host.IsRunning); + } + + [Fact] + public async Task DisconnectionDuringPlaybackFailsPromptly() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(); + using var errors = new StringWriter(); + using var provider = CreateProvider(workspace, host.SocketPath, stderr: errors); + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "wait.tape"), + "Set TypingSpeed 0\nType \"started\"\nSleep 60s"); + var result = provider.GetRequiredService().Parse("terminal tape play shell --tape-file wait.tape"); + + var play = result.InvokeAsync(); + Assert.Equal("started", await host.ReadInputAsync(7)); + await host.DisposeAsync(); + + Assert.Equal(CliExitCodes.FailedToExecuteResourceCommand, await play.DefaultTimeout()); + Assert.Contains("connection closed", errors.ToString()); + } + + [Fact] + public async Task UnreachableTerminalFails() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await using var host = await TerminalTapeTestHost.StartAsync(); + await host.DisposeAsync(); + using var provider = CreateProvider(workspace, host.SocketPath); + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "probe.tape"), ""); + var result = provider.GetRequiredService().Parse("terminal tape play shell --tape-file probe.tape"); + + Assert.Equal(CliExitCodes.FailedToExecuteResourceCommand, await result.InvokeAsync().DefaultTimeout()); + } + + private ServiceProvider CreateProvider( + TemporaryWorkspace workspace, + string? socketPath, + TestOutputTextWriter? stdout = null, + StringWriter? stderr = null, + Action? configure = null, + Action? configureOptions = null) + { + var (provider, _) = TerminalCommandTestServices.CreateProvider(workspace, outputHelper, backchannel => + { + backchannel.ResourceSnapshots = + [ + new ResourceSnapshot { Name = "shell-0", DisplayName = "shell", ResourceType = "Executable", State = "Running" } + ]; + backchannel.TerminalInfoResponse = new() + { + IsAvailable = true, + Replicas = [new() { ReplicaIndex = 0, Label = "shell-0", ConsumerUdsPath = socketPath ?? "unused", IsAlive = true }] + }; + configure?.Invoke(backchannel); + }, options => + { + options.OutputTextWriter = stdout; + options.ErrorTextWriter = stderr; + configureOptions?.Invoke(options); + }); + return provider; + } +} diff --git a/tests/Aspire.Cli.Tests/Snapshots/TerminalTapePlayCommandTests.ResolvesTextOutputBesideRootTape.verified.txt b/tests/Aspire.Cli.Tests/Snapshots/TerminalTapePlayCommandTests.ResolvesTextOutputBesideRootTape.verified.txt new file mode 100644 index 00000000000..782ec6317bc --- /dev/null +++ b/tests/Aspire.Cli.Tests/Snapshots/TerminalTapePlayCommandTests.ResolvesTextOutputBesideRootTape.verified.txt @@ -0,0 +1,62 @@ +Ready for input + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── +Ready for input + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── diff --git a/tests/Aspire.Cli.Tests/TestServices/TerminalCommandTestServices.cs b/tests/Aspire.Cli.Tests/TestServices/TerminalCommandTestServices.cs new file mode 100644 index 00000000000..f3942671896 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TerminalCommandTestServices.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Backchannel; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.TestServices; + +internal static class TerminalCommandTestServices +{ + public static (ServiceProvider Provider, TestAppHostAuxiliaryBackchannel Backchannel) CreateProvider( + TemporaryWorkspace workspace, + ITestOutputHelper outputHelper, + Action configure, + Action? configureOptions = null) + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var backchannel = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 1234 + }, + SupportsTerminalsV1 = true + }; + configure(backchannel); + monitor.AddConnection("socket.hash1", backchannel); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]; + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + configureOptions?.Invoke(options); + }); + + return (services.BuildServiceProvider(), backchannel); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TerminalTapeTestHost.cs b/tests/Aspire.Cli.Tests/TestServices/TerminalTapeTestHost.cs new file mode 100644 index 00000000000..5848b91e052 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TerminalTapeTestHost.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.IO.Pipelines; +using System.Net.Sockets; +using System.Text; +using Hex1b; +using Hex1b.Automation; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// A real HMP resource terminal backed by controllable streams, without a shell or child process. +/// +internal sealed class TerminalTapeTestHost : IAsyncDisposable +{ + // Keep the socket outside the deeper CLI workspace to fit macOS's Unix socket path limit. + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("tape-"); + private readonly CancellationTokenSource _stopping = new(); + private readonly Stream _outputReader; + private readonly Stream _inputWriter; + private readonly Hex1bTerminal _terminal; + private readonly Hmp1PresentationAdapter _presentation; + private readonly Socket _listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + private readonly ConcurrentBag _clients = []; + private readonly Task _runTask; + private readonly Task _listenTask; + private bool _disposed; + + private TerminalTapeTestHost(int width, int height) + { + SocketPath = Path.Combine(_directory.FullName, "h.sock"); + var output = new Pipe(); + var input = new Pipe(); + _outputReader = output.Reader.AsStream(); + Output = output.Writer.AsStream(); + Input = input.Reader.AsStream(); + _inputWriter = input.Writer.AsStream(); + _presentation = new Hmp1PresentationAdapter(width, height); + _terminal = Hex1bTerminal.CreateBuilder() + .WithWorkload(new StreamWorkloadAdapter(_outputReader, _inputWriter)) + .WithPresentation(_presentation) + .Build(); + _runTask = _terminal.RunAsync(_stopping.Token); + _listener.Bind(new UnixDomainSocketEndPoint(SocketPath)); + _listener.Listen(); + _listenTask = ListenAsync(); + } + + public string SocketPath { get; } + public Stream Input { get; } + public Stream Output { get; } + public bool IsRunning => !_runTask.IsCompleted; + + public static Task StartAsync(int width = 100, int height = 30) + { + return Task.FromResult(new TerminalTapeTestHost(width, height)); + } + + private async Task ListenAsync() + { + while (!_stopping.IsCancellationRequested) + { + var socket = await _listener.AcceptAsync(_stopping.Token); + var client = await _presentation.AddClient(new NetworkStream(socket, ownsSocket: true), _stopping.Token); + _clients.Add(client); + } + } + + public async Task ReadInputAsync(int byteCount) + { + var bytes = new byte[byteCount]; + await Input.ReadExactlyAsync(bytes).AsTask().DefaultTimeout(); + return Encoding.UTF8.GetString(bytes); + } + + public string GetScreenText() + { + using var snapshot = _terminal.CreateSnapshot(); + return snapshot.GetScreenText(); + } + + public (int Width, int Height) GetDimensions() + { + using var snapshot = _terminal.CreateSnapshot(); + return (snapshot.Width, snapshot.Height); + } + + public async Task WriteAsync(string text) + { + await Output.WriteAsync(Encoding.UTF8.GetBytes(text)); + using var snapshot = await new Hex1bTerminalInputSequenceBuilder() + .WaitUntil(snapshot => snapshot.ContainsText(text.Trim()), TimeSpan.FromSeconds(10), "Producer output was not applied.") + .Build() + .ApplyAsync(_terminal, CancellationToken.None) + .DefaultTimeout(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + _disposed = true; + await _stopping.CancelAsync(); + try + { + await Task.WhenAll(_runTask, _listenTask); + } + catch (OperationCanceledException) when (_stopping.IsCancellationRequested) + { + // The test owns this producer and stops it only during cleanup. + } + finally + { + _listener.Dispose(); + foreach (var client in _clients) + { + await client.DisposeAsync(); + } + await _terminal.DisposeAsync(); + await Output.DisposeAsync(); + await Input.DisposeAsync(); + await _outputReader.DisposeAsync(); + await _inputWriter.DisposeAsync(); + _stopping.Dispose(); + _directory.Delete(recursive: true); + } + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 2183a885d97..1611d765878 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -276,8 +276,11 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 0ad7e79efea..1a832269bfd 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; +using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; using Xunit; @@ -65,6 +66,7 @@ public void ChromeAndFooter_RespectSurfaceParameters(bool chromeless, bool showD Assert.Equal(chromeless, cut.Find(".terminal-view").ClassList.Contains("terminal-chromeless")); Assert.Equal(chromeless ? 0 : 1, cut.FindAll(".terminal-titlebar").Count); Assert.Equal(showDimensions ? 1 : 0, cut.FindAll(".terminal-size-select").Count); + Assert.Equal(showDimensions ? 1 : 0, cut.FindAll(".terminal-fit").Count); Assert.Single(cut.FindAll(".terminal-font-minus")); Assert.Single(cut.FindAll(".terminal-font-plus")); Assert.Equal(Resources.ConsoleLogs.TerminalFocusControlsHint, cut.Find(".terminal-focus-hint").TextContent); @@ -72,6 +74,74 @@ public void ChromeAndFooter_RespectSurfaceParameters(bool chromeless, bool showD Assert.Equal(Resources.ConsoleLogs.TerminalToolbarIncreaseFontSize, cut.Find(".terminal-font-plus").GetAttribute("aria-label")); } + [Fact] + public async Task InitialFontSize_SeedsMountWithoutResettingCurrentFont() + { + var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); + module.Setup("initTerminal", _ => true).SetResult(1); + var cut = RenderComponent(builder => builder + .Add(p => p.ResourceName, "shell") + .Add(p => p.InitialFontSize, 19)); + Assert.Equal(19, cut.Instance.FontSize); + var options = Assert.IsType( + Assert.Single(module.Invocations, i => i.Identifier == "initTerminal").Arguments[3]); + Assert.Equal(19, options.InitialFontSize); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Connected = true, FontPx = 21 + })); + cut.SetParametersAndRender(builder => builder.Add(p => p.InitialFontSize, 17)); + Assert.Equal(21, cut.Instance.FontSize); + Assert.Single(module.Invocations, i => i.Identifier == "initTerminal"); + } + + [Fact] + public async Task FitButton_UsesCurrentStateAndKeepsThePickerForDimensionsOnly() + { + var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); + module.Setup("initTerminal", _ => true).SetResult(1); + var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "shell")); + var fitButton = cut.FindComponents().Single(p => p.Instance.Class == "terminal-fit"); + Assert.True(fitButton.Instance.Disabled); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Connected = true, FitEnabled = true, + Cols = 97, Rows = 38, SizeKey = "97x38", SizeSelectEnabled = true + })); + Assert.False(fitButton.Instance.Disabled); + Assert.Equal(Resources.ConsoleLogs.TerminalToolbarGridSizeAuto, cut.Find(".terminal-fit").TextContent.Trim()); + var items = cut.FindComponent>().Instance.Items; + Assert.NotNull(items); + Assert.Equal([new("97x38", "97\u00d738", 97, 38), new TerminalSizePreset("80x24", "80\u00d724", 80, 24)], items); + cut.Find(".terminal-fit").Click(); + Assert.Equal(new object?[] { 1 }, Assert.Single(module.Invocations, i => i.Identifier == "fitToContainer").Arguments); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Connected = true, FitEnabled = false, + Cols = 97, Rows = 38, SizeKey = "97x38" + })); + Assert.True(fitButton.Instance.Disabled); + } + + [Fact] + public void AutoFit_ChangesDuringInitializationApplyWithoutReconnecting() + { + var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); + var init = module.Setup("initTerminal", _ => true); + var cut = RenderComponent(builder => builder + .Add(p => p.ResourceName, "shell").Add(p => p.AutoFit, true)); + Assert.True(Assert.IsType(Assert.Single(init.Invocations).Arguments[3]).AutoFit); + cut.SetParametersAndRender(builder => builder.Add(p => p.AutoFit, false)); + init.SetResult(1); + cut.WaitForAssertion(() => Assert.Equal(new object?[] { 1, false }, + Assert.Single(module.Invocations, i => i.Identifier == "setAutoFit").Arguments)); + cut.SetParametersAndRender(builder => builder.Add(p => p.AutoFit, true)); + Assert.Equal(["initTerminal", "getSizePresets", "setAutoFit", "setAutoFit"], module.Invocations.Select(i => i.Identifier)); + } + [Fact] public async Task TerminalChrome_UsesCurrentDimensionsAndIgnoresStaleCallbacks() { @@ -376,7 +446,7 @@ public void EndpointChange_RotatesRegistrationAndDisablesOldInput() } [Fact] - public void CompletionBeforeInitializationFinishes_IsAvailableToTheRetryCheckWithoutAnObserver() + public void CompletionBeforeInitializationFinishes_DisablesInputWithoutDismissingTheView() { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); @@ -385,15 +455,15 @@ public void CompletionBeforeInitializationFinishes_IsAvailableToTheRetryCheckWit var viewId = Assert.IsType(Assert.Single(init.Invocations).Arguments[3]).ViewId; Assert.True(Services.GetRequiredService().TryGet( viewId, "/api/apphost-terminal?terminalId=terminal", out var session)); - Assert.False(cut.Instance.IsTerminalEnded(viewId)); + Assert.False(session.Ended.IsCompleted); session.MarkEnded(); - Assert.True(cut.Instance.IsTerminalEnded(viewId)); + Assert.True(session.Ended.IsCompletedSuccessfully); Assert.True(session.ReadOnly); Assert.Single(cut.FindAll(".terminal-container")); Assert.Equal(["initTerminal"], module.Invocations.Select(i => i.Identifier)); init.SetResult(1); cut.WaitForAssertion(() => Assert.Single(init.Invocations)); - Assert.True(cut.Instance.IsTerminalEnded(viewId)); + Assert.True(session.ReadOnly); } private static void AssertBoundEndpoint(string expected, object? value) diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs index 29a58bdedf1..3cd77e14fc7 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs @@ -58,6 +58,8 @@ public async Task Render_TerminalRespectsDisabledAndLoading(bool disabled, bool var cut = getCut(); cut.WaitForAssertion(() => Assert.Equal(disabled || loading, cut.FindComponent().Instance.ReadOnly)); var terminal = cut.FindComponent().Instance; + Assert.True(terminal.AutoFit); + Assert.False(terminal.ShowDimensionsPicker); foreach (var state in new (bool Disabled, bool Loading)[] { (false, false), (true, false), (true, true), (false, true), (false, false) }) { diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index c49b42210e7..bdc7408d660 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -70,6 +70,8 @@ beforeEach(() => { peer: { id: "browser-1", primaryId: "cli-1", isPrimary: false }, geometry: { columns: 100, rows: 30 }, sizing: { ...options.sizing }, + readOnly: options.readOnly, + readOnlyCalls: [], sizingCalls: [], primaryRequests: 0, focusCalls: 0, @@ -84,6 +86,10 @@ beforeEach(() => { this.sizingCalls.push(sizing); options.onSizingChange(sizing); }, + setReadOnly(readOnly) { + this.readOnly = readOnly; + this.readOnlyCalls.push(readOnly); + }, focus() { this.focusCalls++; document.activeElement = this.element; }, clearSelection() { this.selectionClears++; }, refreshSelectionUI() { this.selectionRefreshes++; }, @@ -92,6 +98,10 @@ beforeEach(() => { element, options, client, resolve() { ready.resolve(client); }, reject(error = new Error("No first frame")) { ready.reject(error); }, + close(code, reason = "", wasClean = true) { + client.connected = false; + options.onClose({ code, reason, wasClean }); + }, role(primary) { client.peer = { ...client.peer, primaryId: primary ? client.peer.id : "cli-1", isPrimary: primary }; options.onRoleChange(client.peer); @@ -160,7 +170,7 @@ function selectionEvent(attempt, overrides = {}) { return event; } -function mount({ visible = true, dotNetRef, options = {}, isEnded = () => false } = {}) { +function mount({ visible = true, dotNetRef, options = {} } = {}) { const element = { clientWidth: visible ? 800 : 0, clientHeight: visible ? 600 : 0, @@ -182,8 +192,10 @@ function mount({ visible = true, dotNetRef, options = {}, isEnded = () => false }); const viewId = options.viewId ?? `view-${++serial}`; const id = terminal.initTerminal(element, "wss://dashboard/api/terminal?resource=app&replica=1", - dotNetRef ?? { invokeMethodAsync: (name, value) => - name === "OnTerminalStateChanged" ? snapshots.push(value) : isEnded(value) }, + dotNetRef ?? { invokeMethodAsync: (name, value) => { + assert.equal(name, "OnTerminalStateChanged"); + snapshots.push(value); + } }, { label: "Localized terminal input", ...options, viewId }, template, footer); ids.push(id); return { id, element, controls, footer, footerControls, viewId }; @@ -390,8 +402,9 @@ test("init returns an id while mount waits for its first connected frame", async await settle(); assert.deepEqual(snapshots.at(-1), { terminalId: id, generation: 1, status: "viewer", connected: true, - isPrimary: false, canTakeControl: true, sizeMode: "font", sizeKey: "auto", + isPrimary: false, canTakeControl: true, sizeMode: "font", sizeKey: "100x30", fontPx: 13, fontControlsEnabled: true, sizeSelectEnabled: true, + fitEnabled: true, canDecreaseFontSize: true, canIncreaseFontSize: true, cols: 100, rows: 30, error: null, }); @@ -586,7 +599,7 @@ test("explicit reconnect cancels the automatic retry and drops a pending sizing attempts[2].resolve(); await settle(); assert.equal(terminal.getToolbarState(id).generation, 3); - assert.equal(terminal.getToolbarState(id).sizeKey, "auto"); + assert.equal(terminal.getToolbarState(id).sizeKey, "100x30"); }); test("automatic retries are bounded and explicit reconnect resets the exhausted budget", async () => { @@ -667,12 +680,50 @@ test("font preference follows its surface across remounts but not another surfac terminal.setFontSizeFromHost(first.id, 21); assert.equal(terminal.getToolbarState(first.id).fontPx, 21); terminal.disposeTerminal(first.id); - mount({ options: { sizeMemoryKey: "memory:dock" } }); + mount({ options: { sizeMemoryKey: "memory:dock", initialFontSize: 15 } }); mount({ options: { sizeMemoryKey: "memory:window" } }); assert.equal(attempts[1].options.sizing.fontSize, 21); assert.equal(attempts[2].options.sizing.fontSize, 13); }); +test("initial font preferences use package bounds and default when absent", () => { + for (const [initialFontSize, expected] of [ + [undefined, 13], [null, 13], [NaN, 13], + [4, MIN_FONT_SIZE], [72, MAX_FONT_SIZE], [18.6, 19], + ]) { + mount({ options: { initialFontSize } }); + assert.equal(attempts.at(-1).options.sizing.fontSize, expected); + } +}); + +test("a detached surface fits as primary using the originating font without stealing control back", async () => { + const source = mount({ options: { sizeMemoryKey: "handoff:dock" } }); + attempts[0].resolve(); + await settle(); + attempts[0].role(true); + terminal.setFontSizeFromHost(source.id, 21); + const font = terminal.getToolbarState(source.id).fontPx; + terminal.disposeTerminal(source.id); + + mount({ options: { autoFit: true, initialFontSize: font } }); + const popup = attempts[1]; + assert.deepEqual(popup.options.sizing, { mode: "auto", fontSize: 21 }); + popup.resolve(); + await settle(); + assert.equal(popup.client.primaryRequests, 1); + popup.role(true); + assert.deepEqual(popup.client.sizingCalls, [{ mode: "auto", fontSize: 21 }]); + popup.role(false); + assert.equal(popup.client.primaryRequests, 1); + + mount({ options: { sizeMemoryKey: "handoff:dock", autoFit: true } }); + attempts[2].resolve(); + await settle(); + assert.equal(attempts[2].client.primaryRequests, 1); + attempts[2].role(true); + assert.deepEqual(attempts[2].client.sizingCalls, [{ mode: "auto", fontSize: 21 }]); +}); + test("font stepper states use package bounds instead of the former xterm range", async () => { const { id } = mount(); attempts[0].resolve(); @@ -701,9 +752,94 @@ test("container-sized surfaces retain the font stepper but reject fixed presets" assert.deepEqual(attempts[0].client.sizingCalls, [{ mode: "auto", fontSize: 18 }]); }); -test("per-view read-only preserves inspection and blocks host sizing and control without static native mode", async () => { +test("opening an auto-fit surface takes primary once and preserves font size across activation", async () => { + const { id, element } = mount({ options: { autoFit: true, showDimensions: false } }); + attempts[0].resolve(); + await settle(); + const client = attempts[0].client; + assert.equal(client.primaryRequests, 1); + assert.deepEqual(client.sizingCalls, []); + attempts[0].role(true); + assert.deepEqual(client.sizingCalls, [{ mode: "auto", fontSize: 13 }]); + terminal.setFontSizeFromHost(id, 18); + element.clientWidth = 1000; + element.clientHeight = 700; + observers[0].callback(); + assert.deepEqual(client.sizing, { mode: "auto", fontSize: 18 }); + assert.equal(client.primaryRequests, 1, "Native automatic sizing handles container resize"); + + attempts[0].role(false); + observers[0].callback(); + assert.equal(client.primaryRequests, 1, "Losing primary must not start a resize ownership fight"); + terminal.setAutoFit(id, false); + terminal.setAutoFit(id, true); + terminal.setAutoFit(id, true); + assert.equal(client.primaryRequests, 2); + attempts[0].role(true); + assert.deepEqual(client.sizingCalls, [ + { mode: "auto", fontSize: 13 }, + { mode: "auto", fontSize: 18 }, + { mode: "auto", fontSize: 18 }, + ]); + assert.equal(attempts.length, 1); +}); + +test("auto-fit waits for a visible writable view and does not size a deactivated pane", async () => { + const { id, element } = mount({ visible: false, options: { autoFit: true, readOnly: true } }); + assert.equal(attempts.length, 0); + element.clientWidth = 800; + element.clientHeight = 600; + observers[0].callback(); + attempts[0].resolve(); + await settle(); + const client = attempts[0].client; + assert.equal(client.primaryRequests, 0); + terminal.setReadOnly(id, false); + assert.equal(client.primaryRequests, 1); + terminal.setAutoFit(id, false); + attempts[0].role(true); + assert.deepEqual(client.sizingCalls, []); + terminal.setReadOnly(id, true); + terminal.setAutoFit(id, true); + assert.deepEqual(client.sizingCalls, []); + terminal.setReadOnly(id, false); + assert.deepEqual(client.sizingCalls, [{ mode: "auto", fontSize: 13 }]); + terminal.disposeTerminal(id); + observers[0].callback(); + assert.equal(client.primaryRequests, 1); +}); + +test("Fit is separate from fixed presets and disabled only for an auto-sized primary or blocked view", async () => { + const { id } = mount(); + assert.equal(terminal.getToolbarState(id).fitEnabled, false); + assert.deepEqual(terminal.getSizePresets().map(p => p.value), ["80x24", "80x30", "100x30", "132x30", "132x50"]); + attempts[0].resolve(); + await settle(); + assert.equal(terminal.getToolbarState(id).fitEnabled, true); + terminal.fitToContainer(id); + assert.equal(attempts[0].client.primaryRequests, 1); + attempts[0].role(true); + assert.equal(terminal.getToolbarState(id).fitEnabled, false); + terminal.setSizeModeFromHost(id, "80x24"); + assert.equal(terminal.getToolbarState(id).fitEnabled, true); + terminal.fitToContainer(id); + assert.equal(terminal.getToolbarState(id).fitEnabled, false); + assert.deepEqual(attempts[0].client.sizingCalls, [ + { mode: "auto", fontSize: 13 }, + { mode: "fixed", columns: 80, rows: 24, fontSize: 13 }, + { mode: "auto", fontSize: 13 }, + ]); + attempts[0].role(false); + assert.equal(terminal.getToolbarState(id).fitEnabled, true); + terminal.setReadOnly(id, true); + terminal.fitToContainer(id); + assert.equal(terminal.getToolbarState(id).fitEnabled, false); + assert.equal(attempts[0].client.primaryRequests, 1); +}); + +test("per-view read-only uses the native policy and blocks host sizing and control", async () => { const { id } = mount({ options: { readOnly: true } }); - assert.equal(attempts[0].options.readOnly, false, "Mount-time native mode cannot support live unblocking"); + assert.equal(attempts[0].options.readOnly, true); attempts[0].resolve(); await settle(); attempts[0].role(true); @@ -716,7 +852,8 @@ test("per-view read-only preserves inspection and blocks host sizing and control assert.equal(state.fontControlsEnabled, false); assert.equal(state.sizeSelectEnabled, false); assert.equal(state.canTakeControl, false); - assert.deepEqual(attempts[0].options.onInput({ type: "wheel", deltaY: 10 }), { action: "scrollLines", args: 3 }); + assert.equal(attempts[0].client.readOnly, true); + assert.equal(attempts[0].options.onInput({ type: "wheel", deltaY: 10 }), "continue"); assert.equal(attempts[0].options.onInput({ type: "pointer", button: "left", shift: true }, { mouseCaptured: true }), "continue", "Native Shift-drag selection remains available"); }); @@ -733,18 +870,37 @@ test("live read-only changes update UX without remounting or mutating package op assert.equal(attempts[0].client.primaryRequests, 0); const context = { selection: { status: "valid", active: true }, mouseCaptured: true }; const onInput = attempts[0].options.onInput; - assert.equal(onInput({ type: "key", key: "a" }, context), "consume"); - assert.equal(onInput({ type: "text", text: "composed text" }, context), "consume"); - assert.equal(onInput({ type: "paste", text: "pasted text" }, context), "consume"); - assert.equal(onInput({ type: "pointer", button: "left" }, context), "consume"); - assert.deepEqual(onInput({ type: "key", key: "c", ctrl: true }, context), { action: "copySelection" }); - assert.deepEqual(onInput({ type: "pointer", button: "right" }, context), { action: "copySelection" }); + assert.equal(attempts[0].client.readOnly, true); + for (const input of [ + { type: "key", key: "a" }, + { type: "text", text: "composed text" }, + { type: "paste", text: "pasted text" }, + { type: "pointer", button: "left" }, + { type: "key", key: "c", ctrl: true }, + { type: "pointer", button: "right" }, + ]) { + assert.equal(onInput(input, context), "continue", "Native policy must own all application and inspection routing"); + } terminal.setReadOnly(id, false); assert.equal(onInput({ type: "key", key: "a" }, context), "continue"); assert.equal(onInput({ type: "paste", text: "allowed" }, context), "continue"); + assert.equal(attempts[0].client.readOnly, false); + assert.deepEqual(attempts[0].client.readOnlyCalls, [false, true, false]); assert.equal(attempts.length, 1); }); +for (const initialReadOnly of [false, true]) { + test(`read-only changes during mounting reconcile from ${initialReadOnly} before input is available`, async () => { + const { id } = mount({ options: { readOnly: initialReadOnly } }); + terminal.setReadOnly(id, !initialReadOnly); + attempts[0].resolve(); + await settle(); + assert.equal(attempts[0].options.readOnly, initialReadOnly); + assert.equal(attempts[0].client.readOnly, !initialReadOnly); + assert.equal(attempts.length, 1); + }); +} + test("read-only cancels a pending resize request without changing producer ownership", async () => { const { id } = mount(); attempts[0].resolve(); @@ -798,13 +954,13 @@ test("element snapshots expose public screen and selection state for the matchin assert.equal(terminal.getTerminalSnapshot(element), null); }); -test("ended registration prevents retry after an unsuccessful first frame without dismissing the view", async () => { - const { id, element } = mount({ isEnded: () => true }); +test("authoritative close before the first frame retains the view without retry or a Blazor completion check", async () => { + const { id, element } = mount(); + attempts[0].close(4000); attempts[0].reject(new Error("Native first-frame failure")); await settle(); assert.equal(terminal.getTerminalSnapshot(element).ended, true); assert.equal(terminal.getToolbarState(id).error, null); - assert.equal(attempts[0].options.signal.aborted, true); assert.equal(timers.size, 0); terminal.refreshLayout(id); terminal.reconnectTerminal(id, attempts[0].options.url); @@ -814,56 +970,67 @@ test("ended registration prevents retry after an unsuccessful first frame withou assert.equal(terminal.getTerminalSnapshot(element), null); }); -test("completion check keeps an existing last presentation and does not forward input", async () => { - const { id, element } = mount({ isEnded: () => true }); +test("authoritative close keeps the existing presentation read-only without remounting", async () => { + const { id, element } = mount(); + attempts[0].client.screenText = "Last available presentation"; attempts[0].resolve(); await settle(); - attempts[0].client.connected = false; - attempts[0].options.onStatus("Connection failed", "error"); + attempts[0].close(4000); + attempts[0].options.onStatus("Late transport error", "error"); await settle(); assert.equal(terminal.getTerminalSnapshot(element).ended, true); assert.equal(attempts[0].client.disposed, false); assert.equal(timers.size, 0); terminal.setReadOnly(id, false); assert.equal(terminal.getTerminalSnapshot(element).readOnly, true); - assert.equal(attempts[0].options.onInput({ type: "key", key: "a" }, { selection: {} }), "consume"); + assert.equal(attempts[0].client.readOnly, true); + assert.equal(terminal.getTerminalSnapshot(element).screenText, "Last available presentation"); + assert.equal(terminal.getToolbarState(id).error, null); terminal.requestPrimaryFromHost(id); assert.equal(attempts[0].client.primaryRequests, 0); }); -test("completion checks are bounded without guessing when the Blazor circuit is unavailable", async () => { - const result = Promise.withResolvers(); - const { id, element } = mount({ isEnded: () => result.promise }); +test("completion before the mount continuation cannot revive the connected state", async () => { + const { id, element } = mount({ options: { autoFit: true } }); attempts[0].resolve(); + attempts[0].close(4000); await settle(); - attempts[0].client.connected = false; - attempts[0].options.onStatus("Connection failed", "error"); - await settle(); - const [timer, { callback, delay }] = timers.entries().next().value; - assert.equal(delay, 5000); - timers.delete(timer); - callback(); - await settle(); - assert.equal(terminal.getToolbarState(id).error, "disconnected"); - assert.equal(attempts[0].client.disposed, false); + assert.equal(terminal.getTerminalSnapshot(element).ended, true); + assert.equal(terminal.getToolbarState(id).connected, false); + assert.equal(terminal.getToolbarState(id).error, null); + assert.equal(attempts[0].client.readOnly, true); + assert.equal(attempts[0].client.primaryRequests, 0); assert.equal(timers.size, 0); - result.resolve(true); - await settle(); - assert.equal(terminal.getTerminalSnapshot(element).ended, false); }); -test("rebind cancels pending completion checks and ignores the old registration's answer", async () => { - const result = Promise.withResolvers(); - const checked = []; - const { id, element, viewId } = mount({ isEnded: value => { checked.push(value); return result.promise; } }); +for (const code of [1000, 1001, 1006]) { + for (const mounted of [false, true]) { + test(`transport close ${code} ${mounted ? "after" : "before"} mounting retries regardless of close reason or cleanliness`, async () => { + const { id, element } = mount(); + if (mounted) { + attempts[0].resolve(); + await settle(); + } + attempts[0].close(code, "Terminal ended", code !== 1006); + attempts[0].reject(); + await settle(); + assert.equal(terminal.getTerminalSnapshot(element).ended, false); + assert.equal(terminal.getToolbarState(id).connected, false); + assert.equal(timers.size, 1); + assert.equal(retry(), 500); + attempts[1].resolve(); + await settle(); + assert.equal(terminal.getToolbarState(id).connected, true); + }); + } +} + +test("rebind ignores authoritative close from the old connection", async () => { + const { id, element } = mount(); attempts[0].resolve(); await settle(); - attempts[0].client.connected = false; - attempts[0].options.onStatus("Connection failed", "error"); - await settle(); - assert.deepEqual(checked, [viewId]); terminal.reconnectTerminal(id, "wss://dashboard/api/terminal?resource=next&viewId=next"); - result.resolve(true); + attempts[0].close(4000); attempts[1].resolve(); await settle(); assert.equal(terminal.getTerminalSnapshot(element).ended, false); @@ -871,17 +1038,15 @@ test("rebind cancels pending completion checks and ignores the old registration' assert.equal(timers.size, 0); }); -test("disposing a view cancels its outstanding completion check and mount", async () => { - const result = Promise.withResolvers(); - const { id } = mount({ isEnded: () => result.promise }); - attempts[0].reject(); - await settle(); +test("disposing a view ignores later native close callbacks", async () => { + const { id } = mount(); terminal.disposeTerminal(id); + attempts[0].close(4000); + attempts[0].close(1006); await settle(); assert.equal(timers.size, 0); assert.equal(attempts[0].options.signal.aborted, true); - result.resolve(true); - await settle(); + assert.equal(terminal.getToolbarState(id), null); assert.equal(attempts.length, 1); }); @@ -890,7 +1055,7 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.167.0-alpha.1547.1.798b26c"); + assert.equal(version, "0.167.0-alpha.1549.1.496ccf5"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index aee9414a59c..c8898c5d3e6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -39,6 +39,7 @@ public async Task ResizeDock_UpdatesAccessibleBoundsWithoutRemountingTerminals( await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents().Count)); var terminals = cut.FindComponents().Select(view => view.Instance).ToArray(); + Assert.Equal([true, false], terminals.Select(terminal => terminal.AutoFit)); await cut.InvokeAsync(() => cut.Instance.SetHeightAsync(requestedHeight, viewportHeight)); var dock = cut.Find(".terminal-dock"); @@ -58,6 +59,10 @@ public async Task ResizeDock_UpdatesAccessibleBoundsWithoutRemountingTerminals( Assert.Equal(terminals, cut.FindComponents().Select(view => view.Instance).ToArray()); Assert.Equal("first", cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); Assert.Empty(client.ClosedTerminals); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + Assert.Equal([false, false], terminals.Select(terminal => terminal.AutoFit)); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + Assert.Equal([true, false], terminals.Select(terminal => terminal.AutoFit)); } [Fact] @@ -491,6 +496,48 @@ public async Task CloseTab_ResponseAfterComponentDisposal_DoesNotNotify(StatusCo Assert.Empty(toasts.FindComponents()); } + [Fact] + public async Task DetachActiveTerminal_CarriesItsFontAndReturnResumesAutoFit() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents().Count)); + var views = cut.FindComponents(); + for (var i = 0; i < views.Count; i++) + { + var view = views[i].Instance; + var fontSize = i == 0 ? 23 : 19; + await cut.InvokeAsync(() => view.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Connected = true, FontPx = fontSize + })); + } + await cut.FindAll(".terminal-dock-tab-select")[1].ClickAsync(new()); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second", "third")); + cut.WaitForAssertion(() => Assert.Equal(3, cut.FindComponents().Count)); + + await cut.Find(".terminal-dock-detach").ClickAsync(new()); + var open = Assert.Single(JSInterop.Invocations, i => i.Identifier == "openTerminalWindow"); + Assert.Equal("second", open.Arguments[0]); + Assert.Equal("http://localhost/terminal-window/apphost/second?fontSize=19", open.Arguments[1]); + Assert.Equal(2, cut.FindComponents().Count); + Assert.Single(cut.FindAll(".terminal-dock-detached")); + + await cut.FindAll(".terminal-dock-detached-actions .aspire-button")[1].ClickAsync(new()); + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll(".terminal-dock-detached")); + var returned = cut.FindComponents().Select(c => c.Instance).ToArray(); + Assert.Equal(3, returned.Length); + Assert.Equal([false, true, false], returned.Select(view => view.AutoFit)); + Assert.Equal("dock:second", returned[1].SizeMemoryKey); + }); + } + [Fact] public async Task RecoverySnapshot_ClosesWindowForMissingTerminal() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index f612b30e2b1..e44963850ca 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -91,6 +91,43 @@ public async Task TerminalResource_Live_Selected_RendersBothViews_DefaultsToTerm await Task.CompletedTask; } + [Fact] + public async Task TerminalResource_OpenWindow_CarriesCurrentFontAndKeepsInlineView() + { + var consoleLogsChannel = Channel.CreateUnbounded>(); + var resourceChannel = Channel.CreateUnbounded>(); + var resource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Running); + var client = new TestDashboardClient( + isEnabled: true, + consoleLogsChannelProvider: _ => consoleLogsChannel, + resourceChannelProvider: () => resourceChannel, + initialResources: [resource]); + SetupConsoleLogsServices(client); + SetupTerminalViewJsInterop(); + TerminalSetupHelpers.SetupTerminalDock(this); + Services.GetRequiredService().NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: resource.Name)); + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); + var cut = RenderComponent(builder => builder + .Add(p => p.ResourceName, resource.Name) + .Add(p => p.ViewportInformation, viewport)); + cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); + var terminal = cut.FindComponent().Instance; + await cut.InvokeAsync(() => terminal.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Connected = true, FontPx = 17 + })); + + var open = Assert.Single(cut.Instance.LogsMenuItemsForTest, + item => item.Text == Resources.ConsoleLogs.TerminalToolbarOpenInWindow); + await cut.InvokeAsync(open.OnClick!); + + var invocation = Assert.Single(JSInterop.Invocations, i => i.Identifier == "openTerminalWindow"); + Assert.Equal("resource:terminal-resource:0", invocation.Arguments[0]); + Assert.Equal("http://localhost/terminal-window/resource/terminal-resource/0?fontSize=17", invocation.Arguments[1]); + Assert.Same(terminal, cut.FindComponent().Instance); + } + [Fact] public async Task TerminalResource_ViewPicker_MarksActiveViewAsChecked() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs index 0cbedc2458f..574926db8bc 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs @@ -11,12 +11,38 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Aspire.Dashboard.Components.Tests.Pages; public class TerminalWindowTests : DashboardTestContext { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OpeningWindow_AutoFitsUsingFontFromQuery(bool appHost) + { + var updates = Channel.CreateUnbounded(); + TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient(terminalChannelProvider: () => updates)); + var path = appHost ? "/terminal-window/apphost/terminal" : "/terminal-window/resource/shell/2"; + Services.GetRequiredService().NavigateTo($"{path}?fontSize=19"); + var cut = RenderComponent(builder => builder + .Add(p => p.TerminalId, appHost ? "terminal" : null) + .Add(p => p.ResourceName, appHost ? null : "shell") + .Add(p => p.ReplicaIndex, appHost ? 0 : 2)); + + var terminal = cut.FindComponent().Instance; + Assert.True(terminal.AutoFit); + Assert.True(terminal.Chromeless); + Assert.True(terminal.ShowDimensionsPicker); + Assert.Equal(19, terminal.InitialFontSize); + var options = Assert.IsType( + Assert.Single(JSInterop.Invocations, i => i.Identifier == "initTerminal").Arguments[3]); + Assert.True(options.AutoFit); + Assert.Equal(19, options.InitialFontSize); + } + [Fact] public async Task SameRoute_PreservesTitleEndedStateAndSubscription() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 9a95cf4afa0..a424717de1e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -39,8 +39,10 @@ public static BunitJSModuleInterop SetupTerminalViewModule(TestContext context, module.Setup("reconnectTerminal", _ => true).SetResult(2); module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); module.SetupVoid("refreshLayout", _ => true).SetVoidResult(); + module.SetupVoid("setAutoFit", _ => true).SetVoidResult(); + module.SetupVoid("fitToContainer", _ => true).SetVoidResult(); module.Setup("getSizePresets").SetResult( - [new("auto", "Auto", 0, 0), new("80x24", "80×24", 80, 24)]); + [new("80x24", "80×24", 80, 24)]); return module; } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs index 33039848990..06dbd7339c3 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs @@ -23,72 +23,62 @@ public sealed class TerminalDockTests(TerminalDockTests.TerminalDockDashboardSer [InlineData(false)] [InlineData(true)] [OuterloopTest("Resource-intensive Playwright browser test")] - public async Task AppHostWorkloadEnded_RetainedSocketKeepsTabUntilExplicitClose(bool beforeHandshake) + public async Task AppHostWorkloadEnded_CompletionCloseKeepsTabUntilExplicitClose(bool beforeHandshake) { await RunTestAsync(async page => { var (updates, closes) = await fixture.StartSessionAsync(); await page.Clock.InstallAsync(); var parkedConnections = Channel.CreateUnbounded(); - var terminalSocket = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - page.WebSocket += (_, socket) => - { - if (new Uri(socket.Url).AbsolutePath == "/api/apphost-terminal") - { - terminalSocket.TrySetResult(socket.Url); - } - }; var connectionCount = 0; - if (beforeHandshake) + await page.RouteWebSocketAsync("**/api/apphost-terminal?*", route => { - await page.RouteWebSocketAsync("**/api/apphost-terminal?*", route => + Interlocked.Increment(ref connectionCount); + if (beforeHandshake) { - Interlocked.Increment(ref connectionCount); route.OnMessage(_ => { }); - parkedConnections.Writer.TryWrite(route); - }); - } + } + else + { + route.ConnectToServer(); + } + parkedConnections.Writer.TryWrite(route); + }); await page.GotoAsync("/").DefaultTimeout(); await updates.Writer.WriteAsync(Change(TerminalChangeType.Added, "ended")); await updates.Writer.WriteAsync(Change(TerminalChangeType.Activated, "ended")); await Assertions.Expect(Tab(page, "ended")).ToBeVisibleAsync(); TestTerminalConnection? producer = null; - string socketUrl; - if (beforeHandshake) - { - socketUrl = (await parkedConnections.Reader.ReadAsync().AsTask().DefaultTimeout()).Url; - } - else + var parked = await parkedConnections.Reader.ReadAsync().AsTask().DefaultTimeout(); + if (!beforeHandshake) { producer = await fixture.TerminalResolver.AcceptConnectionAsync(CancellationToken.None).DefaultTimeout(); await producer.WaitForPeerHandshakesAsync(CancellationToken.None).DefaultTimeout(); await Assertions.Expect(page.Locator(".terminal-dock-pane.active") .GetByRole(AriaRole.Button, new() { Name = "Decrease font size", Exact = true })).ToBeEnabledAsync(); - socketUrl = await terminalSocket.Task.DefaultTimeout(); } - var endpoint = new Uri(socketUrl); + var endpoint = new Uri(parked.Url); var viewId = QueryHelpers.ParseQuery(endpoint.Query)["viewId"].ToString(); Assert.True(fixture.DashboardApp.Services.GetRequiredService() .TryGet(viewId, endpoint.PathAndQuery, out var session)); session.MarkEnded(); - // Transport tests cover how gRPC sets the retained completion state. Before - // the first frame the native mount times out and removes its input, but the - // containing view must remain and consult that state instead of reconnecting. + // Transport tests cover the authoritative gRPC signal. Here both a pre-frame + // socket and a mounted presentation must observe Aspire's completion close. var terminal = await page.Locator(".terminal-dock .terminal-container").ElementHandleAsync(); Assert.NotNull(terminal); - await page.Clock.FastForwardAsync(35_000); - if (beforeHandshake) - { - await page.WaitForFunctionAsync(""" - async () => { - const module = await import('/Components/Controls/TerminalView.razor.js'); - return module.getTerminalSnapshot(document.querySelector('.terminal-dock .terminal-container'))?.ended; - } - """).DefaultTimeout(); - } + await parked.CloseAsync(new() { Code = 4000, Reason = "Terminal ended" }); + await page.EvaluateAsync(""" + async () => { window.terminalModule = await import('/Components/Controls/TerminalView.razor.js'); } + """); + await page.WaitForFunctionAsync(""" + () => { + const state = window.terminalModule.getTerminalSnapshot(document.querySelector('.terminal-dock .terminal-container')); + return state?.ended && !state.connected; + } + """).DefaultTimeout(); await page.Clock.FastForwardAsync(5_000); await Assertions.Expect(Tab(page, "ended")).ToHaveAttributeAsync("aria-selected", "true"); Assert.True(await terminal.EvaluateAsync("element => element.isConnected")); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs index 64f225211f6..fd53f0b1345 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs @@ -55,9 +55,27 @@ await RunTestAsync(async page => await connection.WaitForProducerTextAsync("Output while read-only", CancellationToken.None).DefaultTimeout(); await ExpectObserverTextAsync(page, "Output while read-only"); await Assertions.Expect(terminal.Locator("canvas")).ToBeVisibleAsync(); - await input.FocusAsync(); + await page.EvaluateAsync("() => window.moduleTerminal.focus()"); await page.Keyboard.TypeAsync("blocked-keyboard"); await PasteAsync(input, "blocked-paste"); + Assert.Equal(["Terminal view does not accept input", "Terminal view does not accept input"], + await page.EvaluateAsync(""" + async () => { + const errors = []; + for (const action of [ + () => window.moduleTerminal.paste('blocked-direct-paste'), + () => window.moduleTerminal.runAction('pasteClipboard') + ]) { + try { + await action(); + errors.push('Input unexpectedly accepted'); + } catch (error) { + errors.push(error.message); + } + } + return errors; + } + """)); await page.EvaluateAsync(""" async id => { const module = await import('/Components/Controls/TerminalView.razor.js'); @@ -89,7 +107,7 @@ await page.EvaluateAsync(""" await SetReadOnlyAsync(page, terminalId, session, true); await ExpectReadOnlyAsync(page, true); - await input.FocusAsync(); + await page.EvaluateAsync("() => window.moduleTerminal.focus()"); await page.Keyboard.TypeAsync("blocked-again"); await PasteAsync(input, "blocked-paste-again"); await page.EvaluateAsync("() => window.terminalObserver.paste('still-active')"); @@ -275,14 +293,18 @@ private static Task SetReadOnlyAsync(IPage page, int terminalId, TerminalViewSes private static async Task ExpectReadOnlyAsync(IPage page, bool readOnly) { - // Until Hex1b exposes a live read-only setter, the adapter's input policy and - // server gate change together without replacing the native terminal textarea. await page.WaitForFunctionAsync(""" - async readOnly => { - const module = await import('/Components/Controls/TerminalView.razor.js'); - return module.getTerminalSnapshot(document.querySelector('[data-testid="module-terminal"]'))?.readOnly === readOnly; - } + readOnly => window.moduleTerminal?.readOnly === readOnly """, readOnly).DefaultTimeout(); + var input = page.GetByTestId("module-terminal").Locator("textarea"); + if (readOnly) + { + await Assertions.Expect(input).ToBeDisabledAsync(); + } + else + { + await Assertions.Expect(input).ToBeEnabledAsync(); + } } private static Task PasteAsync(ILocator input, string text) => @@ -297,10 +319,7 @@ private static Task PasteAsync(ILocator input, string text) => private static async Task WaitForConnectedAsync(IPage page, int terminalId) { await page.WaitForFunctionAsync(""" - async id => { - const module = await import('/Components/Controls/TerminalView.razor.js'); - return module.getToolbarState(id)?.connected === true; - } + id => window.terminalModule.getToolbarState(id)?.connected === true """, terminalId).DefaultTimeout(); } @@ -308,6 +327,7 @@ private static Task MountModuleAsync(IPage page, TerminalViewSession sessio page.EvaluateAsync(""" async ({ endpoint, viewId, readOnly, chromeless }) => { const module = await import('/Components/Controls/TerminalView.razor.js'); + window.terminalModule = module; const container = document.createElement('div'); container.dataset.testid = 'module-terminal'; container.style.cssText = 'position:fixed;left:0;top:0;width:700px;height:500px;z-index:10000'; @@ -322,9 +342,22 @@ private static Task MountModuleAsync(IPage page, TerminalViewSession sessio const url = new URL(endpoint, location.href); url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; url.searchParams.set('viewId', viewId); - return module.initTerminal(container, url.href, null, { - label: 'Test terminal input', readOnly, chromeless - }, template, footer); + // Capture the public client returned by the real mount, without replacing + // its input implementation, to exercise direct paste/action entry points. + const { WebTerminal } = await import('/js/hex1b-web-terminal/dist/index.js'); + const mount = WebTerminal.mount; + WebTerminal.mount = async (...args) => { + const client = await mount.call(WebTerminal, ...args); + window.moduleTerminal = client; + return client; + }; + try { + return module.initTerminal(container, url.href, null, { + label: 'Test terminal input', readOnly, chromeless + }, template, footer); + } finally { + WebTerminal.mount = mount; + } } """, new { endpoint = Endpoint, viewId = session.Id, readOnly = session.ReadOnly, chromeless }); diff --git a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs index caabd26a59f..367d0baebe3 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs @@ -23,6 +23,7 @@ internal sealed class TerminalTestHost : ITerminalConnectionResolver, IAsyncDisp private readonly DashboardWebApplication _app; private readonly TerminalTestProducer _producer = new(100, 30, 100); private readonly bool _useGrpc; + private readonly ConcurrentBag _attachmentDisposals = []; private int _disposedAttachments; private int _terminalEnded; private int _includeHmpExit; @@ -76,6 +77,9 @@ public Task WaitForPeerHandshakesAsync(CancellationToken cancellationToken) => public Task WaitForAttachmentsReleasedAsync(CancellationToken cancellationToken) => _producer.WaitForAttachmentsReleasedAsync(cancellationToken); + public Task WaitForDisposedAttachmentsAsync(CancellationToken cancellationToken) => + Task.WhenAll(_attachmentDisposals).WaitAsync(cancellationToken); + public Task ConnectBrowserAsync(CancellationToken cancellationToken) => ConnectBrowserCoreAsync(viewId: null, cancellationToken); @@ -114,7 +118,13 @@ await socket.ConnectAsync(new UriBuilder(frontend) private async Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) { Assert.Equal("test", terminalId); - var connection = (await ConnectAsync(terminalId, 0, cancellationToken))!; + // A completed terminal reports Ended without attaching to the disposed + // producer or returning any HMP handshake bytes. + var connection = Volatile.Read(ref _terminalEnded) != 0 + ? Stream.Null + : (await ConnectAsync(terminalId, 0, cancellationToken))!; + var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _attachmentDisposals.Add(disposed.Task); var call = new AsyncDuplexStreamingCall( new TerminalRequestWriter(connection), new TerminalResponseReader(connection, () => Volatile.Read(ref _terminalEnded) != 0, @@ -126,6 +136,7 @@ private async Task AttachTerminalAsync(string terminalId, CancellationTo { Interlocked.Increment(ref _disposedAttachments); connection.Dispose(); + disposed.TrySetResult(); }); var stream = new GrpcTerminalClientStream(call, terminalId); await stream.SendSelectorAsync(cancellationToken); diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs index a75064b1ede..92e9116ea2c 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -333,10 +333,13 @@ public async Task BrowserView_DetachingReleasesViewerWithoutStoppingProducer(boo await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await host.StartAsync(timeout.Token); - using var first = await host.ConnectBrowserAsync(timeout.Token); + using var session = host.CreateViewSession(readOnly: false); + using var first = await host.ConnectBrowserAsync(session, timeout.Token); await ReadUntilAsync(first, _ => true, timeout.Token); await first.CloseAsync(WebSocketCloseStatus.NormalClosure, "Detach", timeout.Token); await host.WaitForAttachmentsReleasedAsync(timeout.Token); + Assert.Equal(WebSocketCloseStatus.NormalClosure, first.CloseStatus); + Assert.False(session.Ended.IsCompleted); host.Workload.Write("producer-survived-detach"); await host.WaitForProducerTextAsync("producer-survived-detach", timeout.Token); @@ -359,24 +362,26 @@ public async Task BrowserView_DetachingReleasesViewerWithoutStoppingProducer(boo } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task BrowserView_ReadOnlyPolicyChangesWithoutReconnect(bool useGrpc) + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task BrowserView_ReadOnlyPolicyChangesWithoutReconnect(bool useGrpc, bool initiallyReadOnly) { await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await host.StartAsync(timeout.Token); - using var session = host.CreateViewSession(readOnly: false); + using var session = host.CreateViewSession(readOnly: initiallyReadOnly); using var browser = await host.ConnectBrowserAsync(session, timeout.Token); await ReadUntilAsync(browser, _ => true, timeout.Token); session.ReadOnly = true; + host.Workload.Write("\u001b[?1000h\u001b[?1006h"); await SendAsync(browser, """{"type":"input","text":"blocked-input"}""", timeout.Token); await SendAsync(browser, """{"type":"paste","text":"blocked-paste"}""", timeout.Token); - // Read-only commands are discarded before native input validation, - // including keyboard/pointer messages that arrived from stale UI state. - await SendAsync(browser, """{"type":"key"}""", timeout.Token); - await SendAsync(browser, """{"type":"mouse"}""", timeout.Token); + // These valid commands must pass native validation but not reach the producer. + await SendAsync(browser, """{"type":"key","key":"Enter","ctrl":false,"alt":false,"shift":false}""", timeout.Token); + await SendAsync(browser, """{"type":"mouse","action":"down","button":"left","x":1,"y":1}""", timeout.Token); await SendAsync(browser, """{"type":"resize","columns":80,"rows":24}""", timeout.Token); await SendAsync(browser, """{"type":"requestPrimary","columns":80,"rows":24}""", timeout.Token); await SendAsync(browser, """{"type":"resync"}""", timeout.Token); @@ -397,7 +402,9 @@ public async Task BrowserView_ReadOnlyPolicyChangesWithoutReconnect(bool useGrpc var input = new StringBuilder(); while (!input.ToString().EndsWith("allowed", StringComparison.Ordinal)) { - if (await host.Workload.InputEvents.ReadAsync(timeout.Token) is Hex1bKeyEvent key) + var inputEvent = await host.Workload.InputEvents.ReadAsync(timeout.Token); + Assert.IsNotType(inputEvent); + if (inputEvent is Hex1bKeyEvent key) { input.Append(key.Text); } @@ -411,28 +418,128 @@ public async Task BrowserView_ReadOnlyPolicyChangesWithoutReconnect(bool useGrpc [Theory] [InlineData(false)] [InlineData(true)] - public async Task BrowserView_AppHostEndRetainsMirrorUntilBrowserCloses(bool includeHmpExit) + public async Task BrowserView_ReadOnlyPolicyIsLimitedToOneView(bool useGrpc) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var session = host.CreateViewSession(readOnly: true); + using var readOnly = await host.ConnectBrowserAsync(session, timeout.Token); + await ReadUntilAsync(readOnly, _ => true, timeout.Token); + using var interactive = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(interactive, _ => true, timeout.Token); + + await SendAsync(interactive, """{"type":"requestPrimary","columns":80,"rows":24}""", timeout.Token); + var primary = await ReadUntilAsync(interactive, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token); + var peerId = primary.GetProperty("peer").GetProperty("id").GetString(); + await ReadUntilAsync(readOnly, frame => frame.GetProperty("peer").GetProperty("primaryId").GetString() == peerId, timeout.Token); + await SendAsync(readOnly, """{"type":"requestPrimary","columns":120,"rows":40}""", timeout.Token); + await SendAsync(readOnly, """{"type":"paste","text":"blocked"}""", timeout.Token); + await SendAsync(readOnly, """{"type":"resync"}""", timeout.Token); + var unchanged = await ReadUntilAsync(readOnly, frame => frame.GetProperty("full").GetBoolean(), timeout.Token); + Assert.Equal(80, unchanged.GetProperty("columns").GetInt32()); + Assert.Equal(24, unchanged.GetProperty("rows").GetInt32()); + Assert.False(unchanged.GetProperty("peer").GetProperty("isPrimary").GetBoolean()); + Assert.Equal(peerId, host.Presentation.PrimaryPeerId); + + await SendAsync(interactive, """{"type":"input","text":"x"}""", timeout.Token); + Hex1bEvent input; + do + { + input = await host.Workload.InputEvents.ReadAsync(timeout.Token); + } + while (input is not Hex1bKeyEvent); + Assert.Equal("x", ((Hex1bKeyEvent)input).Text); + await readOnly.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + await interactive.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + await host.WaitForAttachmentsReleasedAsync(timeout.Token); + } + + [Theory] + [InlineData(false, """{"type":"unknown"}""")] + [InlineData(true, """{"type":"unknown"}""")] + [InlineData(false, """{"type":"key"}""")] + [InlineData(true, """{"type":"key"}""")] + [InlineData(false, """{"type":"mouse"}""")] + [InlineData(true, """{"type":"mouse"}""")] + [InlineData(false, """{"type":"input","text":42}""")] + [InlineData(true, """{"type":"input","text":42}""")] + [InlineData(false, "{")] + [InlineData(true, "{")] + public async Task BrowserView_NativeValidationRejectsInvalidCommandsEvenWhenReadOnly(bool readOnly, string command) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var session = host.CreateViewSession(readOnly); + using var browser = await host.ConnectBrowserAsync(session, timeout.Token); + await ReadUntilAsync(browser, _ => true, timeout.Token); + + await SendAsync(browser, command, timeout.Token); + var close = await ReadCloseAsync(browser, timeout.Token); + Assert.Equal(WebSocketCloseStatus.PolicyViolation, close.CloseStatus); + Assert.False(session.Ended.IsCompleted); + await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token); + await host.WaitForAttachmentsReleasedAsync(timeout.Token); + } + + [Fact] + public async Task BrowserView_AppHostEndBeforeHandshakeClosesWithoutHwtFrame() { await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc: true); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await host.StartAsync(timeout.Token); + await host.EndTerminalAsync(includeHmpExit: false); using var session = host.CreateViewSession(readOnly: false); using var browser = await host.ConnectBrowserAsync(session, timeout.Token); - await ReadUntilAsync(browser, _ => true, timeout.Token); + + var result = await browser.ReceiveAsync(new byte[64], timeout.Token); + Assert.Equal(WebSocketMessageType.Close, result.MessageType); + Assert.Equal((WebSocketCloseStatus)4000, result.CloseStatus); + await session.Ended.WaitAsync(timeout.Token); + Assert.True(session.ReadOnly); + await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token); + await host.WaitForAttachmentsReleasedAsync(timeout.Token); + Assert.Equal(0, host.ConnectionCount); + await host.WaitForDisposedAttachmentsAsync(timeout.Token); + Assert.Equal(1, host.DisposedAttachments); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task BrowserView_AppHostEndClosesWithCompletionStatusAndReleasesMirror(bool includeHmpExit, bool acknowledgeInitialFrame) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc: true); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var session = host.CreateViewSession(readOnly: false); + using var browser = await host.ConnectBrowserAsync(session, timeout.Token); + if (acknowledgeInitialFrame) + { + await ReadUntilAsync(browser, _ => true, timeout.Token); + } + else + { + var buffer = new byte[64 * 1024]; + WebSocketReceiveResult frame; + do + { + frame = await browser.ReceiveAsync(buffer, timeout.Token); + Assert.Equal(WebSocketMessageType.Binary, frame.MessageType); + } + while (!frame.EndOfMessage); + } await host.EndTerminalAsync(includeHmpExit); await host.WaitForEndedObservedAsync(timeout.Token); await session.Ended.WaitAsync(timeout.Token); - await SendAsync(browser, """{"type":"input","text":"ignored"}""", timeout.Token); - await SendAsync(browser, """{"type":"requestPrimary","columns":80,"rows":24}""", timeout.Token); - await SendAsync(browser, """{"type":"resync"}""", timeout.Token); - - var retained = await ReadUntilAsync(browser, frame => frame.GetProperty("full").GetBoolean(), timeout.Token); - Assert.Equal(100, retained.GetProperty("columns").GetInt32()); - Assert.Equal(30, retained.GetProperty("rows").GetInt32()); - Assert.Equal(WebSocketState.Open, browser.State); - Assert.Equal(0, host.DisposedAttachments); - await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "User dismissed the terminal", timeout.Token); + var close = await ReadCloseAsync(browser, timeout.Token); + Assert.Equal((WebSocketCloseStatus)4000, close.CloseStatus); + Assert.True(session.ReadOnly); + await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token); await host.WaitForAttachmentsReleasedAsync(timeout.Token); Assert.Equal(1, host.DisposedAttachments); } @@ -460,18 +567,26 @@ public async Task BrowserView_ProducerDisconnectClosesBrowserWhileWaitingForAckn await host.Presentation.DisposeAsync(); - try - { - var closed = await browser.ReceiveAsync(buffer, timeout.Token); - Assert.Equal(WebSocketMessageType.Close, closed.MessageType); - } - catch (WebSocketException ex) + var closed = await ReadCloseAsync(browser, timeout.Token); + Assert.Equal(WebSocketCloseStatus.EndpointUnavailable, closed.CloseStatus); + Assert.False(session.Ended.IsCompleted); + await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token); + await host.WaitForAttachmentsReleasedAsync(timeout.Token); + Assert.Equal(useGrpc ? 1 : 0, host.DisposedAttachments); + } + + private static async Task ReadCloseAsync(WebSocket socket, CancellationToken cancellationToken) + { + var buffer = new byte[64 * 1024]; + while (true) { - // Cancelling the server's pending ReceiveAsync can abort the socket. - // Either close path must end promptly, without waiting for the HWT ACK timeout. - Assert.Equal(WebSocketError.ConnectionClosedPrematurely, ex.WebSocketErrorCode); + var result = await socket.ReceiveAsync(buffer, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + return result; + } + Assert.Equal(WebSocketMessageType.Binary, result.MessageType); } - Assert.False(session.Ended.IsCompleted); } private static Task SendAsync(WebSocket socket, string message, CancellationToken cancellationToken) From f666df1c439a5a980a869888f46134d0e98eb7c0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 10 Sep 2026 23:32:58 +1000 Subject: [PATCH 057/106] Update Hex1b Windows PTY package Use the Hex1b build that embeds the updated ConPTY and OpenConsole payloads required for Kitty graphics protocol and Sixel passthrough on Windows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 703c5dd5189..6376975e690 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -116,7 +116,7 @@ - + From 3b0cfec096c9a923279218b32f7a242d65df8338 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 10 Sep 2026 23:33:18 +1000 Subject: [PATCH 058/106] Clarify Hex1b package compatibility Document that native-only Hex1b builds do not require an identical web terminal package version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6376975e690..3bebc2bc622 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -115,7 +115,8 @@ - + From 8f20a047e95a31fa95b7eababf5eed92f6114fc0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 11 Sep 2026 15:09:53 +1000 Subject: [PATCH 059/106] Adopt Hex1b 0.166.0 and update terminal CI expectations Centralize the temporary NuGet audit suppression, align Dashboard lifecycle coverage with authoritative terminal completion, and adopt the stable NuGet/npm package pair while retaining the temporary nuget.org mapping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- Directory.Build.props | 5 +++++ Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 2 +- src/Aspire.Cli/Aspire.Cli.csproj | 3 --- src/Aspire.Dashboard/package-lock.json | 8 ++++---- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 5 ++--- .../wwwroot/js/hex1b-web-terminal/package.json | 2 +- .../JavaScript/TerminalView.test.mjs | 4 ++-- .../Model/DashboardClientTests.cs | 10 ++++++---- 10 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index c0af8b06bf8..a7e4b424864 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -41,6 +41,11 @@ $(NoWarn);xUnit1051;NU1510 + + + $(NoWarn);NU1902;NU1903 diff --git a/Directory.Packages.props b/Directory.Packages.props index 3bebc2bc622..935a897939e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,7 +117,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 6e7a788f7e8..c8e0b31e47a 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -149,7 +149,7 @@ it does not lock the terminal, its creator's automation, or other viewers. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.167.0-alpha.1549.1.496ccf5`. HWT1 is experimental state transfer +exactly `0.166.0`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index df6accc82dd..f335d1f59b2 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -17,9 +17,6 @@ emits code that touches them when we combine our serializer context with McpJsonUtilities.DefaultOptions in BackchannelJsonSerializerContext.cs. Suppress until MCP graduates these types. --> $(NoWarn);CS1591;MCPEXP001 - - $(NoWarn);NU1902;NU1903 true diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 1e543fa778e..523649b4dd6 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1549.1.496ccf5" + "@hex1b/web-terminal": "0.166.0" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.167.0-alpha.1549.1.496ccf5", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1549.1.496ccf5.tgz", - "integrity": "sha512-wVSv2UJFDpryRehug4csmQAbt8zG+GdE/vQYfLZHCGzRWicwuYwrY210KdNn1JPRAnR0+KdaeZsAPd0nMQYLjw==", + "version": "0.166.0", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.166.0.tgz", + "integrity": "sha512-kq93remKSiFcYfm8c5Vqs8zj1UiXN/KFNPCZN2rSoLABBvpotyHFSk/QEr4T2RMCv8RtPXZc9NnOMNoeb/JwiQ==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index 002919c4142..5bf804de3cc 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1549.1.496ccf5" + "@hex1b/web-terminal": "0.166.0" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index dde315c3e41..3c1d1b896a8 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -18,9 +18,8 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1549.1.496ccf5**, -paired with the Hex1b NuGet build from commit -`496ccf508470eed8744dbe46675e3d26928e8c91`. The client and server use the evolving +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.166.0**, +paired with the Hex1b NuGet package **0.166.0**. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index b7d621e4567..c25024ef588 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.167.0-alpha.1549.1.496ccf5", + "version": "0.166.0", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index bdc7408d660..a108bd1264c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -1055,13 +1055,13 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.167.0-alpha.1549.1.496ccf5"); + assert.equal(version, "0.166.0"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); // Central package rows have the form: - // + // // Match the exact Include value, not Hex1b.Tool or Hex1b.McpServer; // whitespace, attribute order and either XML quote style are allowed. const packages = await readFile(new URL("../../Directory.Packages.props", dashboard), "utf8"); diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 13698483d5c..993ff7595fa 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -34,7 +34,7 @@ namespace Aspire.Dashboard.Tests.Model; public sealed class DashboardClientTests(ITestOutputHelper testOutputHelper) : IDisposable { [Fact] - public async Task TerminalStream_EndedBeforeHandshakeRetainsEmptyViewerUntilBrowserCloses() + public async Task TerminalStream_EndedBeforeHandshakeClosesWithCompletionStatusAndDisposesCall() { var channel = Channel.CreateUnbounded(); channel.Writer.TryWrite(new TerminalServerFrame { Ended = true }); @@ -76,15 +76,17 @@ public async Task TerminalStream_EndedBeforeHandshakeRetainsEmptyViewerUntilBrow Assert.True(stream.TerminalEnded); await session.Ended.DefaultTimeout(); - Assert.False(disposed.Task.IsCompleted); + Assert.True(session.ReadOnly); var handshakeWrites = writes.ToArray(); Assert.NotEmpty(handshakeWrites); + // Input already in flight must not reach the AppHost after authoritative completion. await socket.SendAsync("""{"type":"input","text":"ignored"}"""u8.ToArray(), WebSocketMessageType.Text, true, CancellationToken.None).DefaultTimeout(); - await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "User dismissed the terminal", CancellationToken.None).DefaultTimeout(); var message = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None).DefaultTimeout(); Assert.Equal(WebSocketMessageType.Close, message.MessageType); - Assert.Equal(WebSocketCloseStatus.NormalClosure, message.CloseStatus); + Assert.Equal((WebSocketCloseStatus)4000, message.CloseStatus); + Assert.Equal("Terminal ended", message.CloseStatusDescription); + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", CancellationToken.None).DefaultTimeout(); await disposed.Task.DefaultTimeout(); Assert.Equal(handshakeWrites, writes); } From 1c4f18c6266398788d11ed105b6c04c96445be17 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 11 Sep 2026 17:36:09 +1000 Subject: [PATCH 060/106] Fix Windows terminal payload publishing and template restores Preserve Hex1b's architecture-specific Windows native layout for repository builds and external Hosting consumers, retain required PTY sidecars in managed bundles, and add regression coverage. Temporarily provide isolated template restores with scoped Hex1b feed access and matching audit suppression until internal mirroring completes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- Directory.Build.targets | 1 + docs/specs/bundle.md | 2 + src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + .../Aspire.Hosting.Hex1b.targets | 49 ++++ .../buildTransitive/Aspire.Hosting.targets | 2 + src/Aspire.Managed/Aspire.Managed.csproj | 4 +- src/Directory.Build.targets | 23 -- .../Hex1bNativePublishingTests.cs | 267 ++++++++++++++++++ .../Infrastructure.Tests.csproj | 1 + .../Infrastructure.Tests/NuGetConfigTests.cs | 30 ++ .../TemplatesTesting/BuildEnvironment.cs | 5 +- .../TemplatesTesting/data/nuget8.config | 26 ++ tools/CreateLayout/Program.cs | 23 +- 13 files changed, 405 insertions(+), 29 deletions(-) create mode 100644 src/Aspire.Hosting/buildTransitive/Aspire.Hosting.Hex1b.targets create mode 100644 tests/Infrastructure.Tests/Hex1bNativePublishingTests.cs diff --git a/Directory.Build.targets b/Directory.Build.targets index 71791f8c490..a5a039dd59a 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -21,6 +21,7 @@ + diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index ed0aaf4e7f8..22beb4dc696 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -169,6 +169,8 @@ aspire-{version}-{platform}/ **Key change from previous layout**: The separate `.NET Runtime` (~106 MB), `dashboard/` (~42 MB), `aspire-server/` (~19 MB), `tools/aspire-nuget/` (~5 MB), and `tools/dev-certs/` directories have been consolidated into a single `managed/aspire-managed` self-contained binary. Certificate management has been moved natively into the CLI itself, eliminating the need for a separate dev-certs tool. +Windows bundles also include `managed/hex1bpty.exe`, `managed/conpty.dll`, and `managed/arm64/OpenConsole.exe`; `win-x64` additionally includes `managed/x64/OpenConsole.exe`. These PTY sidecars stay outside the managed single-file executable because Hex1b locates its helper beside the application. ConPTY selects `OpenConsole.exe` relative to its DLL using the **OS architecture**, so the x64 bundle must retain the ARM64 helper for execution under emulation. `CreateLayout` preserves this layout and fails if a required sidecar is missing. + **Total Bundle Size:** - **Unzipped:** ~220 MB (down from ~323 MB — eliminated separate runtime) - **Zipped:** ~80 MB diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 90c8a676237..1c0dc10a253 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Aspire.Hosting/buildTransitive/Aspire.Hosting.Hex1b.targets b/src/Aspire.Hosting/buildTransitive/Aspire.Hosting.Hex1b.targets new file mode 100644 index 00000000000..8d0284bb5fd --- /dev/null +++ b/src/Aspire.Hosting/buildTransitive/Aspire.Hosting.Hex1b.targets @@ -0,0 +1,49 @@ + + + + + + $([System.Text.RegularExpressions.Regex]::Replace('%(NativeCopyLocalItems.PathInPackage)', '^runtimes/win-[^/]+/native/', '')) + $([System.Text.RegularExpressions.Regex]::Replace('%(NativeCopyLocalItems.PathInPackage)', '^runtimes/win-[^/]+/native/|[^/]+$', '')) + + + + + + + <_ResolvedCopyLocalPublishAssets Update="@(_ResolvedCopyLocalPublishAssets)" + Condition="'%(_ResolvedCopyLocalPublishAssets.NuGetPackageId)' == 'Hex1b' and $([System.Text.RegularExpressions.Regex]::IsMatch('%(_ResolvedCopyLocalPublishAssets.PathInPackage)', '^runtimes/win-[^/]+/native/'))"> + $([System.Text.RegularExpressions.Regex]::Replace('%(_ResolvedCopyLocalPublishAssets.PathInPackage)', '^runtimes/win-[^/]+/native/', '')) + $([System.Text.RegularExpressions.Regex]::Replace('%(_ResolvedCopyLocalPublishAssets.PathInPackage)', '^runtimes/win-[^/]+/native/|[^/]+$', '')) + + + + + + + + + true + + + + diff --git a/src/Aspire.Hosting/buildTransitive/Aspire.Hosting.targets b/src/Aspire.Hosting/buildTransitive/Aspire.Hosting.targets index 70fc3d578f8..c3f2269ecf0 100644 --- a/src/Aspire.Hosting/buildTransitive/Aspire.Hosting.targets +++ b/src/Aspire.Hosting/buildTransitive/Aspire.Hosting.targets @@ -1,5 +1,7 @@ + + false <_AspireIntegrationAnalyzerAssembly Condition="'$(_AspireIntegrationAnalyzerAssembly)' == ''">$(MSBuildThisFileDirectory)Aspire.Hosting.Integration.Analyzers.dll diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj index e5fd1386df2..21042285c6b 100644 --- a/src/Aspire.Managed/Aspire.Managed.csproj +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -19,7 +19,7 @@ true - + true true @@ -85,7 +85,7 @@ + Text="Aspire.Managed single-file publishing must embed native libraries other than the explicitly copied Windows PTY sidecars." /> diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ea71419abf9..8d36f7f4e54 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -14,29 +14,6 @@ - - - - - - - _workspace.Dispose(); + + [Theory] + [InlineData("win-x64", false)] + [InlineData("win-x64", true)] + [InlineData("win-arm64", false)] + [InlineData("win-arm64", true)] + [InlineData("", false)] + [InlineData("linux-x64", true)] + public async Task NativeAssetsKeepTheirLayoutAndContents(string rid, bool singleFile) + { + var project = CreatePublishProject(rid, singleFile, duplicateUnrelatedAsset: false); + var result = await RunDotNetAsync(["msbuild", project, "-nologo", "-t:CopyTestFiles", "-getItem:NativeCopyLocalItems,ResolvedFileToPublish"]); + Assert.True(result.ExitCode == 0, result.Output); + + using var document = JsonDocument.Parse(result.Output); + var items = document.RootElement.GetProperty("Items"); + var published = items.GetProperty("ResolvedFileToPublish").EnumerateArray().ToArray(); + var expected = GetNativePaths(rid).Append("other/OpenConsole.exe").Order(StringComparer.Ordinal).ToArray(); + Assert.Equal(expected, published.Select(item => item.GetProperty("RelativePath").GetString()).Order(StringComparer.Ordinal)); + + foreach (var item in published) + { + var pathInPackage = item.GetProperty("PathInPackage").GetString()!; + var relativePath = item.GetProperty("RelativePath").GetString()!; + var source = item.GetProperty("Identity").GetString()!; + var destination = Path.Combine(_workspace.Path, "publish", relativePath); + Assert.True(File.ReadAllBytes(source).SequenceEqual(File.ReadAllBytes(destination)), relativePath); + + var isWindowsPty = pathInPackage.StartsWith("runtimes/win-", StringComparison.Ordinal); + Assert.Equal(isWindowsPty, item.TryGetProperty("ExcludeFromSingleFile", out var excluded) && excluded.GetString() == "true"); + } + + var built = items.GetProperty("NativeCopyLocalItems").EnumerateArray() + .Select(item => item.GetProperty("DestinationSubPath").GetString()).Order(StringComparer.Ordinal); + Assert.Equal(rid.Length == 0 ? [] : GetNativePaths(rid).Order(StringComparer.Ordinal), built); + } + + [Fact] + public async Task UnrelatedPublishCollisionsStillFail() + { + var project = CreatePublishProject("win-x64", singleFile: false, duplicateUnrelatedAsset: true); + var result = await RunDotNetAsync(["msbuild", project, "-nologo", "-t:CopyTestFiles"]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("NETSDK1152", result.Output); + } + + [Theory] + [InlineData("win-x64", null)] + [InlineData("win-arm64", null)] + [InlineData("linux-x64", null)] + [InlineData("win-x64", "arm64/OpenConsole.exe")] + [InlineData("win-arm64", "hex1bpty.exe")] + public async Task BundlePreservesPtyLayoutAndRejectsMissingSidecars(string rid, string? missingSidecar) + { + var artifacts = Path.Combine(_workspace.Path, "artifacts"); + var publish = Path.Combine(artifacts, "bin", "Aspire.Managed", "Release", "net10.0", rid, "publish"); + var executable = rid.StartsWith("win-", StringComparison.Ordinal) ? "aspire-managed.exe" : "aspire-managed"; + var files = new List { executable, "wwwroot/index.html" }; + if (rid.StartsWith("win-", StringComparison.Ordinal)) + { + files.AddRange(GetNativePaths(rid)); + } + + foreach (var file in files.Append("Aspire.Dashboard.exe").Append("Aspire.TerminalHost.exe")) + { + if (file != missingSidecar) + { + WriteFile(Path.Combine(publish, file), file); + } + } + + var packageRid = rid switch + { + "win-x64" => "windows-amd64", + "win-arm64" => "windows-arm64", + _ => "linux-amd64" + }; + var packages = Path.Combine(_workspace.Path, "packages"); + WriteFile(Path.Combine(packages, $"microsoft.developercontrolplane.{packageRid}", "1.0.0", "tools", "dcp"), "dcp"); + var layout = Path.Combine(_workspace.Path, "layout"); + var testAssembly = typeof(Hex1bNativePublishingTests).Assembly.Location; + var result = await RunDotNetAsync( + ["exec", "--runtimeconfig", Path.ChangeExtension(testAssembly, ".runtimeconfig.json"), + "--depsfile", Path.ChangeExtension(testAssembly, ".deps.json"), + typeof(Aspire.Tools.CreateLayout.Program).Assembly.Location, + "--output", layout, "--artifacts", artifacts, "--rid", rid], + packages); + if (missingSidecar is not null) + { + Assert.NotEqual(0, result.ExitCode); + Assert.Contains(Path.GetFileName(missingSidecar), result.Output); + return; + } + + Assert.True(result.ExitCode == 0, result.Output); + + var managed = Path.Combine(layout, "managed"); + Assert.Equal(files.Order(StringComparer.Ordinal), Directory.GetFiles(managed, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(managed, path).Replace('\\', '/')).Order(StringComparer.Ordinal)); + foreach (var file in files) + { + Assert.True(File.ReadAllBytes(Path.Combine(publish, file)).SequenceEqual(File.ReadAllBytes(Path.Combine(managed, file))), file); + } + } + + [Fact] + public void SharedTargetsAreImportedAndShippedForHostingConsumers() + { + var repositoryTargets = XDocument.Load(Path.Combine(RepoRoot.Path, "Directory.Build.targets")); + Assert.Contains(repositoryTargets.Descendants("Import"), import => + (string?)import.Attribute("Project") == "$(MSBuildThisFileDirectory)src/Aspire.Hosting/buildTransitive/Aspire.Hosting.Hex1b.targets"); + var hostingTargets = XDocument.Load(Path.Combine(RepoRoot.Path, "src", "Aspire.Hosting", "buildTransitive", "Aspire.Hosting.targets")); + Assert.Contains(hostingTargets.Descendants("Import"), import => (string?)import.Attribute("Project") == "Aspire.Hosting.Hex1b.targets"); + var hostingProject = XDocument.Load(Path.Combine(RepoRoot.Path, "src", "Aspire.Hosting", "Aspire.Hosting.csproj")); + Assert.Contains(hostingProject.Descendants("None"), item => + (string?)item.Attribute("Include") == @"buildTransitive\Aspire.Hosting.Hex1b.targets" && + (string?)item.Attribute("Pack") == "true" && + (string?)item.Attribute("PackagePath") == @"buildTransitive\$(DefaultTargetFramework)"); + } + + private string CreatePublishProject(string rid, bool singleFile, bool duplicateUnrelatedAsset) + { + var nativeItems = new XElement("ItemGroup"); + foreach (var relativePath in GetNativePaths(rid)) + { + var pathInPackage = rid.Length == 0 ? relativePath : $"runtimes/{rid}/native/{relativePath}"; + var source = Path.Combine(_workspace.Path, "package", pathInPackage); + WriteFile(source, pathInPackage); + nativeItems.Add(new XElement(rid.Length == 0 ? "RuntimeTargetsCopyLocalItems" : "NativeCopyLocalItems", + new XAttribute("Include", source), + new XElement("NuGetPackageId", "Hex1b"), + new XElement("PathInPackage", pathInPackage), + new XElement("DestinationSubPath", rid.Length == 0 ? pathInPackage : Path.GetFileName(pathInPackage)), + new XElement("DestinationSubDirectory", rid.Length == 0 ? pathInPackage[..(pathInPackage.LastIndexOf('/') + 1)] : ""))); + } + + var unrelatedSource = Path.Combine(_workspace.Path, "other", "OpenConsole.exe"); + WriteFile(unrelatedSource, "unrelated"); + var unrelatedItem = new XElement("ResolvedFileToPublish", + new XAttribute("Include", unrelatedSource), + new XElement("NuGetPackageId", "OtherPackage"), + new XElement("PathInPackage", "native/OpenConsole.exe"), + new XElement("RelativePath", "other/OpenConsole.exe")); + var publishItems = new XElement("ItemGroup", + new XElement("ResolvedFileToPublish", + new XAttribute("Include", "@(_ResolvedCopyLocalPublishAssets);@(RuntimeTargetsCopyLocalItems)"), + new XElement("RelativePath", "%(DestinationSubDirectory)%(Filename)%(Extension)")), + unrelatedItem); + if (duplicateUnrelatedAsset) + { + publishItems.Add(new XElement("ResolvedFileToPublish", + new XAttribute("Include", Path.Combine(_workspace.Path, "duplicate", "OpenConsole.exe")), + new XElement("RelativePath", "other/OpenConsole.exe"))); + } + + // The SDK produces flattened native items for RID builds but full runtimes//native + // paths for portable builds. Seed those shapes without restoring a synthetic NuGet package; + // retain the SDK's real duplicate-output check and exercise the production target hooks. + var project = new XDocument(new XElement("Project", + new XElement("PropertyGroup", + new XElement("ImportDirectoryBuildProps", "false"), + new XElement("ImportDirectoryBuildTargets", "false")), + new XElement("Import", new XAttribute("Project", "Sdk.props"), new XAttribute("Sdk", "Microsoft.NET.Sdk")), + new XElement("PropertyGroup", + new XElement("TargetFramework", "net11.0"), + new XElement("RuntimeIdentifier", rid), + new XElement("PublishSingleFile", singleFile), + new XElement("IncludeNativeLibrariesForSelfExtract", "true")), + new XElement("Import", new XAttribute("Project", "Sdk.targets"), new XAttribute("Sdk", "Microsoft.NET.Sdk")), + new XElement("Import", new XAttribute("Project", Path.Combine(RepoRoot.Path, "src", "Aspire.Hosting", "buildTransitive", "Aspire.Hosting.targets"))), + new XElement("Target", new XAttribute("Name", "ResolvePackageAssets"), nativeItems), + new XElement("Target", new XAttribute("Name", "_ResolveCopyLocalAssetsForPublish"), + new XAttribute("DependsOnTargets", "ResolvePackageAssets"), + new XElement("ItemGroup", new XElement("_ResolvedCopyLocalPublishAssets", + new XAttribute("Include", "@(NativeCopyLocalItems)"), + new XElement("DestinationSubPath", "%(Filename)%(Extension)"), + new XElement("DestinationSubDirectory", "")))), + new XElement("Target", new XAttribute("Name", "ComputeResolvedFilesToPublishList"), + new XAttribute("DependsOnTargets", "_ResolveCopyLocalAssetsForPublish"), publishItems), + new XElement("Target", new XAttribute("Name", "ComputeFilesToPublish"), + new XAttribute("DependsOnTargets", "ComputeResolvedFilesToPublishList")), + new XElement("Target", new XAttribute("Name", "CopyTestFiles"), + new XAttribute("DependsOnTargets", "ComputeFilesToPublish"), + new XElement("Copy", new XAttribute("SourceFiles", "@(ResolvedFileToPublish)"), + new XAttribute("DestinationFiles", "@(ResolvedFileToPublish->'$(MSBuildProjectDirectory)/publish/%(RelativePath)')"))))); + var projectPath = Path.Combine(_workspace.Path, "Publish.proj"); + project.Save(projectPath); + return projectPath; + } + + private static string[] GetNativePaths(string rid) => rid switch + { + "win-x64" => ["arm64/OpenConsole.exe", "conpty.dll", "hex1bpty.exe", "x64/OpenConsole.exe"], + "win-arm64" => ["arm64/OpenConsole.exe", "conpty.dll", "hex1bpty.exe"], + "" => GetNativePaths("win-x64").Select(path => $"runtimes/win-x64/native/{path}") + .Concat(GetNativePaths("win-arm64").Select(path => $"runtimes/win-arm64/native/{path}")) + .Append("runtimes/linux-x64/native/libhex1binterop.so").ToArray(), + _ => ["libhex1binterop.so"] + }; + + private static void WriteFile(string path, string contents) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + } + + private async Task<(int ExitCode, string Output)> RunDotNetAsync(string[] arguments, string? packages = null) + { + var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + WorkingDirectory = _workspace.Path, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + if (packages is not null) + { + startInfo.Environment["NUGET_PACKAGES"] = packages; + } + + using var process = Process.Start(startInfo)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw; + } + + var output = await stdout + await stderr; + _output.WriteLine(output); + return (process.ExitCode, output); + } +} diff --git a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj index 663045d09b8..aebca0286d3 100644 --- a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj +++ b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj @@ -35,6 +35,7 @@ + diff --git a/tests/Infrastructure.Tests/NuGetConfigTests.cs b/tests/Infrastructure.Tests/NuGetConfigTests.cs index 89e29ca4ac7..aad4cdee9ea 100644 --- a/tests/Infrastructure.Tests/NuGetConfigTests.cs +++ b/tests/Infrastructure.Tests/NuGetConfigTests.cs @@ -9,6 +9,36 @@ namespace Infrastructure.Tests; public sealed class NuGetConfigTests { + [Fact] + public void TemplateRestoresLimitNuGetOrgToHex1bAndPreserveExistingFeeds() + { + var document = XDocument.Load(Path.Combine(RepoRoot.Path, "tests", "Shared", "TemplatesTesting", "data", "nuget8.config")); + var root = document.Root; + Assert.NotNull(root); + var sources = root.Element("packageSources")!.Elements("add") + .ToDictionary(element => element.Attribute("key")!.Value, element => element.Attribute("value")!.Value, StringComparer.Ordinal); + string[] expectedSources = ["built-local", "dotnet-eng", "dotnet-public", "dotnet10", "dotnet9", "nuget-hex1b"]; + Assert.Equal(expectedSources, sources.Keys.Order(StringComparer.Ordinal)); + Assert.Equal("https://api.nuget.org/v3/index.json", sources["nuget-hex1b"]); + + var mappings = root.Element("packageSourceMapping")!.Elements("packageSource") + .ToDictionary(element => element.Attribute("key")!.Value, + element => element.Elements("package").Select(package => package.Attribute("pattern")!.Value).ToArray(), + StringComparer.Ordinal); + Assert.Equal(expectedSources, mappings.Keys.Order(StringComparer.Ordinal)); + foreach (var (source, patterns) in mappings) + { + if (source == "nuget-hex1b") + { + Assert.Equal(["Hex1b"], patterns); + } + else + { + Assert.Equal(["*"], patterns); + } + } + } + [Fact] public void DiagnosticsPackagesAreMappedToPublicAndToolsFeeds() { diff --git a/tests/Shared/TemplatesTesting/BuildEnvironment.cs b/tests/Shared/TemplatesTesting/BuildEnvironment.cs index 23c1e3a6639..071ac066876 100644 --- a/tests/Shared/TemplatesTesting/BuildEnvironment.cs +++ b/tests/Shared/TemplatesTesting/BuildEnvironment.cs @@ -188,7 +188,10 @@ public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotne // Template tests build generated apps from repo-built packages, not from an installed // Aspire CLI bundle layout, so keep bundle resolution disabled for these test builds. EnvVars["AspireUseCliBundle"] = "false"; - EnvVars["NoWarn"] = "ASPIRE010"; + // Generated apps do not import the repository's Directory.Build.props. Temporarily match its + // audit suppression while using nuget.org for Hex1b; remove NU1902/NU1903 with the + // nuget-hex1b source in nuget8.config once the package is available from the internal feeds. + EnvVars["NoWarn"] = "ASPIRE010;NU1902;NU1903"; if (OperatingSystem.IsMacOS()) { diff --git a/tests/Shared/TemplatesTesting/data/nuget8.config b/tests/Shared/TemplatesTesting/data/nuget8.config index edccbff48f6..7426762153a 100644 --- a/tests/Shared/TemplatesTesting/data/nuget8.config +++ b/tests/Shared/TemplatesTesting/data/nuget8.config @@ -11,7 +11,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/CreateLayout/Program.cs b/tools/CreateLayout/Program.cs index 6dd1a5cacfa..9a8ed3fbeeb 100644 --- a/tools/CreateLayout/Program.cs +++ b/tools/CreateLayout/Program.cs @@ -157,9 +157,8 @@ private void CopyManaged() var managedDir = Path.Combine(_outputPath, "managed"); Directory.CreateDirectory(managedDir); - // Copy only the aspire-managed executable and required assets (wwwroot for Dashboard). - // Skip other .exe files — they are native host stubs from referenced Exe projects - // that leak into the publish output but are not needed (everything is in aspire-managed.exe). + // Copy the managed executable and known sidecars, not the apphost stubs from referenced + // Exe projects that also appear in publish output. var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); var managedExeName = isWindows ? "aspire-managed.exe" : "aspire-managed"; @@ -171,6 +170,24 @@ private void CopyManaged() File.Copy(managedExePath, Path.Combine(managedDir, managedExeName), overwrite: true); + if (isWindows) + { + // Hex1b launches hex1bpty.exe beside the app; conpty.dll must stay beside that helper. + // ConPTY selects OpenConsole by OS architecture, so win-x64 also needs the ARM64 host + // when running under emulation. These files cannot live inside the managed single-file. + // https://github.com/microsoft/terminal/blob/main/src/winconpty/winconpty.cpp + string[] ptyFiles = _rid == "win-x64" + ? ["hex1bpty.exe", "conpty.dll", "x64/OpenConsole.exe", "arm64/OpenConsole.exe"] + : ["hex1bpty.exe", "conpty.dll", "arm64/OpenConsole.exe"]; + + foreach (var relativePath in ptyFiles) + { + var destination = Path.Combine(managedDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(managedPublishPath, relativePath), destination, overwrite: true); + } + } + // Copy wwwroot (required for Dashboard static web assets) var wwwrootPath = Path.Combine(managedPublishPath, "wwwroot"); if (Directory.Exists(wwwrootPath)) From e545140e5d9be50fb1e0e654aefc90b13b362d78 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 11 Sep 2026 20:17:42 +1000 Subject: [PATCH 061/106] Fix VS Code fixture restores and terminal component tests Temporarily restrict nuget.org access to Hex1b in both isolated extension fixture restore scopes and retain existing internal/local feed mappings. Register terminal test services before bUnit renders, and await background subscription counters independently of UI renders. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- extension/scripts/run-e2e.js | 19 ++++- extension/src/test/e2eLaunchProfile.test.ts | 76 +++++++++++++++++++ .../Layout/MainLayoutTerminalTests.cs | 3 +- .../Pages/TerminalWindowTests.cs | 9 ++- 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/extension/scripts/run-e2e.js b/extension/scripts/run-e2e.js index 50662f928a8..fc9de7a81fd 100644 --- a/extension/scripts/run-e2e.js +++ b/extension/scripts/run-e2e.js @@ -1937,16 +1937,33 @@ function writeNuGetConfigIfLocalPackageSourcesExist() { const sourceEntries = packageSources .map((source, index) => ` `) .join('\n'); - const fallbackSourceEntries = getApprovedFallbackPackageSources() + const fallbackSources = getApprovedFallbackPackageSources(); + const fallbackSourceEntries = fallbackSources .map(source => ` `) .join('\n'); + // Exact Hex1b mapping restricts nuget.org access. Wildcards preserve the existing local/internal + // source choices for every other package until the temporary source and mappings can be removed. + const sourceMappingEntries = [ + ...packageSources.map((_, index) => `e2e-source-${index}`), + ...fallbackSources.map(source => source.key), + ].map(key => ` + + `).join('\n'); const nugetConfig = ` ${sourceEntries} ${fallbackSourceEntries} + + + +${sourceMappingEntries} + + + + `; // External AppHost fixtures are siblings of the workspace, while an explicitly supplied diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 600cc17efe5..cde054257da 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { spawnSync } from 'child_process'; +import { runInNewContext } from 'vm'; import * as ts from 'typescript'; function removeDirectorySafely(directory: string): void { @@ -85,6 +86,27 @@ function runE2eRunnerAsPlatform(extensionRoot: string, platform: 'darwin' | 'lin }); } +function getFixtureNuGetConfigurations(packageSources: readonly string[]): Map { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); + const sourceFile = ts.createSourceFile('run-e2e.js', runner, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + const functionNames = new Set(['writeNuGetConfigIfLocalPackageSourcesExist', 'getApprovedFallbackPackageSources', 'escapeXml']); + const declarations = sourceFile.statements.filter((statement): statement is ts.FunctionDeclaration => + ts.isFunctionDeclaration(statement) && statement.name !== undefined && functionNames.has(statement.name.text)); + assert.strictEqual(declarations.length, functionNames.size); + + // Execute the actual config writer without the runner's CLI downloads, workspace cleanup, or VS Code launch. + const configurations = new Map(); + runInNewContext(`${declarations.map(declaration => declaration.getText(sourceFile)).join('\n')} +writeNuGetConfigIfLocalPackageSourcesExist();`, { + fs: { writeFileSync: (file: string, content: string) => configurations.set(file, content) }, + getLocalPackageSourceDirectories: () => packageSources, + runRootNuGetConfigPath: 'run-root/NuGet.config', + workspaceNuGetConfigPath: 'workspace/NuGet.config', + }); + return configurations; +} + function createE2eSpecFixtures(extensionRoot: string, fileNames: readonly string[]): string { const testArtifactsRoot = path.join(extensionRoot, '.test-artifacts', 'unit'); fs.mkdirSync(testArtifactsRoot, { recursive: true }); @@ -823,6 +845,60 @@ suite('E2E launch profile', () => { assert.ok(writeConfig.includes('fs.writeFileSync(workspaceNuGetConfigPath, nugetConfig);')); }); + test('limits temporary nuget.org access to Hex1b in both fixture restore scopes', () => { + const configurations = getFixtureNuGetConfigurations(['/packages/local', '/packages/ & "daily"']); + const expectedConfig = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + assert.deepStrictEqual([...configurations], [ + ['run-root/NuGet.config', expectedConfig], + ['workspace/NuGet.config', expectedConfig], + ]); + }); + + test('does not write fixture NuGet configurations without local package sources', () => { + assert.deepStrictEqual([...getFixtureNuGetConfigurations([])], []); + }); + test('suppresses evaluation diagnostics for intentional E2E AppHost interaction APIs', () => { const extensionRoot = path.resolve(__dirname, '..', '..'); const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs index 7dfd21bb0a2..59bd87470a1 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs @@ -35,9 +35,10 @@ public async Task TerminalDock_RunSelection_OnlySubscribesWhileLive(bool startHi new("current", DashboardRunStore.SchemaVersion, DateTimeOffset.UnixEpoch, null, false, "TestApp", string.Empty, true), new("historical", DashboardRunStore.SchemaVersion, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, true, "TestApp", string.Empty, false) ]); - SetupMainLayoutServices(dashboardRunStore: runStore, dashboardClient: client); + // Main layout setup renders the message bar provider, which freezes service registration. TerminalSetupHelpers.SetupTerminalView(this); TerminalSetupHelpers.SetupTerminalDock(this); + SetupMainLayoutServices(dashboardRunStore: runStore, dashboardClient: client); var selection = Assert.IsType(Services.GetRequiredService()); selection.OnSelectRun = runId => client.IsReadOnly = runId is not null; if (startHistorical) diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs index 574926db8bc..380091e539a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs @@ -90,6 +90,10 @@ public async Task AppHostRouteChange_ReplacesWatchAndResetsEndedState(bool first } await SetTerminalAsync(cut, "second").DefaultTimeout(); + // Starting the background watch does not render, so observe its counters independently. + await AsyncTestHelpers.AssertIsTrueRetryAsync( + () => client.TerminalSubscriptionCount == 2 && client.ActiveTerminalSubscriptionCount == 1, + "The replacement terminal subscription did not start."); cut.WaitForAssertion(() => { Assert.Equal(2, client.TerminalSubscriptionCount); @@ -144,7 +148,10 @@ public async Task ResourceRoute_CancelsAppHostWatchAndResetsRouteState(bool firs Assert.Equal(1, client.TerminalSubscriptionCount); await SetTerminalAsync(cut, "next").DefaultTimeout(); - cut.WaitForAssertion(() => Assert.Equal(2, client.TerminalSubscriptionCount)); + await AsyncTestHelpers.AssertIsTrueRetryAsync( + () => client.TerminalSubscriptionCount == 2, + "The terminal subscription did not restart after leaving the resource route."); + Assert.Equal(2, client.TerminalSubscriptionCount); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "next", "Next shell")); head.WaitForAssertion(() => Assert.Equal("Next shell", head.Find("title").TextContent)); Assert.Equal("/api/apphost-terminal?terminalId=next", cut.FindComponent().Instance.EndpointPathAndQuery); From 5ec29a2451aedffca40f1e7db1c4b93861494b88 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 13 Sep 2026 19:03:17 +1000 Subject: [PATCH 062/106] Update Hex1b and enable terminal reflow Upgrade the paired Hex1b NuGet and web-terminal packages, enable matching producer and replica reflow with regression coverage, and remove the visible dock resize hint. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 19 ++++- .../Commands/TerminalTapePlayCommand.cs | 2 + .../Components/Layout/TerminalDock.razor | 5 +- .../Terminal/TerminalWebSocketProxy.cs | 5 ++ src/Aspire.Dashboard/package-lock.json | 8 +- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 4 +- .../wwwroot/js/hex1b-web-terminal/README.md | 46 ++++++++--- .../hex1b-web-terminal/dist/command-mark.d.ts | 14 ++++ .../dist/command-mark.d.ts.map | 1 + .../hex1b-web-terminal/dist/command-mark.js | 26 +++++++ .../dist/command-mark.js.map | 1 + .../js/hex1b-web-terminal/dist/index.d.ts | 1 + .../js/hex1b-web-terminal/dist/index.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/index.js | 1 + .../js/hex1b-web-terminal/dist/index.js.map | 2 +- .../js/hex1b-web-terminal/dist/protocol.d.ts | 1 + .../hex1b-web-terminal/dist/protocol.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/protocol.js | 27 +++++++ .../hex1b-web-terminal/dist/protocol.js.map | 2 +- .../hex1b-web-terminal/dist/renderer.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/renderer.js | 15 +++- .../hex1b-web-terminal/dist/renderer.js.map | 2 +- .../dist/terminal-worker.js | 1 + .../dist/terminal-worker.js.map | 2 +- .../js/hex1b-web-terminal/dist/types.d.ts | 38 +++++++++ .../js/hex1b-web-terminal/dist/types.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/types.js.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.d.ts | 4 +- .../dist/web-terminal.d.ts.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.js | 15 ++++ .../dist/web-terminal.js.map | 2 +- .../hex1b-web-terminal/dist/wire-types.d.ts | 6 +- .../dist/wire-types.d.ts.map | 2 +- .../hex1b-web-terminal/dist/wire-types.js.map | 2 +- .../js/hex1b-web-terminal/package.json | 2 +- .../Terminals/Hex1bAspireTerminal.cs | 3 + .../Terminals/ResourceAspireTerminal.cs | 2 + src/Aspire.TerminalHost/TerminalReplica.cs | 5 +- .../JavaScript/TerminalView.test.mjs | 4 +- .../Layout/TerminalDockTests.cs | 5 +- .../Shared/TerminalTestHost.cs | 6 +- .../Terminal/TerminalWebSocketTests.cs | 76 ++++++++++++++++++ .../Terminals/Hex1bAspireTerminalTests.cs | 52 +++++++++++++ .../Utils/TestAppHostTerminalViewer.cs | 18 ++++- .../TerminalHostAppTests.cs | 78 ++++++++++++++++++- 47 files changed, 472 insertions(+), 49 deletions(-) create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.js.map diff --git a/Directory.Packages.props b/Directory.Packages.props index 935a897939e..4b5c798e4d0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,7 +117,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index c8e0b31e47a..b338a38fe7e 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -69,6 +69,23 @@ The dashboard and CLI attach using `Hmp1WorkloadAdapter`. The dashboard adds a per-browser `Hex1bTerminal` mirror with `Hwt1PresentationAdapter`; the browser receives authoritative terminal state rather than parsing ANSI. +### Resize and reflow + +AppHost-owned terminals and resource terminal hosts explicitly enable +`GhosttyReflowStrategy.Instance` on their HMP presentation adapters. Dashboard +HWT replicas and headless automation replicas use the same policy: HMP preserves +soft wraps during replay but does not negotiate the reflow strategy. +No browser-side reflow setting is required. + +Resizing rewraps soft continuations while preserving hard line breaks, cursor +positions and retained history. Producers and dashboard replicas retain up to +10,000 physical rows of scrollback, so narrowing can evict the oldest rows at +that limit. Fresh HMP replicas receive the current screen and accumulate history +after attachment, not the producer's entire pre-existing scrollback. +Alternate-screen layouts still crop on resize; the saved main screen reflows +when the application returns to it. Primary-peer resize authority is unchanged. +See [Hex1b's reflow configuration](https://github.com/mitchdenny/hex1b/blob/093b67b/docs/web-terminal.md#shell-reflow-configuration). + ## Property contract (gRPC `ResourceService` snapshots) When `WithTerminal()` is applied to a resource, every replica snapshot @@ -149,7 +166,7 @@ it does not lock the terminal, its creator's automation, or other viewers. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.166.0`. HWT1 is experimental state transfer +exactly `0.167.0-alpha.1565.1.6eea363`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. diff --git a/src/Aspire.Cli/Commands/TerminalTapePlayCommand.cs b/src/Aspire.Cli/Commands/TerminalTapePlayCommand.cs index 8571011443e..ee27eb2e15a 100644 --- a/src/Aspire.Cli/Commands/TerminalTapePlayCommand.cs +++ b/src/Aspire.Cli/Commands/TerminalTapePlayCommand.cs @@ -9,6 +9,7 @@ using Aspire.Cli.Resources; using Hex1b; using Hex1b.Automation; +using Hex1b.Reflow; using Hex1b.Tokens; using Microsoft.Extensions.Logging; @@ -156,6 +157,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul // and no scrollback is enabled: VHS Wait+Screen must inspect this mirror's visible screen. await using var terminal = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithReflow(GhosttyReflowStrategy.Instance) .WithWorkload(adapter) .WithDimensions(adapter.RemoteWidth, adapter.RemoteHeight) .AddPresentationFilter(initialScreen) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 44e8a422bbc..dd6173af389 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -23,9 +23,8 @@ aria-valuenow="@_heightPx" aria-valuetext="@Loc[nameof(Resources.Layout.TerminalDockHeight), _heightPx]" aria-describedby="@($"{_elementIdPrefix}-resize-help")" - aria-keyshortcuts="ArrowUp ArrowDown Shift+ArrowUp Shift+ArrowDown Home End" - title="@Loc[nameof(Resources.Layout.TerminalDockResizeHelp)]">
- @Loc[nameof(Resources.Layout.TerminalDockResizeHelp)] + aria-keyshortcuts="ArrowUp ArrowDown Shift+ArrowUp Shift+ArrowDown Home End">
+
@* FluentTabs resets selection when closing an inactive tab (https://github.com/microsoft/fluentui-blazor/issues/3392). Keep selection and removal driven by AppHost updates, with separate tab/close buttons and mounted panes. *@ diff --git a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs index 823cd62f4db..0c6cd536eb9 100644 --- a/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs +++ b/src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs @@ -8,6 +8,7 @@ using Aspire.Dashboard.Model; using Grpc.Core; using Hex1b; +using Hex1b.Reflow; namespace Aspire.Dashboard.Terminal; @@ -299,6 +300,10 @@ private static async Task BridgeAsync(WebSocket socket, Hmp1WorkloadAdapter work var terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(workload) .WithPresentation(presentation) + // HMP preserves soft wraps but does not negotiate reflow policy. Match the + // AppHost/TerminalHost producer so this replica also reflows retained history. + // https://github.com/mitchdenny/hex1b/blob/093b67b/docs/web-terminal.md#shell-reflow-configuration + .WithReflow(GhosttyReflowStrategy.Instance) .WithScrollback(10000) .Build(); await using var terminalLifetime = terminal.ConfigureAwait(false); diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 523649b4dd6..161110d3250 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.166.0" + "@hex1b/web-terminal": "0.167.0-alpha.1565.1.6eea363" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.166.0", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.166.0.tgz", - "integrity": "sha512-kq93remKSiFcYfm8c5Vqs8zj1UiXN/KFNPCZN2rSoLABBvpotyHFSk/QEr4T2RMCv8RtPXZc9NnOMNoeb/JwiQ==", + "version": "0.167.0-alpha.1565.1.6eea363", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1565.1.6eea363.tgz", + "integrity": "sha512-JsY22DHBHYK0hMzbcnQ8wR4x6+N6UzxrfSx8BHeqRLLE7IvQB2jZtkZUZyQm+FoxhAILR7q6vvkyu/zVrADqrA==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index 5bf804de3cc..cdb6bfb844f 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.166.0" + "@hex1b/web-terminal": "0.167.0-alpha.1565.1.6eea363" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index 3c1d1b896a8..2a9ca183ee5 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -18,8 +18,8 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.166.0**, -paired with the Hex1b NuGet package **0.166.0**. The client and server use the evolving +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1565.1.6eea363**, +paired with the Hex1b NuGet package **0.167.0-alpha.1565.1.6eea363**. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md index ab29c24d019..11eabc7f467 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md @@ -185,6 +185,7 @@ workers, fonts, and the intended WebSocket endpoint. | `onTitleChange` | Initial authoritative workload title, then distinct presented changes; see below. | | `onClose` | Native WebSocket close details, including pre-mount transport failure; not workload completion. | | `onProgressChange`, `onShellIntegrationChange` | Initial authoritative activity, then distinct presented changes for host-owned chrome. | +| `onWorkingDirectoryChange`, `onCommandMarkChange` | Initial authoritative OSC 7 directory and latest OSC 133 marker, then distinct presented changes; see below. | | `inputBindings`, `onInput`, `actions` | Per-view input policy and custom actions. | | `onSelectionUI` | Synchronous, cancelable UI notification hook. | @@ -195,7 +196,8 @@ Font size is an integer from 8–32, defaulting to 16. Import `MIN_FONT_SIZE` an ownership. `requestPrimary()` explicitly requests ownership; inspect `peer` or `onRoleChange` to observe the result. -The handle exposes `geometry`, `peer`, `connected`, `readOnly`, `title`, `progress`, `shellIntegration`, `stats`, `screenText`, +The handle exposes `geometry`, `peer`, `connected`, `readOnly`, `title`, `progress`, `shellIntegration`, +`workingDirectory`, `commandMark`, `stats`, `screenText`, `sizing`, `viewport`, `selection`, `inputBindings`, and `inputContext`. Metrics start empty; check optional fields before using them. History may be unavailable, and selection can be unavailable, none, pending, valid, or @@ -204,7 +206,8 @@ their state-specific values. `screenText` reflects the presented viewport, not an independently reconstructed ANSI buffer. Callbacks include `onGeometry`, `onRoleChange`, `onTitleChange`, `onSizingChange`, `onStats`, -`onProgressChange`, `onShellIntegrationChange`, `onViewportChange`, `onSelectionChange`, `onStatus`, and `onInputError`. +`onProgressChange`, `onShellIntegrationChange`, `onWorkingDirectoryChange`, `onCommandMarkChange`, +`onViewportChange`, `onSelectionChange`, `onStatus`, and `onInputError`. ### Live read-only views @@ -341,11 +344,28 @@ A/B/C preserve the last reported result, and D replaces it, including clearing it to null when the shell omits its status. No command text, history, or output locations are retained by these APIs. -Both getters return defensive copies. Their callbacks receive the first +`terminal.workingDirectory` exposes OSC 7 state as `{ uri, host, path }`, all +`null` until the first report. `uri` is the raw reported `file://` URI; `host` +and `path` are derived from it (`host` is `""` for a local/unqualified +authority). A malformed or non-`file` URI leaves the previous value unchanged. + +`terminal.commandMark` exposes the single most-recently-reported OSC 133 marker +as `{ phase, exitCode, rawParameters } | null` — `null` until the first marker. +`phase` uses the same enum as `shellIntegration.phase`. `exitCode` is non-null +only on a `finished` (D) marker. `rawParameters` is the verbatim +`key=value[;key=value...]` text trailing the marker (for example a +`cmdline_url` extension on marker C), or `null` when none was present; use the +exported `parseCommandMarkParameters(rawParameters)` helper to parse it into a +`Map`, or `getCmdlineUrl(mark)` as a shortcut for the `cmdline_url` entry. This +is **not** a command-mark history — only the latest marker is exposed, mirroring +`shellIntegration`. A host that wants its own history should accumulate +distinct values from `onCommandMarkChange` itself. + +All four getters return defensive copies. Their callbacks receive the first authoritative presented state before mount resolves, then distinct presented -changes. Both getters are updated before either activity callback. Callbacks -use the same direct, synchronous host-callback convention as title changes; -host exceptions are not swallowed or retried. +changes. All four getters are updated before their corresponding activity +callback. Callbacks use the same direct, synchronous host-callback convention +as title changes; host exceptions are not swallowed or retried. Frames coalesce: the browser might see only Finished for a fast command, or miss an entire command whose final state is unchanged. These callbacks are @@ -356,15 +376,16 @@ and a new mount receives its own baseline. This example creates optional chrome outside the terminal: ```ts -import { WebTerminal } from "@hex1b/web-terminal"; +import { WebTerminal, getCmdlineUrl } from "@hex1b/web-terminal"; const status = document.createElement("span"); +const cwd = document.createElement("span"); const progress = document.createElement("progress"); progress.max = 100; progress.hidden = true; const container = document.createElement("div"); container.style.cssText = "width:800px;height:480px"; -document.body.append(status, progress, container); +document.body.append(status, cwd, progress, container); const terminal = await WebTerminal.mount(container, { url: "/ws/terminal", @@ -378,6 +399,13 @@ const terminal = await WebTerminal.mount(container, { status.textContent = value.phase + (value.lastExitCode === null ? "" : ` (last exit ${value.lastExitCode})`); }, + onWorkingDirectoryChange(value) { + cwd.textContent = value.path ?? ""; + }, + onCommandMarkChange(value) { + const cmdlineUrl = getCmdlineUrl(value); + if (cmdlineUrl) console.log("Command link:", cmdlineUrl); + }, onStats(stats) { if (!stats.connected) { progress.hidden = true; @@ -385,7 +413,7 @@ const terminal = await WebTerminal.mount(container, { } } }); -console.log(terminal.progress, terminal.shellIntegration); +console.log(terminal.progress, terminal.shellIntegration, terminal.workingDirectory, terminal.commandMark); ``` No title, document chrome, or progress UI is changed automatically by the diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.d.ts new file mode 100644 index 00000000000..f83bbb7c517 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.d.ts @@ -0,0 +1,14 @@ +import type { TerminalCommandMark } from "./types.js"; +/** + * Parses a {@link TerminalCommandMark.rawParameters} string into key/value pairs, mirroring the + * server's `TerminalCommandMark.Parameters`: segments are split on `;`, each split on the first + * `=`; segments without `=` are ignored. Values are not decoded (e.g. percent-decoding is left + * to the caller for keys that use it, such as `cmdline_url`). Returns an empty map for null/"". + */ +export declare function parseCommandMarkParameters(rawParameters: string | null): ReadonlyMap; +/** + * Gets the raw (still percent-encoded) `cmdline_url` parameter from a command mark, or null when + * absent. This is a Contour-originated, non-universal extension to OSC 133;C. + */ +export declare function getCmdlineUrl(mark: TerminalCommandMark | null): string | null; +//# sourceMappingURL=command-mark.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.d.ts.map new file mode 100644 index 00000000000..2ba331a2437 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"command-mark.d.ts","sourceRoot":"","sources":["../src/command-mark.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtD;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CASpG;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,mBAAmB,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAE7E"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.js new file mode 100644 index 00000000000..eefeb6547f0 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.js @@ -0,0 +1,26 @@ +/** + * Parses a {@link TerminalCommandMark.rawParameters} string into key/value pairs, mirroring the + * server's `TerminalCommandMark.Parameters`: segments are split on `;`, each split on the first + * `=`; segments without `=` are ignored. Values are not decoded (e.g. percent-decoding is left + * to the caller for keys that use it, such as `cmdline_url`). Returns an empty map for null/"". + */ +export function parseCommandMarkParameters(rawParameters) { + const result = new Map(); + if (!rawParameters) + return result; + for (const segment of rawParameters.split(";")) { + const separator = segment.indexOf("="); + if (separator < 0) + continue; + result.set(segment.slice(0, separator), segment.slice(separator + 1)); + } + return result; +} +/** + * Gets the raw (still percent-encoded) `cmdline_url` parameter from a command mark, or null when + * absent. This is a Contour-originated, non-universal extension to OSC 133;C. + */ +export function getCmdlineUrl(mark) { + return mark ? parseCommandMarkParameters(mark.rawParameters).get("cmdline_url") ?? null : null; +} +//# sourceMappingURL=command-mark.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.js.map new file mode 100644 index 00000000000..bdc6b524f28 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/command-mark.js.map @@ -0,0 +1 @@ +{"version":3,"file":"command-mark.js","sourceRoot":"","sources":["../src/command-mark.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,UAAU,0BAA0B,CAAC,aAA4B;IACrE,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,IAAI,CAAC,aAAa;QAAE,OAAO,MAAM,CAAC;IAClC,KAAK,MAAM,OAAO,IAAI,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACvC,IAAI,SAAS,GAAG,CAAC;YAAE,SAAS;QAC5B,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC;IACxE,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,IAAgC;IAC5D,OAAO,IAAI,CAAC,CAAC,CAAC,0BAA0B,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACjG,CAAC","sourcesContent":["import type { TerminalCommandMark } from \"./types.js\";\n\n/**\n * Parses a {@link TerminalCommandMark.rawParameters} string into key/value pairs, mirroring the\n * server's `TerminalCommandMark.Parameters`: segments are split on `;`, each split on the first\n * `=`; segments without `=` are ignored. Values are not decoded (e.g. percent-decoding is left\n * to the caller for keys that use it, such as `cmdline_url`). Returns an empty map for null/\"\".\n */\nexport function parseCommandMarkParameters(rawParameters: string | null): ReadonlyMap {\n const result = new Map();\n if (!rawParameters) return result;\n for (const segment of rawParameters.split(\";\")) {\n const separator = segment.indexOf(\"=\");\n if (separator < 0) continue;\n result.set(segment.slice(0, separator), segment.slice(separator + 1));\n }\n return result;\n}\n\n/**\n * Gets the raw (still percent-encoded) `cmdline_url` parameter from a command mark, or null when\n * absent. This is a Contour-originated, non-universal extension to OSC 133;C.\n */\nexport function getCmdlineUrl(mark: TerminalCommandMark | null): string | null {\n return mark ? parseCommandMarkParameters(mark.rawParameters).get(\"cmdline_url\") ?? null : null;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts index c403b566328..29477ba91ae 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts @@ -1,5 +1,6 @@ export { WebTerminal } from "./web-terminal.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; export { MIN_FONT_SIZE, MAX_FONT_SIZE } from "./terminal-sizing.js"; +export { parseCommandMarkParameters, getCmdlineUrl } from "./command-mark.js"; export type * from "./types.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map index 33ceb329e3d..7d5c1311e2c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACpE,mBAAmB,YAAY,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACpE,OAAO,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC9E,mBAAmB,YAAY,CAAC"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js index dd3306b5dbb..341c167aa50 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js @@ -1,4 +1,5 @@ export { WebTerminal } from "./web-terminal.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; export { MIN_FONT_SIZE, MAX_FONT_SIZE } from "./terminal-sizing.js"; +export { parseCommandMarkParameters, getCmdlineUrl } from "./command-mark.js"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map index 191573565b2..37177b81837 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC","sourcesContent":["export { WebTerminal } from \"./web-terminal.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\nexport { MIN_FONT_SIZE, MAX_FONT_SIZE } from \"./terminal-sizing.js\";\nexport type * from \"./types.js\";\n"]} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACpE,OAAO,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC","sourcesContent":["export { WebTerminal } from \"./web-terminal.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\nexport { MIN_FONT_SIZE, MAX_FONT_SIZE } from \"./terminal-sizing.js\";\nexport { parseCommandMarkParameters, getCmdlineUrl } from \"./command-mark.js\";\nexport type * from \"./types.js\";\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts index 940cbef6cbc..9759264663f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts @@ -4,6 +4,7 @@ export declare const LIMITS: Readonly<{ frameBytes: number; metadataBytes: number; titleUnits: 4096; + commandMarkParameterUnits: 8192; cells: 262144; images: 4096; placements: 16384; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map index b45ec78089f..282b587dd7e 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,eAAe,EAAiB,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIlH,eAAO,MAAM,MAAM;;;;;;;;;EASjB,CAAC;AAKH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGxD;AAyBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,GAAG,IAAI,CAqC1H;AAsID,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,CAiD1D;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAY9G"} \ No newline at end of file +{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,eAAe,EAAiB,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIlH,eAAO,MAAM,MAAM;;;;;;;;;;EAUjB,CAAC;AAKH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGxD;AAyBD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,GAAG,IAAI,CAqC1H;AA6JD,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,CAiD1D;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAY9G"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js index 53d710d863c..6436a85a16a 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js @@ -5,6 +5,7 @@ export const LIMITS = Object.freeze({ frameBytes: 96 * 1024 * 1024, metadataBytes: 8 * 1024 * 1024, titleUnits: 4096, + commandMarkParameterUnits: 8192, cells: 262144, images: 4096, placements: 16384, @@ -124,6 +125,32 @@ function validateMetadata(metadata) { integer(shell.lastExitCode, "terminal shell exit code", -2147483648, 2147483647); if (shell.phase === "unknown" && shell.lastExitCode !== null) throw new Error("Unknown terminal shell phase has an exit code"); + const workingDirectory = metadata.workingDirectory; + if (!isRecord(workingDirectory)) + throw new Error("Invalid terminal working directory"); + const wdFields = [workingDirectory.uri, workingDirectory.host, workingDirectory.path]; + if (wdFields.every(field => field === null)) { + // No directory reported yet. + } + else if (wdFields.some(field => typeof field !== "string" || field.length > LIMITS.metadataBytes)) { + throw new Error("Invalid terminal working directory"); + } + const commandMark = metadata.commandMark; + if (commandMark !== null) { + if (!isRecord(commandMark) || typeof commandMark.phase !== "string" || + !["unknown", "prompt", "commandLine", "executing", "finished"].includes(commandMark.phase)) { + throw new Error("Invalid terminal command mark"); + } + if (commandMark.exitCode !== null) { + integer(commandMark.exitCode, "terminal command mark exit code", -2147483648, 2147483647); + if (commandMark.phase !== "finished") + throw new Error("Non-finished terminal command mark has an exit code"); + } + if (commandMark.rawParameters !== null && + (typeof commandMark.rawParameters !== "string" || commandMark.rawParameters.length > LIMITS.commandMarkParameterUnits)) { + throw new Error("Invalid terminal command mark parameters"); + } + } const columns = integer(metadata.columns, "columns", 1, 1024); const rows = integer(metadata.rows, "rows", 1, 512); if (typeof metadata.mouseTracking !== "number" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) { diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map index cddc434aa8d..b6b2d7a41f6 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/protocol.js.map @@ -1 +1 @@ -{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FrameMetadata, HistoryMetadata, SelectionText, TerminalCell, TerminalFrame } from \"./wire-types.js\";\nimport { isRecord } from \"./validation.js\";\n\n// Binary validation is deliberately independent of the GPU and the transport.\nexport const LIMITS = Object.freeze({\n commandBytes: 64 * 1024,\n frameBytes: 96 * 1024 * 1024,\n metadataBytes: 8 * 1024 * 1024,\n titleUnits: 4096,\n cells: 262144,\n images: 4096,\n placements: 16384,\n textureBytes: 256 * 1024 * 1024,\n});\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\nconst utf8Encoder = new TextEncoder();\n\nexport function assertCommandSize(command: unknown): void {\n if (utf8Encoder.encode(JSON.stringify(command)).byteLength > LIMITS.commandBytes)\n throw new RangeError(\"Terminal input exceeds the 64 KiB message limit. Send a smaller amount of text.\");\n}\n\nfunction integer(value: unknown, name: string, min = 0, max = Number.MAX_SAFE_INTEGER): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max) {\n throw new Error(`Invalid ${name}: ${String(value)}`);\n }\n return value;\n}\n\nfunction array(value: unknown, name: string, limit: number): asserts value is unknown[] {\n if (!Array.isArray(value) || value.length > limit) throw new Error(`Invalid ${name}`);\n}\n\nfunction key(value: unknown): asserts value is string {\n if (typeof value !== \"string\" || !value.length || value.length > 1024) {\n throw new Error(\"Invalid image key\");\n }\n}\n\nfunction rowId(value: unknown, name: string): asserts value is string {\n if (typeof value !== \"string\" || !/^[1-9][0-9]{0,18}$/u.test(value) || BigInt(value) > 9223372036854775807n) {\n throw new Error(`Invalid ${name}`);\n }\n}\n\nexport function validateHistory(history: unknown, columns: number, rows: number): asserts history is HistoryMetadata | null {\n if (history === null) return;\n if (!isRecord(history)) throw new Error(\"Missing history metadata\");\n rowId(history.generation, \"history generation\");\n if (history.buffer !== \"main\" && history.buffer !== \"alternate\") throw new Error(\"Invalid history buffer\");\n const totalRows = integer(history.totalRows, \"history total rows\", rows, 2147483647);\n const liveTop = integer(history.liveTop, \"history live top\", 0, totalRows - rows);\n if (liveTop !== totalRows - rows) throw new Error(\"Inconsistent history extent\");\n integer(history.top, \"history top\", 0, liveTop);\n if (typeof history.following !== \"boolean\" || (history.following && history.top !== history.liveTop)) {\n throw new Error(\"Invalid history following state\");\n }\n integer(history.requestId, \"viewport request id\");\n array(history.rowIds, \"viewport row ids\", rows);\n if (history.rowIds.length !== rows || new Set(history.rowIds).size !== rows) throw new Error(\"Invalid viewport row ids\");\n for (const id of history.rowIds) rowId(id, \"viewport row id\");\n const selection = history.selection;\n if (!isRecord(selection) || typeof selection.status !== \"string\" ||\n ![\"none\", \"valid\", \"invalidated\"].includes(selection.status)) throw new Error(\"Invalid selection status\");\n integer(selection.requestId, \"selection request id\");\n if (typeof selection.mode !== \"string\" || ![\"character\", \"word\", \"line\", \"rectangle\"].includes(selection.mode)) throw new Error(\"Invalid selection mode\");\n array(selection.ranges, \"selection ranges\", rows);\n let previousRow = -1;\n for (const range of selection.ranges) {\n if (!isRecord(range)) throw new Error(\"Invalid selection range\");\n const row = integer(range.row, \"selection range row\", previousRow + 1, rows - 1);\n const startColumn = integer(range.startColumn, \"selection start column\", 0, columns - 1);\n integer(range.endColumn, \"selection end column\", startColumn + 1, columns);\n previousRow = row;\n }\n validateSelectionText(selection);\n if (selection.status !== \"valid\" && selection.ranges.length) throw new Error(\"Inactive selection has highlight ranges\");\n if (history.copy !== null) {\n if (!isRecord(history.copy)) throw new Error(\"Missing copy metadata\");\n integer(history.copy.requestId, \"copy request id\", 1);\n validateSelectionText(history.copy);\n }\n}\n\nfunction validateSelectionText(selection: Record): asserts selection is Record & SelectionText {\n if (typeof selection.status !== \"string\" || ![\"none\", \"valid\", \"invalidated\"].includes(selection.status) ||\n (selection.status === \"valid\"\n ? typeof selection.text !== \"string\" || selection.text.length > LIMITS.metadataBytes\n : selection.text !== null)) {\n throw new Error(\"Invalid selection text\");\n }\n}\n\nfunction validateMetadata(metadata: unknown): asserts metadata is FrameMetadata {\n if (!isRecord(metadata) || metadata.version !== 1 || typeof metadata.full !== \"boolean\") {\n throw new Error(\"Unsupported frame metadata version\");\n }\n integer(metadata.revision, \"revision\", 1);\n integer(metadata.baseRevision, \"base revision\");\n if (typeof metadata.title !== \"string\" || metadata.title.length > LIMITS.titleUnits ||\n /[\\u0000-\\u001f\\u007f-\\u009f\\ud800-\\udfff]/u.test(metadata.title)) {\n throw new Error(\"Invalid terminal title\");\n }\n const progress = metadata.progress;\n if (!isRecord(progress) || typeof progress.state !== \"string\" ||\n ![\"none\", \"normal\", \"error\", \"indeterminate\", \"warning\"].includes(progress.state)) {\n throw new Error(\"Invalid terminal progress\");\n }\n if (progress.state === \"none\" || progress.state === \"indeterminate\") {\n if (progress.percentage !== null) throw new Error(\"Unexpected terminal progress percentage\");\n } else {\n integer(progress.percentage, \"terminal progress percentage\", 0, 100);\n }\n const shell = metadata.shellIntegration;\n if (!isRecord(shell) || typeof shell.phase !== \"string\" ||\n ![\"unknown\", \"prompt\", \"commandLine\", \"executing\", \"finished\"].includes(shell.phase)) {\n throw new Error(\"Invalid terminal shell integration\");\n }\n if (shell.lastExitCode !== null)\n integer(shell.lastExitCode, \"terminal shell exit code\", -2147483648, 2147483647);\n if (shell.phase === \"unknown\" && shell.lastExitCode !== null)\n throw new Error(\"Unknown terminal shell phase has an exit code\");\n const columns = integer(metadata.columns, \"columns\", 1, 1024);\n const rows = integer(metadata.rows, \"rows\", 1, 512);\n if (typeof metadata.mouseTracking !== \"number\" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) {\n throw new Error(\"Unsupported mouse tracking mode\");\n }\n if (!isRecord(metadata.peer) || typeof metadata.peer.isPrimary !== \"boolean\") throw new Error(\"Invalid peer state\");\n for (const field of [\"id\", \"primaryId\"]) {\n const id = metadata.peer[field];\n if (id !== null && (typeof id !== \"string\" || !id.length || id.length > 256)) {\n throw new Error(`Invalid peer ${field}`);\n }\n }\n if (metadata.peer.id !== null && metadata.peer.isPrimary !== (metadata.peer.id === metadata.peer.primaryId)) {\n throw new Error(\"Inconsistent primary peer state\");\n }\n integer(columns * rows, \"cell count\", 1, LIMITS.cells);\n validateHistory(metadata.history, columns, rows);\n array(metadata.hyperlinks, \"hyperlinks\", columns * rows);\n let previousLinkEnd = 0;\n for (const link of metadata.hyperlinks) {\n if (!isRecord(link)) throw new Error(\"Invalid hyperlink\");\n const row = integer(link.row, \"hyperlink row\", 0, rows - 1);\n const start = integer(link.startColumn, \"hyperlink start column\", 0, columns - 1);\n const end = integer(link.endColumn, \"hyperlink end column\", start + 1, columns);\n if (row * columns + start < previousLinkEnd) throw new Error(\"Unordered or overlapping hyperlinks\");\n previousLinkEnd = row * columns + end;\n if (typeof link.uri !== \"string\" || !link.uri.length || link.uri.length > LIMITS.metadataBytes)\n throw new Error(\"Invalid hyperlink URI\");\n }\n if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) {\n throw new Error(\"This spike requires server geometry of 10 × 20 logical pixels\");\n }\n for (const field of [\"defaultBackground\", \"defaultForeground\"]) {\n if (metadata[field] !== undefined) integer(metadata[field], field, 0, 0xffffffff);\n }\n if (!isRecord(metadata.cursor) || typeof metadata.cursor.visible !== \"boolean\") throw new Error(\"Invalid cursor\");\n integer(metadata.cursor.x, \"cursor x\", -1, 1024);\n integer(metadata.cursor.y, \"cursor y\", -1, 512);\n const shapes = [\"Default\", \"BlinkingBlock\", \"SteadyBlock\", \"BlinkingUnderline\", \"SteadyUnderline\", \"BlinkingBar\", \"SteadyBar\"];\n if (typeof metadata.cursor.shape === \"string\") metadata.cursor.shape = shapes.indexOf(metadata.cursor.shape);\n integer(metadata.cursor.shape, \"cursor shape\", 0, 6);\n array(metadata.images, \"images\", LIMITS.images);\n array(metadata.retainedImages, \"retained image keys\", LIMITS.images);\n array(metadata.placements, \"placements\", LIMITS.placements);\n array(metadata.warnings, \"warnings\", 256);\n if (metadata.warnings.some(w => typeof w !== \"string\")) throw new Error(\"Invalid warning\");\n if (!isRecord(metadata.stats)) throw new Error(\"Invalid server metrics\");\n for (const field of [\"workloadBytes\", \"outputBatches\", \"captureMs\", \"elapsedMs\"]) {\n const metric = metadata.stats[field];\n if (typeof metric !== \"number\" || !Number.isFinite(metric) || metric < 0) {\n throw new Error(`Invalid server metric ${field}`);\n }\n }\n const retained = new Set();\n for (const imageKey of metadata.retainedImages) {\n key(imageKey);\n if (retained.has(imageKey)) throw new Error(\"Duplicate retained image key\");\n retained.add(imageKey);\n }\n const imageKeys = new Set();\n let decodedImageBytes = 0;\n for (const image of metadata.images) {\n if (!isRecord(image)) throw new Error(\"Invalid image\");\n key(image.key);\n if (imageKeys.has(image.key) || !retained.has(image.key)) throw new Error(\"Inconsistent new image keys\");\n imageKeys.add(image.key);\n const width = integer(image.width, \"image width\", 1, 16384);\n const height = integer(image.height, \"image height\", 1, 16384);\n const byteLength = integer(image.byteLength, \"image byte length\", 1, LIMITS.frameBytes);\n if (image.format !== \"rgba\" && image.format !== \"png\") throw new Error(\"Unsupported image format\");\n if (image.format === \"rgba\" && byteLength !== width * height * 4) {\n throw new Error(\"RGBA image size mismatch\");\n }\n decodedImageBytes += width * height * 4;\n if (decodedImageBytes > LIMITS.textureBytes) throw new Error(\"New images exceed decoded texture budget\");\n }\n for (const placement of metadata.placements) {\n if (!isRecord(placement)) throw new Error(\"Invalid placement\");\n key(placement.key);\n if (!retained.has(placement.key)) throw new Error(\"Placement references an unretained image\");\n if (placement.kind !== \"kgp\" && placement.kind !== \"sixel\") throw new Error(\"Invalid placement kind\");\n for (const field of [\"x\", \"y\", \"width\", \"height\", \"sourceX\", \"sourceY\", \"sourceWidth\", \"sourceHeight\", \"clipX\", \"clipY\", \"clipWidth\", \"clipHeight\", \"z\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || !Number.isFinite(coordinate) || Math.abs(coordinate) > 2147483648) {\n throw new Error(`Invalid placement ${field}`);\n }\n }\n for (const field of [\"width\", \"height\", \"sourceWidth\", \"sourceHeight\", \"clipWidth\", \"clipHeight\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || coordinate < 0) throw new Error(`Negative placement ${field}`);\n }\n }\n}\n\n/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */\nexport function decodeFrame(buffer: unknown): TerminalFrame {\n if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.frameBytes) {\n throw new Error(\"Invalid or oversized binary frame\");\n }\n const view = new DataView(buffer);\n let offset = 0;\n const requireBytes = (count: number) => {\n if (count < 0 || count > view.byteLength - offset) throw new Error(\"Truncated HWT1 frame\");\n };\n const u32 = () => { requireBytes(4); const n = view.getUint32(offset, true); offset += 4; return n; };\n if (u32() !== 0x31545748) throw new Error(\"Unsupported frame magic (expected HWT1)\");\n const metadataLength = integer(u32(), \"metadata length\", 2, LIMITS.metadataBytes);\n requireBytes(metadataLength);\n const metadata: unknown = JSON.parse(utf8.decode(new Uint8Array(buffer, offset, metadataLength)));\n offset += metadataLength;\n validateMetadata(metadata);\n const cellCount = metadata.columns * metadata.rows;\n const changedCount = integer(u32(), \"changed cell count\", 0, cellCount);\n if (changedCount > Math.floor((view.byteLength - offset) / 22)) throw new Error(\"Truncated cell records\");\n if (metadata.full && changedCount !== cellCount) throw new Error(\"Incomplete full frame\");\n const cells = [];\n const seen = new Set();\n for (let i = 0; i < changedCount; i++) {\n requireBytes(22);\n const index = u32();\n if (index >= cellCount || seen.has(index)) throw new Error(\"Invalid or duplicate cell index\");\n seen.add(index);\n const foreground = u32();\n const background = u32();\n const underlineColor = u32();\n const attributes = view.getUint16(offset, true);\n const width = view.getUint8(offset + 2);\n const underlineStyle = view.getUint8(offset + 3);\n const textLength = view.getUint16(offset + 4, true);\n offset += 6;\n if (underlineStyle > 5) throw new Error(\"Unsupported underline style\");\n requireBytes(textLength);\n const text = utf8.decode(new Uint8Array(buffer, offset, textLength));\n offset += textLength;\n cells.push({ index, foreground, background, underlineColor, attributes, width, underlineStyle, text });\n }\n const imageBytes = metadata.images.reduce((total, image) => total + image.byteLength, 0);\n if (imageBytes !== view.byteLength - offset) throw new Error(\"Image payload length mismatch\");\n const images = metadata.images.map(image => {\n const bytes = new Uint8Array(buffer, offset, image.byteLength);\n offset += image.byteLength;\n return { ...image, bytes };\n });\n return { metadata, cells, images };\n}\n\n/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */\nexport function screenText(cells: readonly (TerminalCell | undefined)[], columns: number, rows: number): string {\n const lines = [];\n for (let y = 0; y < rows; y++) {\n let line = \"\";\n for (let x = 0; x < columns; x++) {\n const cell = cells[y * columns + x];\n if (!cell || cell.width === 0) continue;\n line += cell.attributes & 64 ? \" \".repeat(cell.width) : (cell.text || \" \");\n }\n lines.push(line.replace(/ +$/u, \"\"));\n }\n return lines.join(\"\\n\");\n}\n"]} \ No newline at end of file +{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FrameMetadata, HistoryMetadata, SelectionText, TerminalCell, TerminalFrame } from \"./wire-types.js\";\nimport { isRecord } from \"./validation.js\";\n\n// Binary validation is deliberately independent of the GPU and the transport.\nexport const LIMITS = Object.freeze({\n commandBytes: 64 * 1024,\n frameBytes: 96 * 1024 * 1024,\n metadataBytes: 8 * 1024 * 1024,\n titleUnits: 4096,\n commandMarkParameterUnits: 8192,\n cells: 262144,\n images: 4096,\n placements: 16384,\n textureBytes: 256 * 1024 * 1024,\n});\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\nconst utf8Encoder = new TextEncoder();\n\nexport function assertCommandSize(command: unknown): void {\n if (utf8Encoder.encode(JSON.stringify(command)).byteLength > LIMITS.commandBytes)\n throw new RangeError(\"Terminal input exceeds the 64 KiB message limit. Send a smaller amount of text.\");\n}\n\nfunction integer(value: unknown, name: string, min = 0, max = Number.MAX_SAFE_INTEGER): number {\n if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max) {\n throw new Error(`Invalid ${name}: ${String(value)}`);\n }\n return value;\n}\n\nfunction array(value: unknown, name: string, limit: number): asserts value is unknown[] {\n if (!Array.isArray(value) || value.length > limit) throw new Error(`Invalid ${name}`);\n}\n\nfunction key(value: unknown): asserts value is string {\n if (typeof value !== \"string\" || !value.length || value.length > 1024) {\n throw new Error(\"Invalid image key\");\n }\n}\n\nfunction rowId(value: unknown, name: string): asserts value is string {\n if (typeof value !== \"string\" || !/^[1-9][0-9]{0,18}$/u.test(value) || BigInt(value) > 9223372036854775807n) {\n throw new Error(`Invalid ${name}`);\n }\n}\n\nexport function validateHistory(history: unknown, columns: number, rows: number): asserts history is HistoryMetadata | null {\n if (history === null) return;\n if (!isRecord(history)) throw new Error(\"Missing history metadata\");\n rowId(history.generation, \"history generation\");\n if (history.buffer !== \"main\" && history.buffer !== \"alternate\") throw new Error(\"Invalid history buffer\");\n const totalRows = integer(history.totalRows, \"history total rows\", rows, 2147483647);\n const liveTop = integer(history.liveTop, \"history live top\", 0, totalRows - rows);\n if (liveTop !== totalRows - rows) throw new Error(\"Inconsistent history extent\");\n integer(history.top, \"history top\", 0, liveTop);\n if (typeof history.following !== \"boolean\" || (history.following && history.top !== history.liveTop)) {\n throw new Error(\"Invalid history following state\");\n }\n integer(history.requestId, \"viewport request id\");\n array(history.rowIds, \"viewport row ids\", rows);\n if (history.rowIds.length !== rows || new Set(history.rowIds).size !== rows) throw new Error(\"Invalid viewport row ids\");\n for (const id of history.rowIds) rowId(id, \"viewport row id\");\n const selection = history.selection;\n if (!isRecord(selection) || typeof selection.status !== \"string\" ||\n ![\"none\", \"valid\", \"invalidated\"].includes(selection.status)) throw new Error(\"Invalid selection status\");\n integer(selection.requestId, \"selection request id\");\n if (typeof selection.mode !== \"string\" || ![\"character\", \"word\", \"line\", \"rectangle\"].includes(selection.mode)) throw new Error(\"Invalid selection mode\");\n array(selection.ranges, \"selection ranges\", rows);\n let previousRow = -1;\n for (const range of selection.ranges) {\n if (!isRecord(range)) throw new Error(\"Invalid selection range\");\n const row = integer(range.row, \"selection range row\", previousRow + 1, rows - 1);\n const startColumn = integer(range.startColumn, \"selection start column\", 0, columns - 1);\n integer(range.endColumn, \"selection end column\", startColumn + 1, columns);\n previousRow = row;\n }\n validateSelectionText(selection);\n if (selection.status !== \"valid\" && selection.ranges.length) throw new Error(\"Inactive selection has highlight ranges\");\n if (history.copy !== null) {\n if (!isRecord(history.copy)) throw new Error(\"Missing copy metadata\");\n integer(history.copy.requestId, \"copy request id\", 1);\n validateSelectionText(history.copy);\n }\n}\n\nfunction validateSelectionText(selection: Record): asserts selection is Record & SelectionText {\n if (typeof selection.status !== \"string\" || ![\"none\", \"valid\", \"invalidated\"].includes(selection.status) ||\n (selection.status === \"valid\"\n ? typeof selection.text !== \"string\" || selection.text.length > LIMITS.metadataBytes\n : selection.text !== null)) {\n throw new Error(\"Invalid selection text\");\n }\n}\n\nfunction validateMetadata(metadata: unknown): asserts metadata is FrameMetadata {\n if (!isRecord(metadata) || metadata.version !== 1 || typeof metadata.full !== \"boolean\") {\n throw new Error(\"Unsupported frame metadata version\");\n }\n integer(metadata.revision, \"revision\", 1);\n integer(metadata.baseRevision, \"base revision\");\n if (typeof metadata.title !== \"string\" || metadata.title.length > LIMITS.titleUnits ||\n /[\\u0000-\\u001f\\u007f-\\u009f\\ud800-\\udfff]/u.test(metadata.title)) {\n throw new Error(\"Invalid terminal title\");\n }\n const progress = metadata.progress;\n if (!isRecord(progress) || typeof progress.state !== \"string\" ||\n ![\"none\", \"normal\", \"error\", \"indeterminate\", \"warning\"].includes(progress.state)) {\n throw new Error(\"Invalid terminal progress\");\n }\n if (progress.state === \"none\" || progress.state === \"indeterminate\") {\n if (progress.percentage !== null) throw new Error(\"Unexpected terminal progress percentage\");\n } else {\n integer(progress.percentage, \"terminal progress percentage\", 0, 100);\n }\n const shell = metadata.shellIntegration;\n if (!isRecord(shell) || typeof shell.phase !== \"string\" ||\n ![\"unknown\", \"prompt\", \"commandLine\", \"executing\", \"finished\"].includes(shell.phase)) {\n throw new Error(\"Invalid terminal shell integration\");\n }\n if (shell.lastExitCode !== null)\n integer(shell.lastExitCode, \"terminal shell exit code\", -2147483648, 2147483647);\n if (shell.phase === \"unknown\" && shell.lastExitCode !== null)\n throw new Error(\"Unknown terminal shell phase has an exit code\");\n const workingDirectory = metadata.workingDirectory;\n if (!isRecord(workingDirectory)) throw new Error(\"Invalid terminal working directory\");\n const wdFields = [workingDirectory.uri, workingDirectory.host, workingDirectory.path];\n if (wdFields.every(field => field === null)) {\n // No directory reported yet.\n } else if (wdFields.some(field => typeof field !== \"string\" || field.length > LIMITS.metadataBytes)) {\n throw new Error(\"Invalid terminal working directory\");\n }\n const commandMark = metadata.commandMark;\n if (commandMark !== null) {\n if (!isRecord(commandMark) || typeof commandMark.phase !== \"string\" ||\n ![\"unknown\", \"prompt\", \"commandLine\", \"executing\", \"finished\"].includes(commandMark.phase)) {\n throw new Error(\"Invalid terminal command mark\");\n }\n if (commandMark.exitCode !== null) {\n integer(commandMark.exitCode, \"terminal command mark exit code\", -2147483648, 2147483647);\n if (commandMark.phase !== \"finished\") throw new Error(\"Non-finished terminal command mark has an exit code\");\n }\n if (commandMark.rawParameters !== null &&\n (typeof commandMark.rawParameters !== \"string\" || commandMark.rawParameters.length > LIMITS.commandMarkParameterUnits)) {\n throw new Error(\"Invalid terminal command mark parameters\");\n }\n }\n const columns = integer(metadata.columns, \"columns\", 1, 1024);\n const rows = integer(metadata.rows, \"rows\", 1, 512);\n if (typeof metadata.mouseTracking !== \"number\" || ![0, 9, 1000, 1002, 1003].includes(metadata.mouseTracking)) {\n throw new Error(\"Unsupported mouse tracking mode\");\n }\n if (!isRecord(metadata.peer) || typeof metadata.peer.isPrimary !== \"boolean\") throw new Error(\"Invalid peer state\");\n for (const field of [\"id\", \"primaryId\"]) {\n const id = metadata.peer[field];\n if (id !== null && (typeof id !== \"string\" || !id.length || id.length > 256)) {\n throw new Error(`Invalid peer ${field}`);\n }\n }\n if (metadata.peer.id !== null && metadata.peer.isPrimary !== (metadata.peer.id === metadata.peer.primaryId)) {\n throw new Error(\"Inconsistent primary peer state\");\n }\n integer(columns * rows, \"cell count\", 1, LIMITS.cells);\n validateHistory(metadata.history, columns, rows);\n array(metadata.hyperlinks, \"hyperlinks\", columns * rows);\n let previousLinkEnd = 0;\n for (const link of metadata.hyperlinks) {\n if (!isRecord(link)) throw new Error(\"Invalid hyperlink\");\n const row = integer(link.row, \"hyperlink row\", 0, rows - 1);\n const start = integer(link.startColumn, \"hyperlink start column\", 0, columns - 1);\n const end = integer(link.endColumn, \"hyperlink end column\", start + 1, columns);\n if (row * columns + start < previousLinkEnd) throw new Error(\"Unordered or overlapping hyperlinks\");\n previousLinkEnd = row * columns + end;\n if (typeof link.uri !== \"string\" || !link.uri.length || link.uri.length > LIMITS.metadataBytes)\n throw new Error(\"Invalid hyperlink URI\");\n }\n if (metadata.cellWidth !== 10 || metadata.cellHeight !== 20) {\n throw new Error(\"This spike requires server geometry of 10 × 20 logical pixels\");\n }\n for (const field of [\"defaultBackground\", \"defaultForeground\"]) {\n if (metadata[field] !== undefined) integer(metadata[field], field, 0, 0xffffffff);\n }\n if (!isRecord(metadata.cursor) || typeof metadata.cursor.visible !== \"boolean\") throw new Error(\"Invalid cursor\");\n integer(metadata.cursor.x, \"cursor x\", -1, 1024);\n integer(metadata.cursor.y, \"cursor y\", -1, 512);\n const shapes = [\"Default\", \"BlinkingBlock\", \"SteadyBlock\", \"BlinkingUnderline\", \"SteadyUnderline\", \"BlinkingBar\", \"SteadyBar\"];\n if (typeof metadata.cursor.shape === \"string\") metadata.cursor.shape = shapes.indexOf(metadata.cursor.shape);\n integer(metadata.cursor.shape, \"cursor shape\", 0, 6);\n array(metadata.images, \"images\", LIMITS.images);\n array(metadata.retainedImages, \"retained image keys\", LIMITS.images);\n array(metadata.placements, \"placements\", LIMITS.placements);\n array(metadata.warnings, \"warnings\", 256);\n if (metadata.warnings.some(w => typeof w !== \"string\")) throw new Error(\"Invalid warning\");\n if (!isRecord(metadata.stats)) throw new Error(\"Invalid server metrics\");\n for (const field of [\"workloadBytes\", \"outputBatches\", \"captureMs\", \"elapsedMs\"]) {\n const metric = metadata.stats[field];\n if (typeof metric !== \"number\" || !Number.isFinite(metric) || metric < 0) {\n throw new Error(`Invalid server metric ${field}`);\n }\n }\n const retained = new Set();\n for (const imageKey of metadata.retainedImages) {\n key(imageKey);\n if (retained.has(imageKey)) throw new Error(\"Duplicate retained image key\");\n retained.add(imageKey);\n }\n const imageKeys = new Set();\n let decodedImageBytes = 0;\n for (const image of metadata.images) {\n if (!isRecord(image)) throw new Error(\"Invalid image\");\n key(image.key);\n if (imageKeys.has(image.key) || !retained.has(image.key)) throw new Error(\"Inconsistent new image keys\");\n imageKeys.add(image.key);\n const width = integer(image.width, \"image width\", 1, 16384);\n const height = integer(image.height, \"image height\", 1, 16384);\n const byteLength = integer(image.byteLength, \"image byte length\", 1, LIMITS.frameBytes);\n if (image.format !== \"rgba\" && image.format !== \"png\") throw new Error(\"Unsupported image format\");\n if (image.format === \"rgba\" && byteLength !== width * height * 4) {\n throw new Error(\"RGBA image size mismatch\");\n }\n decodedImageBytes += width * height * 4;\n if (decodedImageBytes > LIMITS.textureBytes) throw new Error(\"New images exceed decoded texture budget\");\n }\n for (const placement of metadata.placements) {\n if (!isRecord(placement)) throw new Error(\"Invalid placement\");\n key(placement.key);\n if (!retained.has(placement.key)) throw new Error(\"Placement references an unretained image\");\n if (placement.kind !== \"kgp\" && placement.kind !== \"sixel\") throw new Error(\"Invalid placement kind\");\n for (const field of [\"x\", \"y\", \"width\", \"height\", \"sourceX\", \"sourceY\", \"sourceWidth\", \"sourceHeight\", \"clipX\", \"clipY\", \"clipWidth\", \"clipHeight\", \"z\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || !Number.isFinite(coordinate) || Math.abs(coordinate) > 2147483648) {\n throw new Error(`Invalid placement ${field}`);\n }\n }\n for (const field of [\"width\", \"height\", \"sourceWidth\", \"sourceHeight\", \"clipWidth\", \"clipHeight\"]) {\n const coordinate = placement[field];\n if (typeof coordinate !== \"number\" || coordinate < 0) throw new Error(`Negative placement ${field}`);\n }\n }\n}\n\n/** Decode one complete, experimental HWT1 message, checking every boundary before reading. */\nexport function decodeFrame(buffer: unknown): TerminalFrame {\n if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > LIMITS.frameBytes) {\n throw new Error(\"Invalid or oversized binary frame\");\n }\n const view = new DataView(buffer);\n let offset = 0;\n const requireBytes = (count: number) => {\n if (count < 0 || count > view.byteLength - offset) throw new Error(\"Truncated HWT1 frame\");\n };\n const u32 = () => { requireBytes(4); const n = view.getUint32(offset, true); offset += 4; return n; };\n if (u32() !== 0x31545748) throw new Error(\"Unsupported frame magic (expected HWT1)\");\n const metadataLength = integer(u32(), \"metadata length\", 2, LIMITS.metadataBytes);\n requireBytes(metadataLength);\n const metadata: unknown = JSON.parse(utf8.decode(new Uint8Array(buffer, offset, metadataLength)));\n offset += metadataLength;\n validateMetadata(metadata);\n const cellCount = metadata.columns * metadata.rows;\n const changedCount = integer(u32(), \"changed cell count\", 0, cellCount);\n if (changedCount > Math.floor((view.byteLength - offset) / 22)) throw new Error(\"Truncated cell records\");\n if (metadata.full && changedCount !== cellCount) throw new Error(\"Incomplete full frame\");\n const cells = [];\n const seen = new Set();\n for (let i = 0; i < changedCount; i++) {\n requireBytes(22);\n const index = u32();\n if (index >= cellCount || seen.has(index)) throw new Error(\"Invalid or duplicate cell index\");\n seen.add(index);\n const foreground = u32();\n const background = u32();\n const underlineColor = u32();\n const attributes = view.getUint16(offset, true);\n const width = view.getUint8(offset + 2);\n const underlineStyle = view.getUint8(offset + 3);\n const textLength = view.getUint16(offset + 4, true);\n offset += 6;\n if (underlineStyle > 5) throw new Error(\"Unsupported underline style\");\n requireBytes(textLength);\n const text = utf8.decode(new Uint8Array(buffer, offset, textLength));\n offset += textLength;\n cells.push({ index, foreground, background, underlineColor, attributes, width, underlineStyle, text });\n }\n const imageBytes = metadata.images.reduce((total, image) => total + image.byteLength, 0);\n if (imageBytes !== view.byteLength - offset) throw new Error(\"Image payload length mismatch\");\n const images = metadata.images.map(image => {\n const bytes = new Uint8Array(buffer, offset, image.byteLength);\n offset += image.byteLength;\n return { ...image, bytes };\n });\n return { metadata, cells, images };\n}\n\n/** Mirror text, not ANSI; continuation cells are already represented by their lead glyph. */\nexport function screenText(cells: readonly (TerminalCell | undefined)[], columns: number, rows: number): string {\n const lines = [];\n for (let y = 0; y < rows; y++) {\n let line = \"\";\n for (let x = 0; x < columns; x++) {\n const cell = cells[y * columns + x];\n if (!cell || cell.width === 0) continue;\n line += cell.attributes & 64 ? \" \".repeat(cell.width) : (cell.text || \" \");\n }\n lines.push(line.replace(/ +$/u, \"\"));\n }\n return lines.join(\"\\n\");\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map index 3af134dc66f..e829f798942 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js index 8fab657a032..e5b2b9569a3 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js @@ -20,6 +20,11 @@ function rgba(packed) { function glyphKey(cell) { return `${cell.attributes & 5}/${cell.width}/${cell.text}`; } +function isKgpPlaceholder(cell) { + // The base scalar and following diacritics encode an image reference, not a glyph. + // Keep the authoritative text and colors intact even when no image is placed. + return cell.text.codePointAt(0) === 0x10eeee; +} /** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */ function packGlyphs(glyphs, size, scale, initial = { x: 0, y: 0, rowHeight: 0 }) { let { x, y, rowHeight } = initial; @@ -210,7 +215,7 @@ export class TerminalRenderer { prepareGlyphs(cells) { const visible = new Map(); for (const cell of cells) { - if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64)) + if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64) || isKgpPlaceholder(cell)) continue; visible.set(glyphKey(cell), cell); } @@ -387,7 +392,7 @@ export class TerminalRenderer { const y = Math.floor(i / this.columns) * CELL_HEIGHT; const width = Math.min(cell.width * CELL_WIDTH, this.width - x); const foreground = rgba(cell.foreground); - const glyph = this.glyphs.get(glyphKey(cell)); + const glyph = isKgpPlaceholder(cell) ? undefined : this.glyphs.get(glyphKey(cell)); if (glyph) { const tint = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground; this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT, tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]); @@ -448,7 +453,13 @@ export class TerminalRenderer { for (const image of this.images.values()) image.destroy(); this.images.clear(); + this.textureBytes = 0; this.atlas?.destroy(); + this.glyphs.clear(); + this.glyphKeyUnits = 0; + this.batches = []; + this.quadCount = 0; + this.instances = new Float32Array(0); this.font?.dispose(); this.fontMetrics.clear(); this.backend.dispose(); diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map index 6106163c237..886bb77d7d5 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map @@ -1 +1 @@ -{"version":3,"file":"renderer.js","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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","sourcesContent":["import { LIMITS } from \"./protocol.js\";\nimport { loadFont, measureFont, normalizeFont } from \"./terminal-font.js\";\nimport { createRenderBackend } from \"./backend-selection.js\";\nimport { QUAD_STRIDE } from \"./render-backend.js\";\nimport type { RenderBackend, RenderBatch, RenderColor, RenderTexture } from \"./render-backend.js\";\nimport type { FontMetrics, LoadedFont, NormalizedFont } from \"./terminal-font.js\";\nimport type { TerminalFont, TerminalRendererPreference, TerminalSize } from \"./types.js\";\nimport type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from \"./wire-types.js\";\n\ntype Vector4 = RenderColor;\ntype TextureResource = RenderTexture;\ninterface Shelf { x: number; y: number; rowHeight: number }\ninterface GlyphPlacement { key: string; cell: TerminalCell; x: number; y: number; width: number; height: number }\ninterface Glyph { colored: boolean; u0: number; v0: number; u1: number; v1: number }\ntype Batch = RenderBatch;\n\nconst CELL_WIDTH = 10;\nconst CELL_HEIGHT = 20;\nconst MAX_QUADS = 1024 * 1024;\nconst MAX_GLYPHS = 16384;\nconst MAX_GLYPH_KEY_UNITS = 1024 * 1024;\nconst STRIDE = QUAD_STRIDE;\nconst WHITE: Vector4 = [1, 1, 1, 1];\n\nfunction rgba(packed: number): Vector4 {\n return [\n (packed & 255) / 255,\n ((packed >>> 8) & 255) / 255,\n ((packed >>> 16) & 255) / 255,\n ((packed >>> 24) & 255) / 255,\n ];\n}\n\nfunction glyphKey(cell: TerminalCell): string {\n return `${cell.attributes & 5}/${cell.width}/${cell.text}`;\n}\n\n/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */\nfunction packGlyphs(glyphs: ReadonlyMap, size: number, scale: number,\n initial: Shelf = { x: 0, y: 0, rowHeight: 0 }): { placements: GlyphPlacement[]; shelf: Shelf } | null {\n let { x, y, rowHeight } = initial;\n const placements = [];\n for (const [key, cell] of glyphs) {\n const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4;\n const height = Math.ceil(CELL_HEIGHT * scale) + 4;\n if (width > size || height > size) return null;\n if (x + width > size) { x = 0; y += rowHeight; rowHeight = 0; }\n if (y + height > size) return null;\n placements.push({ key, cell, x, y, width, height });\n x += width;\n rowHeight = Math.max(rowHeight, height);\n }\n return { placements, shelf: { x, y, rowHeight } };\n}\n\n/** Shared instanced-quad preparation; Canvas2D rasterizes reusable glyphs for either backend. */\nexport class TerminalRenderer {\n canvas: OffscreenCanvas;\n scale: number;\n backingScale: number;\n backend: RenderBackend;\n fallbackReason?: string;\n fontConfiguration: NormalizedFont;\n fontMetrics: Map;\n images: Map;\n glyphs: Map;\n imageUploadBytes: number;\n imagePayloadBytes: number;\n glyphUploadBytes: number;\n atlasRebuilds: number;\n textureBytes: number;\n instances: Float32Array;\n disposed: boolean;\n columns: number;\n rows: number;\n // Initialized by create() before the renderer can prepare or submit frames.\n font!: LoadedFont;\n rasterCanvas!: OffscreenCanvas;\n raster!: OffscreenCanvasRenderingContext2D;\n atlas!: TextureResource;\n glyphKeyUnits = 0;\n shelf: Shelf = { x: 0, y: 0, rowHeight: 0 };\n canvasLimited = false;\n width = 0;\n height = 0;\n quadCount = 0;\n batches: Batch[] = [];\n\n static async create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error) => void,\n font?: TerminalFont, preference: TerminalRendererPreference = \"auto\"): Promise {\n const normalizedFont = normalizeFont(font);\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n const { backend, fallbackReason } = await createRenderBackend(canvas, onFatal, preference);\n const renderer = new TerminalRenderer(canvas, scale, backend, normalizedFont);\n renderer.fallbackReason = fallbackReason;\n try {\n await renderer.initialize();\n return renderer;\n } catch (error) {\n renderer.dispose();\n throw error;\n }\n }\n\n constructor(canvas: OffscreenCanvas, scale: number, backend: RenderBackend, font: NormalizedFont) {\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n this.canvas = canvas;\n this.scale = scale;\n this.backingScale = scale;\n this.backend = backend;\n this.fontConfiguration = font;\n this.fontMetrics = new Map();\n this.images = new Map();\n this.glyphs = new Map();\n this.imageUploadBytes = 0;\n this.imagePayloadBytes = 0;\n this.glyphUploadBytes = 0;\n this.atlasRebuilds = 0;\n this.textureBytes = 0;\n this.instances = new Float32Array(4096 * STRIDE);\n this.disposed = false;\n this.columns = 0;\n this.rows = 0;\n }\n\n async initialize() {\n this.font = await loadFont(this.fontConfiguration);\n this.rasterCanvas = new OffscreenCanvas(1, 1);\n const raster = this.rasterCanvas.getContext(\"2d\", { willReadFrequently: true });\n if (!raster) throw new Error(\"Worker glyph rasterization is unavailable\");\n this.raster = raster;\n this.resetAtlas(Math.min(2048, this.backend.maxTextureDimension2D));\n }\n\n createTexture(width: number, height: number, label: string): TextureResource {\n return this.backend.createTexture(width, height, label);\n }\n\n resetAtlas(size: number): void {\n this.atlas?.destroy();\n this.atlas = this.createTexture(size, size, \"Glyph atlas\");\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.shelf = { x: 0, y: 0, rowHeight: 0 };\n }\n\n resize(columns: number, rows: number, viewport?: TerminalSize): void {\n const width = columns * CELL_WIDTH;\n const height = rows * CELL_HEIGHT;\n const limit = this.backend.maxCanvasDimension2D;\n const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity);\n this.canvasLimited = width * requested > limit || height * requested > limit;\n this.backingScale = Math.min(requested, limit / width, limit / height);\n const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale)));\n const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale)));\n if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight) return;\n this.columns = columns;\n this.rows = rows;\n this.width = width;\n this.height = height;\n this.canvas.width = backingWidth;\n this.canvas.height = backingHeight;\n this.backend.resize(width, height);\n }\n\n /** Call only between submissions. Missing/over-budget resources terminate the session. */\n async updateImages(incoming: readonly FrameImage[], retainedKeys: readonly string[]): Promise {\n const retained = new Set(retainedKeys);\n const replacements = new Map(incoming.map(image => [image.key, image]));\n let projectedBytes = 0;\n for (const key of retained) {\n const image = replacements.get(key) || this.images.get(key);\n if (!image) throw new Error(`Missing retained image resource: ${key}`);\n projectedBytes += image.width * image.height * 4;\n }\n if (projectedBytes > LIMITS.textureBytes) throw new Error(\"Retained images exceed the 256 MiB texture budget\");\n for (const [key, image] of this.images) {\n if (!retained.has(key) || replacements.has(key)) {\n image.destroy();\n this.textureBytes -= image.width * image.height * 4;\n this.images.delete(key);\n }\n }\n for (const image of incoming) {\n const resource = this.createTexture(image.width, image.height, `Image ${image.key}`);\n try {\n if (image.format === \"rgba\") {\n resource.writePixels(image.bytes, image.width, image.height);\n } else {\n // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions.\n const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength);\n if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a ||\n png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 ||\n png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) {\n throw new Error(`PNG header dimensions do not match resource ${image.key}`);\n }\n const bitmap = await createImageBitmap(new Blob([image.bytes], { type: \"image/png\" }), {\n premultiplyAlpha: \"none\",\n colorSpaceConversion: \"none\",\n });\n try {\n if (bitmap.width !== image.width || bitmap.height !== image.height) throw new Error(\"Decoded PNG dimension mismatch\");\n resource.writeBitmap(bitmap);\n } finally {\n bitmap.close();\n }\n }\n this.images.set(image.key, resource);\n this.textureBytes += image.width * image.height * 4;\n this.imageUploadBytes += image.width * image.height * 4;\n this.imagePayloadBytes += image.byteLength;\n } catch (error) {\n resource.destroy();\n throw error;\n }\n }\n }\n\n prepareGlyphs(cells: readonly (TerminalCell | undefined)[]): void {\n const visible = new Map();\n for (const cell of cells) {\n if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64)) continue;\n visible.set(glyphKey(cell), cell);\n }\n const keyUnits = (glyphs: ReadonlyMap) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0);\n if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) {\n throw new Error(\"Visible glyph metadata exceeds the bounded glyph cache\");\n }\n const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key)));\n if (!missing.size) return;\n const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS &&\n this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS;\n let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null;\n if (!plan) {\n let size = this.atlas.width;\n const maxSize = Math.min(4096, this.backend.maxTextureDimension2D);\n while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) {\n size = Math.min(size * 2, maxSize);\n }\n if (!plan) throw new Error(\"Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale\");\n this.resetAtlas(size);\n this.atlasRebuilds++;\n }\n for (const placement of plan.placements) this.uploadGlyph(placement);\n this.shelf = plan.shelf;\n }\n\n uploadGlyph({ key, cell, x, y, width, height }: GlyphPlacement): void {\n const scale = this.scale;\n const raster = this.raster;\n const style = cell.attributes & 5;\n let metrics = this.fontMetrics.get(style);\n if (!metrics) {\n metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT);\n this.fontMetrics.set(style, metrics);\n }\n this.rasterCanvas.width = width;\n this.rasterCanvas.height = height;\n raster.font = metrics.font;\n raster.textBaseline = \"alphabetic\";\n raster.textAlign = \"left\";\n raster.fillStyle = \"white\";\n // One transform per font style, not per glyph: borders remain font outlines,\n // and graphemes are clipped to their server-owned span without individual stretching.\n raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline);\n raster.fillText(cell.text, 0, 0);\n const pixels = raster.getImageData(0, 0, width, height);\n let colored = false;\n for (let i = 0; i < pixels.data.length; i += 4) {\n if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) {\n colored = true;\n break;\n }\n }\n this.atlas.writePixels(pixels.data, width, height, x, y);\n this.glyphUploadBytes += width * height * 4;\n this.glyphs.set(key, {\n colored,\n u0: (x + 2) / this.atlas.width,\n v0: (y + 2) / this.atlas.height,\n u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width,\n v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height,\n });\n this.glyphKeyUnits += key.length;\n }\n\n /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */\n quad(resource: TextureResource, x: number, y: number, width: number, height: number,\n color: Vector4, mode = 0, uv: Vector4 = [0, 0, 1, 1],\n clip: Vector4 = [0, 0, this.width, this.height]): void {\n if (width <= 0 || height <= 0 || color[3] <= 0) return;\n const left = Math.max(0, x, clip[0]);\n const top = Math.max(0, y, clip[1]);\n const right = Math.min(this.width, x + width, clip[0] + clip[2]);\n const bottom = Math.min(this.height, y + height, clip[1] + clip[3]);\n if (right <= left || bottom <= top) return;\n if (this.quadCount >= MAX_QUADS) throw new Error(\"Frame exceeds bounded quad budget\");\n const offset = this.quadCount * STRIDE;\n if (offset + STRIDE > this.instances.length) {\n const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE));\n grown.set(this.instances);\n this.instances = grown;\n }\n const du = uv[2] - uv[0];\n const dv = uv[3] - uv[1];\n this.instances.set([\n left, top, right - left, bottom - top,\n uv[0] + (left - x) / width * du,\n uv[1] + (top - y) / height * dv,\n uv[0] + (right - x) / width * du,\n uv[1] + (bottom - y) / height * dv,\n ...color, mode, 0, 0, 0,\n ], offset);\n const last = this.batches[this.batches.length - 1];\n if (last?.resource === resource) last.count++;\n else this.batches.push({ resource, start: this.quadCount, count: 1 });\n this.quadCount++;\n }\n\n solid(x: number, y: number, width: number, height: number, color: Vector4): void {\n this.quad(this.atlas, x, y, width, height, color);\n }\n\n placement(placement: ImagePlacement): void {\n const image = this.images.get(placement.key);\n if (!image) throw new Error(`Placement texture is missing: ${placement.key}`);\n const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement;\n if (!sw || !sh || !placement.width || !placement.height) return;\n // Clip out-of-texture source regions in destination space instead of stretching edge texels.\n const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width;\n const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height;\n const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width;\n const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height;\n const left = Math.max(sourceLeft, placement.clipX);\n const top = Math.max(sourceTop, placement.clipY);\n const right = Math.min(sourceRight, placement.clipX + placement.clipWidth);\n const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight);\n this.quad(\n image, placement.x, placement.y, placement.width, placement.height, WHITE, 2,\n [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height],\n [left, top, Math.max(0, right - left), Math.max(0, bottom - top)],\n );\n }\n\n decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void {\n if (cell.attributes & 128) this.solid(x, y + 10, width, 1, foreground);\n if (cell.attributes & 256) this.solid(x, y + 1, width, 1, foreground);\n const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0);\n const color = rgba(cell.underlineColor);\n if (style === 1) this.solid(x, y + 18, width, 1, color);\n else if (style === 2) {\n this.solid(x, y + 16, width, 1, color);\n this.solid(x, y + 18, width, 1, color);\n } else if (style === 3) {\n for (let dx = 0; dx < width; dx++) {\n this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color);\n }\n } else if (style === 4 || style === 5) {\n const step = style === 4 ? 2 : 5;\n const segment = style === 4 ? 1 : 3;\n for (let dx = 0; dx < width; dx += step) this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color);\n }\n }\n\n render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean) {\n const start = performance.now();\n this.quadCount = 0;\n this.batches = [];\n const placements = metadata.placements.map((placement, order) => ({\n placement,\n order,\n z: placement.kind === \"sixel\" ? -1 : placement.z,\n })).sort((a, b) => a.z - b.z || a.order - b.order);\n for (const item of placements) if (item.z < -1073741824) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell) continue;\n this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background));\n }\n for (const item of placements) if (item.z >= -1073741824 && item.z < 0) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn)) continue;\n const x = (i % this.columns) * CELL_WIDTH;\n const y = Math.floor(i / this.columns) * CELL_HEIGHT;\n const width = Math.min(cell.width * CELL_WIDTH, this.width - x);\n const foreground = rgba(cell.foreground);\n const glyph = this.glyphs.get(glyphKey(cell));\n if (glyph) {\n const tint: Vector4 = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground;\n this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT,\n tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]);\n }\n // Reverse and dim are already reflected in server-projected colors.\n this.decorations(cell, x, y, width, foreground);\n }\n for (const item of placements) if (item.z >= 0) this.placement(item.placement);\n const cursor = metadata.cursor;\n const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1;\n if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) {\n const cell = cells[cursor.y * this.columns + cursor.x];\n const color = rgba(cell?.foreground ?? 0xffffffff);\n const x = cursor.x * CELL_WIDTH;\n const y = cursor.y * CELL_HEIGHT;\n if (cursor.shape === 3 || cursor.shape === 4) this.solid(x, y + 18, CELL_WIDTH, 2, color);\n else if (cursor.shape === 5 || cursor.shape === 6) this.solid(x, y, 2, CELL_HEIGHT, color);\n else {\n color[3] *= 0.55;\n this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color);\n }\n }\n const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000);\n this.backend.submit(this.instances, this.quadCount, this.batches, base);\n return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length };\n }\n\n metrics() {\n return {\n renderer: this.backend.kind,\n rendererFallbackReason: this.fallbackReason,\n fontFamily: this.font.family,\n rasterScale: this.scale,\n backingScale: this.backingScale,\n backingWidth: this.canvas.width,\n backingHeight: this.canvas.height,\n imageCount: this.images.size,\n textureBytes: this.textureBytes,\n atlasGlyphs: this.glyphs.size,\n atlasBytes: this.atlas.width * this.atlas.height * 4,\n atlasRebuilds: this.atlasRebuilds,\n imageUploadBytes: this.imageUploadBytes,\n imagePayloadBytes: this.imagePayloadBytes,\n glyphUploadBytes: this.glyphUploadBytes,\n instanceBufferBytes: this.backend.instanceBufferBytes,\n };\n }\n\n async idle() {\n await this.backend.idle();\n }\n\n dispose() {\n if (this.disposed) return;\n this.disposed = true;\n for (const image of this.images.values()) image.destroy();\n this.images.clear();\n this.atlas?.destroy();\n this.font?.dispose();\n this.fontMetrics.clear();\n this.backend.dispose();\n }\n}\n"]} \ No newline at end of file +{"version":3,"file":"renderer.js","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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","sourcesContent":["import { LIMITS } from \"./protocol.js\";\nimport { loadFont, measureFont, normalizeFont } from \"./terminal-font.js\";\nimport { createRenderBackend } from \"./backend-selection.js\";\nimport { QUAD_STRIDE } from \"./render-backend.js\";\nimport type { RenderBackend, RenderBatch, RenderColor, RenderTexture } from \"./render-backend.js\";\nimport type { FontMetrics, LoadedFont, NormalizedFont } from \"./terminal-font.js\";\nimport type { TerminalFont, TerminalRendererPreference, TerminalSize } from \"./types.js\";\nimport type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from \"./wire-types.js\";\n\ntype Vector4 = RenderColor;\ntype TextureResource = RenderTexture;\ninterface Shelf { x: number; y: number; rowHeight: number }\ninterface GlyphPlacement { key: string; cell: TerminalCell; x: number; y: number; width: number; height: number }\ninterface Glyph { colored: boolean; u0: number; v0: number; u1: number; v1: number }\ntype Batch = RenderBatch;\n\nconst CELL_WIDTH = 10;\nconst CELL_HEIGHT = 20;\nconst MAX_QUADS = 1024 * 1024;\nconst MAX_GLYPHS = 16384;\nconst MAX_GLYPH_KEY_UNITS = 1024 * 1024;\nconst STRIDE = QUAD_STRIDE;\nconst WHITE: Vector4 = [1, 1, 1, 1];\n\nfunction rgba(packed: number): Vector4 {\n return [\n (packed & 255) / 255,\n ((packed >>> 8) & 255) / 255,\n ((packed >>> 16) & 255) / 255,\n ((packed >>> 24) & 255) / 255,\n ];\n}\n\nfunction glyphKey(cell: TerminalCell): string {\n return `${cell.attributes & 5}/${cell.width}/${cell.text}`;\n}\n\nfunction isKgpPlaceholder(cell: TerminalCell): boolean {\n // The base scalar and following diacritics encode an image reference, not a glyph.\n // Keep the authoritative text and colors intact even when no image is placed.\n return cell.text.codePointAt(0) === 0x10eeee;\n}\n\n/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */\nfunction packGlyphs(glyphs: ReadonlyMap, size: number, scale: number,\n initial: Shelf = { x: 0, y: 0, rowHeight: 0 }): { placements: GlyphPlacement[]; shelf: Shelf } | null {\n let { x, y, rowHeight } = initial;\n const placements = [];\n for (const [key, cell] of glyphs) {\n const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4;\n const height = Math.ceil(CELL_HEIGHT * scale) + 4;\n if (width > size || height > size) return null;\n if (x + width > size) { x = 0; y += rowHeight; rowHeight = 0; }\n if (y + height > size) return null;\n placements.push({ key, cell, x, y, width, height });\n x += width;\n rowHeight = Math.max(rowHeight, height);\n }\n return { placements, shelf: { x, y, rowHeight } };\n}\n\n/** Shared instanced-quad preparation; Canvas2D rasterizes reusable glyphs for either backend. */\nexport class TerminalRenderer {\n canvas: OffscreenCanvas;\n scale: number;\n backingScale: number;\n backend: RenderBackend;\n fallbackReason?: string;\n fontConfiguration: NormalizedFont;\n fontMetrics: Map;\n images: Map;\n glyphs: Map;\n imageUploadBytes: number;\n imagePayloadBytes: number;\n glyphUploadBytes: number;\n atlasRebuilds: number;\n textureBytes: number;\n instances: Float32Array;\n disposed: boolean;\n columns: number;\n rows: number;\n // Initialized by create() before the renderer can prepare or submit frames.\n font!: LoadedFont;\n rasterCanvas!: OffscreenCanvas;\n raster!: OffscreenCanvasRenderingContext2D;\n atlas!: TextureResource;\n glyphKeyUnits = 0;\n shelf: Shelf = { x: 0, y: 0, rowHeight: 0 };\n canvasLimited = false;\n width = 0;\n height = 0;\n quadCount = 0;\n batches: Batch[] = [];\n\n static async create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error) => void,\n font?: TerminalFont, preference: TerminalRendererPreference = \"auto\"): Promise {\n const normalizedFont = normalizeFont(font);\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n const { backend, fallbackReason } = await createRenderBackend(canvas, onFatal, preference);\n const renderer = new TerminalRenderer(canvas, scale, backend, normalizedFont);\n renderer.fallbackReason = fallbackReason;\n try {\n await renderer.initialize();\n return renderer;\n } catch (error) {\n renderer.dispose();\n throw error;\n }\n }\n\n constructor(canvas: OffscreenCanvas, scale: number, backend: RenderBackend, font: NormalizedFont) {\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n this.canvas = canvas;\n this.scale = scale;\n this.backingScale = scale;\n this.backend = backend;\n this.fontConfiguration = font;\n this.fontMetrics = new Map();\n this.images = new Map();\n this.glyphs = new Map();\n this.imageUploadBytes = 0;\n this.imagePayloadBytes = 0;\n this.glyphUploadBytes = 0;\n this.atlasRebuilds = 0;\n this.textureBytes = 0;\n this.instances = new Float32Array(4096 * STRIDE);\n this.disposed = false;\n this.columns = 0;\n this.rows = 0;\n }\n\n async initialize() {\n this.font = await loadFont(this.fontConfiguration);\n this.rasterCanvas = new OffscreenCanvas(1, 1);\n const raster = this.rasterCanvas.getContext(\"2d\", { willReadFrequently: true });\n if (!raster) throw new Error(\"Worker glyph rasterization is unavailable\");\n this.raster = raster;\n this.resetAtlas(Math.min(2048, this.backend.maxTextureDimension2D));\n }\n\n createTexture(width: number, height: number, label: string): TextureResource {\n return this.backend.createTexture(width, height, label);\n }\n\n resetAtlas(size: number): void {\n this.atlas?.destroy();\n this.atlas = this.createTexture(size, size, \"Glyph atlas\");\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.shelf = { x: 0, y: 0, rowHeight: 0 };\n }\n\n resize(columns: number, rows: number, viewport?: TerminalSize): void {\n const width = columns * CELL_WIDTH;\n const height = rows * CELL_HEIGHT;\n const limit = this.backend.maxCanvasDimension2D;\n const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity);\n this.canvasLimited = width * requested > limit || height * requested > limit;\n this.backingScale = Math.min(requested, limit / width, limit / height);\n const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale)));\n const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale)));\n if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight) return;\n this.columns = columns;\n this.rows = rows;\n this.width = width;\n this.height = height;\n this.canvas.width = backingWidth;\n this.canvas.height = backingHeight;\n this.backend.resize(width, height);\n }\n\n /** Call only between submissions. Missing/over-budget resources terminate the session. */\n async updateImages(incoming: readonly FrameImage[], retainedKeys: readonly string[]): Promise {\n const retained = new Set(retainedKeys);\n const replacements = new Map(incoming.map(image => [image.key, image]));\n let projectedBytes = 0;\n for (const key of retained) {\n const image = replacements.get(key) || this.images.get(key);\n if (!image) throw new Error(`Missing retained image resource: ${key}`);\n projectedBytes += image.width * image.height * 4;\n }\n if (projectedBytes > LIMITS.textureBytes) throw new Error(\"Retained images exceed the 256 MiB texture budget\");\n for (const [key, image] of this.images) {\n if (!retained.has(key) || replacements.has(key)) {\n image.destroy();\n this.textureBytes -= image.width * image.height * 4;\n this.images.delete(key);\n }\n }\n for (const image of incoming) {\n const resource = this.createTexture(image.width, image.height, `Image ${image.key}`);\n try {\n if (image.format === \"rgba\") {\n resource.writePixels(image.bytes, image.width, image.height);\n } else {\n // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions.\n const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength);\n if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a ||\n png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 ||\n png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) {\n throw new Error(`PNG header dimensions do not match resource ${image.key}`);\n }\n const bitmap = await createImageBitmap(new Blob([image.bytes], { type: \"image/png\" }), {\n premultiplyAlpha: \"none\",\n colorSpaceConversion: \"none\",\n });\n try {\n if (bitmap.width !== image.width || bitmap.height !== image.height) throw new Error(\"Decoded PNG dimension mismatch\");\n resource.writeBitmap(bitmap);\n } finally {\n bitmap.close();\n }\n }\n this.images.set(image.key, resource);\n this.textureBytes += image.width * image.height * 4;\n this.imageUploadBytes += image.width * image.height * 4;\n this.imagePayloadBytes += image.byteLength;\n } catch (error) {\n resource.destroy();\n throw error;\n }\n }\n }\n\n prepareGlyphs(cells: readonly (TerminalCell | undefined)[]): void {\n const visible = new Map();\n for (const cell of cells) {\n if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64) || isKgpPlaceholder(cell)) continue;\n visible.set(glyphKey(cell), cell);\n }\n const keyUnits = (glyphs: ReadonlyMap) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0);\n if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) {\n throw new Error(\"Visible glyph metadata exceeds the bounded glyph cache\");\n }\n const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key)));\n if (!missing.size) return;\n const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS &&\n this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS;\n let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null;\n if (!plan) {\n let size = this.atlas.width;\n const maxSize = Math.min(4096, this.backend.maxTextureDimension2D);\n while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) {\n size = Math.min(size * 2, maxSize);\n }\n if (!plan) throw new Error(\"Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale\");\n this.resetAtlas(size);\n this.atlasRebuilds++;\n }\n for (const placement of plan.placements) this.uploadGlyph(placement);\n this.shelf = plan.shelf;\n }\n\n uploadGlyph({ key, cell, x, y, width, height }: GlyphPlacement): void {\n const scale = this.scale;\n const raster = this.raster;\n const style = cell.attributes & 5;\n let metrics = this.fontMetrics.get(style);\n if (!metrics) {\n metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT);\n this.fontMetrics.set(style, metrics);\n }\n this.rasterCanvas.width = width;\n this.rasterCanvas.height = height;\n raster.font = metrics.font;\n raster.textBaseline = \"alphabetic\";\n raster.textAlign = \"left\";\n raster.fillStyle = \"white\";\n // One transform per font style, not per glyph: borders remain font outlines,\n // and graphemes are clipped to their server-owned span without individual stretching.\n raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline);\n raster.fillText(cell.text, 0, 0);\n const pixels = raster.getImageData(0, 0, width, height);\n let colored = false;\n for (let i = 0; i < pixels.data.length; i += 4) {\n if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) {\n colored = true;\n break;\n }\n }\n this.atlas.writePixels(pixels.data, width, height, x, y);\n this.glyphUploadBytes += width * height * 4;\n this.glyphs.set(key, {\n colored,\n u0: (x + 2) / this.atlas.width,\n v0: (y + 2) / this.atlas.height,\n u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width,\n v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height,\n });\n this.glyphKeyUnits += key.length;\n }\n\n /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */\n quad(resource: TextureResource, x: number, y: number, width: number, height: number,\n color: Vector4, mode = 0, uv: Vector4 = [0, 0, 1, 1],\n clip: Vector4 = [0, 0, this.width, this.height]): void {\n if (width <= 0 || height <= 0 || color[3] <= 0) return;\n const left = Math.max(0, x, clip[0]);\n const top = Math.max(0, y, clip[1]);\n const right = Math.min(this.width, x + width, clip[0] + clip[2]);\n const bottom = Math.min(this.height, y + height, clip[1] + clip[3]);\n if (right <= left || bottom <= top) return;\n if (this.quadCount >= MAX_QUADS) throw new Error(\"Frame exceeds bounded quad budget\");\n const offset = this.quadCount * STRIDE;\n if (offset + STRIDE > this.instances.length) {\n const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE));\n grown.set(this.instances);\n this.instances = grown;\n }\n const du = uv[2] - uv[0];\n const dv = uv[3] - uv[1];\n this.instances.set([\n left, top, right - left, bottom - top,\n uv[0] + (left - x) / width * du,\n uv[1] + (top - y) / height * dv,\n uv[0] + (right - x) / width * du,\n uv[1] + (bottom - y) / height * dv,\n ...color, mode, 0, 0, 0,\n ], offset);\n const last = this.batches[this.batches.length - 1];\n if (last?.resource === resource) last.count++;\n else this.batches.push({ resource, start: this.quadCount, count: 1 });\n this.quadCount++;\n }\n\n solid(x: number, y: number, width: number, height: number, color: Vector4): void {\n this.quad(this.atlas, x, y, width, height, color);\n }\n\n placement(placement: ImagePlacement): void {\n const image = this.images.get(placement.key);\n if (!image) throw new Error(`Placement texture is missing: ${placement.key}`);\n const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement;\n if (!sw || !sh || !placement.width || !placement.height) return;\n // Clip out-of-texture source regions in destination space instead of stretching edge texels.\n const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width;\n const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height;\n const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width;\n const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height;\n const left = Math.max(sourceLeft, placement.clipX);\n const top = Math.max(sourceTop, placement.clipY);\n const right = Math.min(sourceRight, placement.clipX + placement.clipWidth);\n const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight);\n this.quad(\n image, placement.x, placement.y, placement.width, placement.height, WHITE, 2,\n [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height],\n [left, top, Math.max(0, right - left), Math.max(0, bottom - top)],\n );\n }\n\n decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void {\n if (cell.attributes & 128) this.solid(x, y + 10, width, 1, foreground);\n if (cell.attributes & 256) this.solid(x, y + 1, width, 1, foreground);\n const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0);\n const color = rgba(cell.underlineColor);\n if (style === 1) this.solid(x, y + 18, width, 1, color);\n else if (style === 2) {\n this.solid(x, y + 16, width, 1, color);\n this.solid(x, y + 18, width, 1, color);\n } else if (style === 3) {\n for (let dx = 0; dx < width; dx++) {\n this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color);\n }\n } else if (style === 4 || style === 5) {\n const step = style === 4 ? 2 : 5;\n const segment = style === 4 ? 1 : 3;\n for (let dx = 0; dx < width; dx += step) this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color);\n }\n }\n\n render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean) {\n const start = performance.now();\n this.quadCount = 0;\n this.batches = [];\n const placements = metadata.placements.map((placement, order) => ({\n placement,\n order,\n z: placement.kind === \"sixel\" ? -1 : placement.z,\n })).sort((a, b) => a.z - b.z || a.order - b.order);\n for (const item of placements) if (item.z < -1073741824) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell) continue;\n this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background));\n }\n for (const item of placements) if (item.z >= -1073741824 && item.z < 0) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn)) continue;\n const x = (i % this.columns) * CELL_WIDTH;\n const y = Math.floor(i / this.columns) * CELL_HEIGHT;\n const width = Math.min(cell.width * CELL_WIDTH, this.width - x);\n const foreground = rgba(cell.foreground);\n const glyph = isKgpPlaceholder(cell) ? undefined : this.glyphs.get(glyphKey(cell));\n if (glyph) {\n const tint: Vector4 = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground;\n this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT,\n tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]);\n }\n // Reverse and dim are already reflected in server-projected colors.\n this.decorations(cell, x, y, width, foreground);\n }\n for (const item of placements) if (item.z >= 0) this.placement(item.placement);\n const cursor = metadata.cursor;\n const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1;\n if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) {\n const cell = cells[cursor.y * this.columns + cursor.x];\n const color = rgba(cell?.foreground ?? 0xffffffff);\n const x = cursor.x * CELL_WIDTH;\n const y = cursor.y * CELL_HEIGHT;\n if (cursor.shape === 3 || cursor.shape === 4) this.solid(x, y + 18, CELL_WIDTH, 2, color);\n else if (cursor.shape === 5 || cursor.shape === 6) this.solid(x, y, 2, CELL_HEIGHT, color);\n else {\n color[3] *= 0.55;\n this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color);\n }\n }\n const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000);\n this.backend.submit(this.instances, this.quadCount, this.batches, base);\n return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length };\n }\n\n metrics() {\n return {\n renderer: this.backend.kind,\n rendererFallbackReason: this.fallbackReason,\n fontFamily: this.font.family,\n rasterScale: this.scale,\n backingScale: this.backingScale,\n backingWidth: this.canvas.width,\n backingHeight: this.canvas.height,\n imageCount: this.images.size,\n textureBytes: this.textureBytes,\n atlasGlyphs: this.glyphs.size,\n atlasBytes: this.atlas.width * this.atlas.height * 4,\n atlasRebuilds: this.atlasRebuilds,\n imageUploadBytes: this.imageUploadBytes,\n imagePayloadBytes: this.imagePayloadBytes,\n glyphUploadBytes: this.glyphUploadBytes,\n instanceBufferBytes: this.backend.instanceBufferBytes,\n };\n }\n\n async idle() {\n await this.backend.idle();\n }\n\n dispose() {\n if (this.disposed) return;\n this.disposed = true;\n for (const image of this.images.values()) image.destroy();\n this.images.clear();\n this.textureBytes = 0;\n this.atlas?.destroy();\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.batches = [];\n this.quadCount = 0;\n this.instances = new Float32Array(0);\n this.font?.dispose();\n this.fontMetrics.clear();\n this.backend.dispose();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js index ef44c33bb53..06eff65d0e9 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js @@ -125,6 +125,7 @@ async function drawFrame() { mouseTracking: metadata.mouseTracking, peer: metadata.peer, history: metadata.history, revision: frame.revision, title: metadata.title, progress: metadata.progress, shellIntegration: metadata.shellIntegration, + workingDirectory: metadata.workingDirectory, commandMark: metadata.commandMark, text, hyperlinks: metadata.hyperlinks }); send({ type: "ack", revision: frame.revision }); diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map index 77a716e8538..4ce5f7a025d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map @@ -1 +1 @@ -{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close();\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, title: metadata.title,\n progress: metadata.progress, shellIntegration: metadata.shellIntegration,\n text, hyperlinks: metadata.hyperlinks\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n // WebSocket errors are followed by close, which carries the browser's actual status.\n // Rejecting mount on error would terminate this worker before that status can be delivered.\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"closed\", details: {\n code: event.code, reason: event.reason, wasClean: event.wasClean\n } });\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file +{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close();\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, title: metadata.title,\n progress: metadata.progress, shellIntegration: metadata.shellIntegration,\n workingDirectory: metadata.workingDirectory, commandMark: metadata.commandMark,\n text, hyperlinks: metadata.hyperlinks\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n // WebSocket errors are followed by close, which carries the browser's actual status.\n // Rejecting mount on error would terminate this worker before that status can be delivered.\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"closed\", details: {\n code: event.code, reason: event.reason, wasClean: event.wasClean\n } });\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts index 0df4440263f..8b78376fd7e 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts @@ -291,6 +291,28 @@ export interface TerminalShellIntegration { /** Null means no reported status, not success. Preserved across the next prompt/command. */ readonly lastExitCode: number | null; } +/** Last reported OSC 7 working directory, or all-null before any is reported. */ +export interface TerminalWorkingDirectory { + /** Raw URI as reported by the shell (typically `file://`), or null. */ + readonly uri: string | null; + /** Authority from the URI; "" for a local/unqualified authority. Null when uri is null. */ + readonly host: string | null; + /** Decoded filesystem path from the URI. Null when uri is null. */ + readonly path: string | null; +} +/** + * Latest OSC 133 marker, distinct from {@link TerminalShellIntegration}: it additionally carries + * any raw trailing `key=value` parameters (e.g. a `cmdline_url` extension on marker C). This is + * the single most-recent marker only — the server does not transport a mark history or event + * log over this wire; consumers that want their own history should accumulate distinct values + * from {@link WebTerminalOptions.onCommandMarkChange} themselves. + */ +export interface TerminalCommandMark { + readonly phase: TerminalShellIntegrationPhase; + readonly exitCode: number | null; + /** Verbatim `key=value[;key=value...]` trailing the marker, or null when none was present. */ + readonly rawParameters: string | null; +} export interface WebTerminalOptions extends InputPolicyOptions { url: string | URL; /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */ @@ -336,6 +358,18 @@ export interface WebTerminalOptions extends InputPolicyOptions { * occur between frames. Replays provide current state, never synthetic command executions. */ onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void; + /** + * Receives the first authoritative presented working directory before mount resolves, then + * distinct presented changes. All-null means none reported yet; a malformed or non-`file` OSC 7 + * report does not change presented state. No notifications after disposal. + */ + onWorkingDirectoryChange?: (workingDirectory: TerminalWorkingDirectory) => void; + /** + * Receives the first authoritative presented command mark before mount resolves (null if none + * yet reported), then distinct presented changes. Only the latest marker is transmitted, not a + * history; entire commands may occur between frames. No notifications after disposal. + */ + onCommandMarkChange?: (commandMark: TerminalCommandMark | null) => void; onStats?: (stats: TerminalStats, text: string | undefined) => void; onViewportChange?: (viewport: TerminalViewport) => void; onSelectionChange?: (selection: TerminalSelection) => void; @@ -357,6 +391,10 @@ export interface WebTerminalHandle { readonly progress: TerminalProgress; /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */ readonly shellIntegration: TerminalShellIntegration; + /** Current presented working directory, all-null initially. Retained on disconnect/dispose. */ + readonly workingDirectory: TerminalWorkingDirectory; + /** Latest presented command mark, or null if none reported yet. Retained on disconnect/dispose. */ + readonly commandMark: TerminalCommandMark | null; readonly stats: TerminalStats; readonly screenText: string; readonly sizing: TerminalSizingState; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map index 44f5db0b58d..30efa8a73d9 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map index d02d5a631ef..42c761baa44 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\n/** Native WebSocket close details, not an assertion that the terminal workload completed. */\nexport interface TerminalCloseDetails {\n /** RFC 6455 status reported by the browser, including 1006 for abnormal loss without a close frame. */\n readonly code: number;\n /** Peer-provided close reason, or \"\". Treat as untrusted text. */\n readonly reason: string;\n /** Whether the browser observed a clean WebSocket closing handshake, not workload success. */\n readonly wasClean: boolean;\n}\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\n/** Application-reported OSC 9;4 indicator, independent of shell execution. */\nexport type TerminalProgressState = \"none\" | \"normal\" | \"error\" | \"indeterminate\" | \"warning\";\n/** Immutable current progress; a hidden or indeterminate indicator has no percentage. */\nexport interface TerminalProgress {\n readonly state: TerminalProgressState;\n readonly percentage: number | null;\n}\n/** Last reported OSC 133 phase. Unknown does not mean idle; commandLine is not execution. */\nexport type TerminalShellIntegrationPhase = \"unknown\" | \"prompt\" | \"commandLine\" | \"executing\" | \"finished\";\n/** Current shell phase and latest reported completion status, not command history. */\nexport interface TerminalShellIntegration {\n readonly phase: TerminalShellIntegrationPhase;\n /** Null means no reported status, not success. Preserved across the next prompt/command. */\n readonly lastExitCode: number | null;\n}\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n /** Initial per-view input policy. Change it later with setReadOnly; not a server authorization boundary. */\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n /**\n * Receives the native WebSocket close details once, including connection failures and closes\n * before the first frame. The view is disconnected before this callback; a pending mount\n * rejects after notification. No callback is synthesized for abort, disposal, initialization\n * failure, or mount timeout, and none runs after disposal. This client never reconnects\n * automatically. Interpret application close codes in the host; even 1000 is not proof of\n * workload completion. Callback exceptions reach the host and are not retried.\n */\n onClose?: (details: TerminalCloseDetails) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n /**\n * Receives the first authoritative presented title (including \"\") before mount resolves,\n * then distinct presented changes. The title getter is updated first. Titles are untrusted\n * text; render with textContent, not HTML. No notifications after disposal.\n */\n onTitleChange?: (title: string) => void;\n /**\n * Receives the first authoritative presented progress before mount resolves, then distinct\n * presented changes. Both activity getters update before either callback. Intermediate\n * states may coalesce; this is not a callback for every OSC sequence. None hides the indicator.\n * No notifications after disposal; connection loss does not manufacture a progress clear.\n */\n onProgressChange?: (progress: TerminalProgress) => void;\n /**\n * Receives the first authoritative presented shell state before mount resolves, then distinct\n * presented changes. This is not a lossless command-start/finish stream: entire commands may\n * occur between frames. Replays provide current state, never synthetic command executions.\n */\n onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n /** Whether this view blocks application input, resize, and primary takeover. */\n readonly readOnly: boolean;\n /** Current presented workload title, or \"\" when unset/cleared. Retained on disconnect/dispose. */\n readonly title: string;\n /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */\n readonly progress: TerminalProgress;\n /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */\n readonly shellIntegration: TerminalShellIntegration;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n /**\n * Changes this view's input policy without remounting or changing peer roles.\n * Output, history, selection and copying remain available. Cancels active gestures,\n * pending composition and clipboard paste; already dispatched commands cannot be recalled.\n * Hosts must separately enforce permissions on their per-view Hwt1PresentationAdapter.\n */\n setReadOnly(readOnly: boolean): void;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\n/** Native WebSocket close details, not an assertion that the terminal workload completed. */\nexport interface TerminalCloseDetails {\n /** RFC 6455 status reported by the browser, including 1006 for abnormal loss without a close frame. */\n readonly code: number;\n /** Peer-provided close reason, or \"\". Treat as untrusted text. */\n readonly reason: string;\n /** Whether the browser observed a clean WebSocket closing handshake, not workload success. */\n readonly wasClean: boolean;\n}\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\n/** Application-reported OSC 9;4 indicator, independent of shell execution. */\nexport type TerminalProgressState = \"none\" | \"normal\" | \"error\" | \"indeterminate\" | \"warning\";\n/** Immutable current progress; a hidden or indeterminate indicator has no percentage. */\nexport interface TerminalProgress {\n readonly state: TerminalProgressState;\n readonly percentage: number | null;\n}\n/** Last reported OSC 133 phase. Unknown does not mean idle; commandLine is not execution. */\nexport type TerminalShellIntegrationPhase = \"unknown\" | \"prompt\" | \"commandLine\" | \"executing\" | \"finished\";\n/** Current shell phase and latest reported completion status, not command history. */\nexport interface TerminalShellIntegration {\n readonly phase: TerminalShellIntegrationPhase;\n /** Null means no reported status, not success. Preserved across the next prompt/command. */\n readonly lastExitCode: number | null;\n}\n/** Last reported OSC 7 working directory, or all-null before any is reported. */\nexport interface TerminalWorkingDirectory {\n /** Raw URI as reported by the shell (typically `file://`), or null. */\n readonly uri: string | null;\n /** Authority from the URI; \"\" for a local/unqualified authority. Null when uri is null. */\n readonly host: string | null;\n /** Decoded filesystem path from the URI. Null when uri is null. */\n readonly path: string | null;\n}\n/**\n * Latest OSC 133 marker, distinct from {@link TerminalShellIntegration}: it additionally carries\n * any raw trailing `key=value` parameters (e.g. a `cmdline_url` extension on marker C). This is\n * the single most-recent marker only — the server does not transport a mark history or event\n * log over this wire; consumers that want their own history should accumulate distinct values\n * from {@link WebTerminalOptions.onCommandMarkChange} themselves.\n */\nexport interface TerminalCommandMark {\n readonly phase: TerminalShellIntegrationPhase;\n readonly exitCode: number | null;\n /** Verbatim `key=value[;key=value...]` trailing the marker, or null when none was present. */\n readonly rawParameters: string | null;\n}\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n /** Initial per-view input policy. Change it later with setReadOnly; not a server authorization boundary. */\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n /**\n * Receives the native WebSocket close details once, including connection failures and closes\n * before the first frame. The view is disconnected before this callback; a pending mount\n * rejects after notification. No callback is synthesized for abort, disposal, initialization\n * failure, or mount timeout, and none runs after disposal. This client never reconnects\n * automatically. Interpret application close codes in the host; even 1000 is not proof of\n * workload completion. Callback exceptions reach the host and are not retried.\n */\n onClose?: (details: TerminalCloseDetails) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n /**\n * Receives the first authoritative presented title (including \"\") before mount resolves,\n * then distinct presented changes. The title getter is updated first. Titles are untrusted\n * text; render with textContent, not HTML. No notifications after disposal.\n */\n onTitleChange?: (title: string) => void;\n /**\n * Receives the first authoritative presented progress before mount resolves, then distinct\n * presented changes. Both activity getters update before either callback. Intermediate\n * states may coalesce; this is not a callback for every OSC sequence. None hides the indicator.\n * No notifications after disposal; connection loss does not manufacture a progress clear.\n */\n onProgressChange?: (progress: TerminalProgress) => void;\n /**\n * Receives the first authoritative presented shell state before mount resolves, then distinct\n * presented changes. This is not a lossless command-start/finish stream: entire commands may\n * occur between frames. Replays provide current state, never synthetic command executions.\n */\n onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void;\n /**\n * Receives the first authoritative presented working directory before mount resolves, then\n * distinct presented changes. All-null means none reported yet; a malformed or non-`file` OSC 7\n * report does not change presented state. No notifications after disposal.\n */\n onWorkingDirectoryChange?: (workingDirectory: TerminalWorkingDirectory) => void;\n /**\n * Receives the first authoritative presented command mark before mount resolves (null if none\n * yet reported), then distinct presented changes. Only the latest marker is transmitted, not a\n * history; entire commands may occur between frames. No notifications after disposal.\n */\n onCommandMarkChange?: (commandMark: TerminalCommandMark | null) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n /** Whether this view blocks application input, resize, and primary takeover. */\n readonly readOnly: boolean;\n /** Current presented workload title, or \"\" when unset/cleared. Retained on disconnect/dispose. */\n readonly title: string;\n /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */\n readonly progress: TerminalProgress;\n /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */\n readonly shellIntegration: TerminalShellIntegration;\n /** Current presented working directory, all-null initially. Retained on disconnect/dispose. */\n readonly workingDirectory: TerminalWorkingDirectory;\n /** Latest presented command mark, or null if none reported yet. Retained on disconnect/dispose. */\n readonly commandMark: TerminalCommandMark | null;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n /**\n * Changes this view's input policy without remounting or changing peer roles.\n * Output, history, selection and copying remain available. Cancels active gestures,\n * pending composition and clipboard paste; already dispatched commands cannot be recalled.\n * Hosts must separately enforce permissions on their per-view Hwt1PresentationAdapter.\n */\n setReadOnly(readOnly: boolean): void;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts index c5789c854f6..f8f184fb48e 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts @@ -1,4 +1,4 @@ -import type { CopySelectionOptions, InputActionHandler, InputBinding, TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport, TerminalProgress, TerminalShellIntegration, WebTerminalHandle, WebTerminalOptions } from "./types.js"; +import type { CopySelectionOptions, InputActionHandler, InputBinding, TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport, TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark, WebTerminalHandle, WebTerminalOptions } from "./types.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; /** * First-party HWT1 client. Owns only the element it appends, not the caller's @@ -18,6 +18,8 @@ export declare class WebTerminal implements WebTerminalHandle { get title(): string; get progress(): TerminalProgress; get shellIntegration(): TerminalShellIntegration; + get workingDirectory(): TerminalWorkingDirectory; + get commandMark(): TerminalCommandMark | null; get stats(): TerminalStats; get screenText(): string; get sizing(): TerminalSizingState; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map index c655b2363fe..cace0ea2b6d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,oBAAoB,EAAE,kBAAkB,EAAiB,YAAY,EACjF,kBAAkB,EAAE,gBAAgB,EAAE,aAAa,EAAE,oBAAoB,EAAE,YAAY,EAC3D,iBAAiB,EAAE,cAAc,EAAE,mBAAmB,EAAE,aAAa,EAAE,gBAAgB,EACnH,gBAAgB,EAAE,wBAAwB,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAGxG,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAQrF;;;GAGG;AACH,qBAAa,WAAY,YAAW,iBAAiB;;IACnD,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAC;IAiDjC,gGAAgG;WACnF,KAAK,CAAC,SAAS,EAAE,WAAW,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,WAAW,CAAC;IAqB7F,OAAO;IAqBP,IAAI,QAAQ,IAAI,gBAAgB,CAAkC;IAClE,IAAI,IAAI,IAAI,YAAY,CAA8B;IACtD,IAAI,SAAS,YAA8B;IAC3C,IAAI,QAAQ,IAAI,OAAO,CAA2B;IAClD,iGAAiG;IACjG,IAAI,KAAK,IAAI,MAAM,CAAwB;IAC3C,IAAI,QAAQ,IAAI,gBAAgB,CAAkC;IAClE,IAAI,gBAAgB,IAAI,wBAAwB,CAA0C;IAC1F,IAAI,KAAK,IAAI,aAAa,CAA+B;IACzD,IAAI,UAAU,WAA+B;IAC7C,IAAI,MAAM,IAAI,mBAAmB,CAAgC;IACjE,IAAI,aAAa,IAAI,YAAY,EAAE,CAAkC;IACrE,IAAI,QAAQ,IAAI,gBAAgB,CAI/B;IACD,IAAI,SAAS,IAAI,iBAAiB,CAIjC;IAsSD,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAChC,YAAY;IACZ,cAAc;IAEd,8EAA8E;IAC9E,kBAAkB;IAyBZ,aAAa,CAAC,EAAE,KAAa,EAAE,GAAE,oBAAyB,GAAG,OAAO,CAAC,MAAM,CAAC;IAsClF,IAAI,YAAY,IAAI,oBAAoB,CAOvC;IAmBD,4GAA4G;IAC5G,SAAS,CAAC,MAAM,EAAE,eAAe,EAAE,IAAI,CAAC,EAAE,oBAAoB,EAAE,KAAK,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC;IACvG,SAAS,CAAC,MAAM,EAAE,gBAAgB,EAAE,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC;IAC7F,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IACtG,SAAS,CAAC,MAAM,EAAE,gBAAgB,GAAG,cAAc,EAAE,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAC5G,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IACpF,SAAS,CAAC,IAAI,SAAS,MAAM,EAAE,MAAM,EAAE,IAAI,SAAS,kBAAkB,GAAG,KAAK,GAAG,IAAI,EACnF,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC;IAC1D,SAAS,CAAC,MAAM,EAAE,kBAAkB,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC;IAoC9F,+EAA+E;IAC/E,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAMnB,cAAc,IAAI,OAAO,CAAC,MAAM,CAAC;IAgBvC,mGAAmG;IACnG,WAAW,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI;IAsGpC,KAAK;IAML,8EAA8E;IAC9E,cAAc;IAad,8EAA8E;IAC9E,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAQ3C,8FAA8F;IAC9F,SAAS,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAcvC,MAAM;IAqBN,2EAA2E;IAC3E,OAAO;CAcR"} \ No newline at end of file +{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js index 11824ea740d..f826cbb5772 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js @@ -53,6 +53,8 @@ export class WebTerminal { #hasTitle = false; #progress = { state: "none", percentage: null }; #shellIntegration = { phase: "unknown", lastExitCode: null }; + #workingDirectory = { uri: null, host: null, path: null }; + #commandMark = null; #hasActivity = false; #history; #highlights; @@ -122,6 +124,8 @@ export class WebTerminal { get title() { return this.#title; } get progress() { return { ...this.#progress }; } get shellIntegration() { return { ...this.#shellIntegration }; } + get workingDirectory() { return { ...this.#workingDirectory }; } + get commandMark() { return this.#commandMark ? { ...this.#commandMark } : null; } get stats() { return { ...this.#stats }; } get screenText() { return this.#screenText; } get sizing() { return { ...this.#sizing }; } @@ -356,10 +360,17 @@ export class WebTerminal { this.#progress.percentage !== message.progress.percentage; const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase || this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode; + const workingDirectoryChanged = !this.#hasActivity || + this.#workingDirectory.uri !== message.workingDirectory.uri; + const commandMarkChanged = !this.#hasActivity || this.#commandMark?.phase !== message.commandMark?.phase || + this.#commandMark?.exitCode !== message.commandMark?.exitCode || + this.#commandMark?.rawParameters !== message.commandMark?.rawParameters; this.#title = message.title; this.#hasTitle = true; this.#progress = { ...message.progress }; this.#shellIntegration = { ...message.shellIntegration }; + this.#workingDirectory = { ...message.workingDirectory }; + this.#commandMark = message.commandMark ? { ...message.commandMark } : null; this.#hasActivity = true; if (titleChanged) this.#options.onTitleChange?.(this.#title); @@ -367,6 +378,10 @@ export class WebTerminal { this.#options.onProgressChange?.(this.progress); if (!this.#disposed && shellChanged) this.#options.onShellIntegrationChange?.(this.shellIntegration); + if (!this.#disposed && workingDirectoryChanged) + this.#options.onWorkingDirectoryChange?.(this.workingDirectory); + if (!this.#disposed && commandMarkChanged) + this.#options.onCommandMarkChange?.(this.commandMark); } } else if (message.type === "history") { diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map index a517c320c01..661808d7e7c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n TerminalProgress, TerminalShellIntegration, WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #closed = false;\n #readOnly: boolean;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #resetComposition: (() => void) | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #title = \"\";\n #hasTitle = false;\n #progress: TerminalProgress = { state: \"none\", percentage: null };\n #shellIntegration: TerminalShellIntegration = { phase: \"unknown\", lastExitCode: null };\n #hasActivity = false;\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n if (options.readOnly !== undefined && typeof options.readOnly !== \"boolean\")\n throw new TypeError(\"readOnly must be a boolean\");\n this.#readOnly = options.readOnly ?? false;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get readOnly(): boolean { return this.#readOnly; }\n /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */\n get title(): string { return this.#title; }\n get progress(): TerminalProgress { return { ...this.#progress }; }\n get shellIntegration(): TerminalShellIntegration { return { ...this.#shellIntegration }; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: this.#readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: this.#readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,\n openHyperlink: uri => {\n try { window.open(uri, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n }\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"closed\") {\n if (this.#closed) return;\n this.#closed = true;\n clearTimeout(this.#readyTimer);\n try {\n this.#disconnect();\n if (!this.#disposed) this.#options.onClose?.(Object.freeze({ ...message.details }));\n } finally {\n this.#ready.reject(new Error(`Terminal WebSocket closed (${message.details.code}${\n message.details.reason ? `: ${message.details.reason}` : \"\"}) before mounting completed`));\n }\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n const titleChanged = !this.#hasTitle || this.#title !== message.title;\n const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state ||\n this.#progress.percentage !== message.progress.percentage;\n const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase ||\n this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode;\n this.#title = message.title;\n this.#hasTitle = true;\n this.#progress = { ...message.progress };\n this.#shellIntegration = { ...message.shellIntegration };\n this.#hasActivity = true;\n if (titleChanged) this.#options.onTitleChange?.(this.#title);\n if (!this.#disposed && progressChanged) this.#options.onProgressChange?.(this.progress);\n if (!this.#disposed && shellChanged) this.#options.onShellIntegrationChange?.(this.shellIntegration);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected &&\n (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#canInput() || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#canInput()) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly && [\"input\", \"paste\", \"key\", \"mouse\", \"resize\", \"requestPrimary\"].includes(command.type))\n throw new Error(\"Terminal view does not accept input\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: !this.#readOnly && this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: this.#readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try {\n const decision = this.#policy.resolve(Object.freeze(input), this.inputContext);\n if (this.#readOnly && decision.route === InputRoute.Application)\n return { route: input.type === \"pointer\" || input.type === \"wheel\" ? InputRoute.Continue : InputRoute.Consume };\n return decision;\n }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */\n setReadOnly(readOnly: boolean): void {\n if (typeof readOnly !== \"boolean\") throw new TypeError(\"readOnly must be a boolean\");\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n if (this.#readOnly === readOnly) return;\n const inputFocused = document.activeElement === this.element &&\n (!this.element.shadowRoot?.activeElement || this.element.shadowRoot.activeElement === this.#input);\n this.#readOnly = readOnly;\n this.#inputSerial++;\n this.#resetComposition?.();\n if (this.#input) {\n this.#input.value = \"\";\n this.#input.disabled = !this.#canInput();\n }\n // Set the policy before cancelling so pending moves and button releases cannot leak.\n this.#mouse?.cancel();\n this.#mouse?.refresh();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#queueResize(true);\n if (inputFocused) this.focus();\n this.#selectionUI?.refresh();\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.#resetComposition = () => {\n composing = false;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n };\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n if (!this.#canInput()) return;\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n if (!composing) return;\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file +{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC/F,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAC5F,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAQ7C,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAErF,SAAS,eAAe,CAAoB,IAAgB,EAAE,QAAgB,EAAE,IAAiB;IAC/F,MAAM,OAAO,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IAC7C,IAAI,CAAC,CAAC,OAAO,YAAY,IAAI,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,QAAQ,EAAE,CAAC,CAAC;IACzF,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;GAGG;AACH,MAAM,OAAO,WAAW;IACb,OAAO,CAAiB;IACjC,QAAQ,CAAqB;IAC7B,SAAS,CAA6B;IACtC,8EAA8E;IAC9E,OAAO,CAAU;IACjB,QAAQ,CAAkB;IAC1B,OAAO,CAAqB;IAC5B,MAAM,CAAuB;IAC7B,MAAM,CAA2B;IACjC,SAAS,CAA6B;IACtC,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACnC,KAAK,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IAChC,OAAO,GAAG,eAAe,EAAE,CAAC;IAC5B,SAAS,GAAqB,EAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,aAAa,EAAE,CAAC,EAAE,CAAC;IACzG,KAAK,GAAiB,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IACtE,UAAU,GAAG,KAAK,CAAC;IACnB,OAAO,GAAG,KAAK,CAAC;IAChB,SAAS,CAAU;IACnB,SAAS,GAAG,KAAK,CAAC;IAClB,YAAY,GAAG,KAAK,CAAC;IACrB,YAAY,CAA4C;IACxD,cAAc,CAAqB;IACnC,iBAAiB,CAA4C;IAC7D,iBAAiB,CAA2B;IAC5C,MAAM,GAAG,OAAO,CAAC,aAAa,EAAe,CAAC;IAC9C,WAAW,CAA4C;IACvD,MAAM,GAAkB,EAAE,CAAC;IAC3B,WAAW,GAAG,EAAE,CAAC;IACjB,MAAM,GAAG,EAAE,CAAC;IACZ,SAAS,GAAG,KAAK,CAAC;IAClB,SAAS,GAAqB,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;IAClE,iBAAiB,GAA6B,EAAE,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC;IACvF,iBAAiB,GAA6B,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IACpF,YAAY,GAA+B,IAAI,CAAC;IAChD,YAAY,GAAG,KAAK,CAAC;IACrB,QAAQ,CAAe;IACvB,WAAW,CAAkB;IAC7B,WAAW,CAAkB;IAC7B,gBAAgB,GAAG,EAAE,CAAC;IACtB,WAAW,GAAG,CAAC,CAAC;IAChB,QAAQ,GAAG,KAAK,CAAC;IACjB,OAAO,CAAc;IACrB,QAAQ,CAAkC;IAC1C,gBAAgB,GAAG,KAAK,CAAC;IACzB,YAAY,GAAG,CAAC,CAAC;IACjB,YAAY,CAA0B;IACtC,iBAAiB,CAAkB;IACnC,iBAAiB,GAAG,EAAE,CAAC;IACvB,WAAW,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IACtC,WAAW,GAAG,IAAI,UAAU,EAAE,CAAC;IAE/B,gGAAgG;IAChG,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,SAAsB,EAAE,OAA2B;QACpE,IAAI,CAAC,CAAC,SAAS,YAAY,WAAW,CAAC;YAAE,MAAM,IAAI,SAAS,CAAC,8CAA8C,CAAC,CAAC;QAC7G,IAAI,CAAC,OAAO,EAAE,GAAG;YAAE,MAAM,IAAI,SAAS,CAAC,sCAAsC,CAAC,CAAC;QAC/E,IAAI,OAAO,CAAC,MAAM,EAAE,OAAO;YAAE,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QACzD,IAAI,iBAAiB,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,CAAC,eAAe,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC;YACpG,MAAM,IAAI,KAAK,CAAC,uEAAuE,CAAC,CAAC;QAC3F,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,cAAc,IAAI,CAAC,MAAM,CAAC,eAAe;YACnE,CAAC,iBAAiB,CAAC,SAAS,CAAC,0BAA0B,EAAE,CAAC;YAC5D,MAAM,IAAI,KAAK,CAAC,yFAAyF,CAAC,CAAC;QAC7G,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC;QAC1C,IAAI,CAAC;YACH,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;YACvG,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,QAAQ,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,YAAoB,OAA2B;QAC7C,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;QACxB,IAAI,OAAO,CAAC,QAAQ,KAAK,SAAS,IAAI,OAAO,OAAO,CAAC,QAAQ,KAAK,SAAS;YACzE,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACpD,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC;QAC3C,IAAI,CAAC,SAAS,GAAG,iBAAiB,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QACrD,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC,CAAC,OAAO,CAAC,SAAS,YAAY,GAAG,CAAC;YACtE,CAAC,OAAO,OAAO,CAAC,SAAS,KAAK,QAAQ,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;YACtE,MAAM,IAAI,SAAS,CAAC,gDAAgD,CAAC,CAAC;QACxE,IAAI,OAAO,CAAC,aAAa,KAAK,SAAS,IAAI,CAAC,OAAO,OAAO,CAAC,aAAa,KAAK,UAAU;YACnF,OAAO,CAAC,aAAa,CAAC,WAAW,CAAC,IAAI,KAAK,eAAe,CAAC;YAC7D,MAAM,IAAI,SAAS,CAAC,mDAAmD,CAAC,CAAC;QAC3E,IAAI,CAAC,OAAO,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC;QACxC,IAAI,CAAC,QAAQ,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC;QAC/D,IAAI,CAAC,QAAQ,GAAG,IAAI,YAAY,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,kBAAkB,EAAE,CAAC,CAAC;QAClG,IAAI,CAAC,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC7C,IAAI,CAAC,OAAO,CAAC,SAAS,GAAG,gBAAgB,CAAC;QAC1C,IAAI,CAAC,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC;QAC3B,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,GAAG,gEAAgE,CAAC;IAChG,CAAC;IAED,IAAI,QAAQ,KAAuB,OAAO,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;IAClE,IAAI,IAAI,KAAmB,OAAO,EAAE,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IACtD,IAAI,SAAS,KAAK,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;IAC3C,IAAI,QAAQ,KAAc,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC;IAClD,iGAAiG;IACjG,IAAI,KAAK,KAAa,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IAC3C,IAAI,QAAQ,KAAuB,OAAO,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;IAClE,IAAI,gBAAgB,KAA+B,OAAO,EAAE,GAAG,IAAI,CAAC,iBAAiB,EAAE,CAAC,CAAC,CAAC;IAC1F,IAAI,gBAAgB,KAA+B,OAAO,EAAE,GAAG,IAAI,CAAC,iBAAiB,EAAE,CAAC,CAAC,CAAC;IAC1F,IAAI,WAAW,KAAiC,OAAO,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7G,IAAI,KAAK,KAAoB,OAAO,EAAE,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACzD,IAAI,UAAU,KAAK,OAAO,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC;IAC7C,IAAI,MAAM,KAA0B,OAAO,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;IACjE,IAAI,aAAa,KAAqB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IACrE,IAAI,QAAQ;QACV,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACxC,OAAO,EAAE,GAAG,QAAQ,EAAE,UAAU,EAAE,QAAQ,CAAC,SAAS;YAClD,MAAM,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACvE,CAAC;IACD,IAAI,SAAS;QACX,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC;QAC1C,OAAO,EAAE,GAAG,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,MAAM,KAAK,OAAO,EAAE,OAAO,EAAE,SAAS,CAAC,MAAM,KAAK,SAAS;YAClG,OAAO,EAAE,IAAI,CAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,gBAAgB,EAAE,CAAC;IAC/D,CAAC;IAED,MAAM,CAAC,SAAsB;QAC3B,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO;YAAE,MAAM,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC;QACrE,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;QACtD,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ;YAAE,GAAG,CAAC,QAAQ,GAAG,MAAM,CAAC;QACrD,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;YAAE,GAAG,CAAC,QAAQ,GAAG,KAAK,CAAC;QACnD,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC;YAAE,MAAM,IAAI,SAAS,CAAC,+BAA+B,CAAC,CAAC;QAClG,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,KAAK,SAAS,IAAI,IAAI,CAAC,QAAQ,CAAC,KAAK,KAAK,MAAM;YAC/E,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,gBAAgB,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QACnF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,GAAG,IAAI,KAAK,GAAG,CAAC;YAAE,MAAM,IAAI,UAAU,CAAC,uCAAuC,CAAC,CAAC;QACvH,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC9D,IAAI,CAAC,OAAO,GAAG,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACrD,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC3D,MAAM,CAAC,SAAS,GAAG;;UAEb,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBA4BP,CAAC;QAChB,IAAI,CAAC,QAAQ,GAAG,eAAe,CAAC,MAAM,EAAE,UAAU,EAAE,cAAc,CAAC,CAAC;QACpE,IAAI,CAAC,OAAO,GAAG,eAAe,CAAC,MAAM,EAAE,QAAQ,EAAE,iBAAiB,CAAC,CAAC;QACpE,IAAI,CAAC,MAAM,GAAG,eAAe,CAAC,MAAM,EAAE,UAAU,EAAE,mBAAmB,CAAC,CAAC;QACvE,IAAI,CAAC,WAAW,GAAG,eAAe,CAAC,MAAM,EAAE,aAAa,EAAE,cAAc,CAAC,CAAC;QAC1E,IAAI,CAAC,WAAW,GAAG,eAAe,CAAC,MAAM,EAAE,aAAa,EAAE,cAAc,CAAC,CAAC;QAC1E,IAAI,CAAC,iBAAiB,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACvD,IAAI,CAAC,iBAAiB,CAAC,IAAI,GAAG,cAAc,CAAC;QAC7C,IAAI,CAAC,iBAAiB,CAAC,SAAS,GAAG,yBAAyB,CAAC;QAC7D,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC,OAAO,GAAG,8DAA8D,CAAC;QACtG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QAC5C,IAAI,CAAC,YAAY,GAAG,IAAI,WAAW,CAAC;YAClC,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,iBAAiB;YACtD,MAAM,EAAE,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,iBAAiB,EAAE,iBAAiB,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM;YAC/G,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ;gBAC5F,UAAU,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC;YACvF,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;YACpC,aAAa,EAAE,IAAI,CAAC,QAAQ,CAAC,aAAa;YAC1C,WAAW,EAAE,KAAK,CAAC,EAAE;gBACnB,IAAI,CAAC,iBAAiB,GAAG,KAAK,CAAC,CAAC,CAAC,wBAAwB,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACpF,IAAI,CAAC,iBAAiB,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,CAAC;gBACxC,IAAI,CAAC,uBAAuB,EAAE,CAAC;gBAC/B,IAAI,KAAK;oBAAE,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,iBAAiB,EAAE,OAAO,CAAC,CAAC;YACvE,CAAC;SACF,CAAC,CAAC;QACH,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,YAAY,EAAE,IAAI,CAAC,QAAQ,CAAC,KAAK,IAAI,qDAAqD,CAAC,CAAC;QACrH,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,CAAC;QAC5B,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC/B,MAAM,OAAO,GAAG,CAAC,SAAqB,EAAE,EAAE;YACxC,IAAI,CAAC;gBAAC,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;gBAAC,SAAS,EAAE,CAAC;YAAC,CAAC;YAChD,OAAO,KAAK,EAAE,CAAC;gBAAC,IAAI,CAAC,gBAAgB,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;gBAAC,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAAC,CAAC;QAC3F,CAAC,CAAC;QACF,IAAI,CAAC,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE;YACnG,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,SAAS;gBACrG,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC;YAC9B,KAAK,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;YACjF,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YAC3D,MAAM,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;YACjF,GAAG,EAAE,SAAS,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,SAAS,CAAC;YACrD,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC;YAC3C,OAAO,EAAE,CAAC,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,mBAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC;YACvE,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI;YACjG,aAAa,EAAE,GAAG,CAAC,EAAE;gBACnB,IAAI,CAAC;oBAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,qBAAqB,CAAC,CAAC;gBAAC,CAAC;gBAC1D,OAAO,KAAK,EAAE,CAAC;oBAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;gBAAC,CAAC;YAC9C,CAAC;SACF,CAAC,CAAC;QACH,IAAI,CAAC,aAAa,EAAE,CAAC;QACrB,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,cAAc,EAAE,iBAAiB,CAAC,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE;YAClG,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;QACxF,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QACvC,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,iBAAiB,EAAE,iBAAiB,CAAC,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE;YACrG,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,aAAa,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;QACzF,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QACvC,eAAe,CAAC,MAAM,EAAE,WAAW,EAAE,cAAc,CAAC,CAAC,gBAAgB,CAAC,aAAa,EAAE,KAAK,CAAC,EAAE;YAC3F,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC;gBAAE,OAAO;YAClE,IAAI,CAAC,KAAK,CAAC,MAAM,YAAY,OAAO,IAAI,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;gBACnE,KAAK,CAAC,MAAM,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;gBAAE,OAAO;YAC1E,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,IAAI,CAAC,KAAK,EAAE,CAAC;QACf,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QACvC,IAAI,CAAC,SAAS,GAAG,IAAI,cAAc,CAAC,OAAO,CAAC,EAAE;YAC5C,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC;YACjD,MAAM,OAAO,GAAG,KAAK,KAAK,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,MAAM,KAAK,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;YAC3E,IAAI,CAAC,KAAK,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;YAC/B,IAAI,CAAC,IAAI,EAAE,CAAC;YACZ,IAAI,OAAO;gBAAE,IAAI,CAAC,YAAY,EAAE,CAAC;QACnC,CAAC,CAAC,CAAC;QACH,kEAAkE;QAClE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAClC,MAAM,CAAC,gBAAgB,CAAC,UAAU,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QAC9F,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QACtH,IAAI,CAAC,WAAW,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;QACtH,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,SAAS,KAAK,SAAS;YAClD,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,GAAG,CAAC,sBAAsB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,mBAAmB,EAAE,CAAC;YAC7G,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,mBAAmB,EAAE,CAAC,CAAC;QAC/G,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,KAAwC,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;QAClH,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE;YAC7C,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,OAAO,IAAI,wBAAwB,CAAC,CAAC,CAAC;QACnE,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,cAAc,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,8CAA8C,CAAC,CAAC,CAAC,CAAC;QAC3H,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,0BAA0B,EAAE,CAAC;QACzD,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI;YAC3D,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IAC1C,CAAC;IAED,QAAQ,CAAC,OAA4B;QACnC,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO;QAC3B,IAAI,OAAO,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;YACjC,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;YACvB,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;QAC3C,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACrC,IAAI,IAAI,CAAC,OAAO;gBAAE,OAAO;YACzB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;YACpB,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YAC/B,IAAI,CAAC;gBACH,IAAI,CAAC,WAAW,EAAE,CAAC;gBACnB,IAAI,CAAC,IAAI,CAAC,SAAS;oBAAE,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;YACtF,CAAC;oBAAS,CAAC;gBACT,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,8BAA8B,OAAO,CAAC,OAAO,CAAC,IAAI,GAC7E,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,6BAA6B,CAAC,CAAC,CAAC;YAC/F,CAAC;QACH,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACrC,IAAI,OAAO,CAAC,KAAK,KAAK,OAAO,EAAE,CAAC;gBAC9B,IAAI,CAAC,WAAW,EAAE,CAAC;gBACnB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;YACjD,CAAC;YACD,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;QAC3D,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YACvC,MAAM,KAAK,GAAG,CAAC,IAAI,CAAC,YAAY,CAAC;YACjC,MAAM,eAAe,GAAG,KAAK,IAAK,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,CAAW;iBACxG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;YAC3D,IAAI,CAAC,SAAS,GAAG;gBACf,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI;gBAC5C,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,aAAa,EAAE,OAAO,CAAC,aAAa;aACnG,CAAC;YACF,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;YACzB,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC;YAC3B,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;YAC1B,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACzC,IAAI,IAAI,CAAC,SAAS,EAAE,IAAI,QAAQ,CAAC,aAAa,KAAK,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,aAAa;gBAAE,IAAI,CAAC,KAAK,EAAE,CAAC;YACzH,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;YAC5C,IAAI,eAAe,IAAI,OAAO,CAAC,SAAS,KAAK,IAAI,CAAC,KAAK,CAAC,SAAS;gBAAE,IAAI,CAAC,IAAI,EAAE,CAAC;YAC/E,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC;gBAC1B,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;gBAChC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;gBAC9B,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;YAClC,CAAC;YACD,IAAI,KAAK,IAAI,CAAC,CAAC,OAAO,CAAC,SAAS,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC;gBAAE,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;YACnF,IAAI,IAAI,CAAC,cAAc,KAAK,GAAG,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,IAAI,EAAE;gBAAE,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;YAClG,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;gBACtC,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;gBAChC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;YAC1D,CAAC;YACD,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;YAC1E,IAAI,eAAe;gBAAE,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/D,IAAI,KAAK;gBAAE,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACvD,IAAI,OAAO,CAAC,EAAE,KAAK,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,OAAO,CAAC,SAAS,KAAK,IAAI,CAAC,KAAK,CAAC,SAAS,IAAI,OAAO,CAAC,SAAS,KAAK,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC;gBAC7H,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAC1C,CAAC;YACD,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;gBAC3F,MAAM,YAAY,GAAG,CAAC,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,MAAM,KAAK,OAAO,CAAC,KAAK,CAAC;gBACtE,MAAM,eAAe,GAAG,CAAC,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,SAAS,CAAC,KAAK,KAAK,OAAO,CAAC,QAAQ,CAAC,KAAK;oBAC3F,IAAI,CAAC,SAAS,CAAC,UAAU,KAAK,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;gBAC5D,MAAM,YAAY,GAAG,CAAC,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,iBAAiB,CAAC,KAAK,KAAK,OAAO,CAAC,gBAAgB,CAAC,KAAK;oBACxG,IAAI,CAAC,iBAAiB,CAAC,YAAY,KAAK,OAAO,CAAC,gBAAgB,CAAC,YAAY,CAAC;gBAChF,MAAM,uBAAuB,GAAG,CAAC,IAAI,CAAC,YAAY;oBAChD,IAAI,CAAC,iBAAiB,CAAC,GAAG,KAAK,OAAO,CAAC,gBAAgB,CAAC,GAAG,CAAC;gBAC9D,MAAM,kBAAkB,GAAG,CAAC,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,YAAY,EAAE,KAAK,KAAK,OAAO,CAAC,WAAW,EAAE,KAAK;oBACtG,IAAI,CAAC,YAAY,EAAE,QAAQ,KAAK,OAAO,CAAC,WAAW,EAAE,QAAQ;oBAC7D,IAAI,CAAC,YAAY,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,EAAE,aAAa,CAAC;gBAC1E,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;gBAC5B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;gBACtB,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;gBACzC,IAAI,CAAC,iBAAiB,GAAG,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;gBACzD,IAAI,CAAC,iBAAiB,GAAG,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;gBACzD,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;gBAC5E,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;gBACzB,IAAI,YAAY;oBAAE,IAAI,CAAC,QAAQ,CAAC,aAAa,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;gBAC7D,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,eAAe;oBAAE,IAAI,CAAC,QAAQ,CAAC,gBAAgB,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;gBACxF,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,YAAY;oBAAE,IAAI,CAAC,QAAQ,CAAC,wBAAwB,EAAE,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;gBACrG,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,uBAAuB;oBAAE,IAAI,CAAC,QAAQ,CAAC,wBAAwB,EAAE,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;gBAChH,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,kBAAkB;oBAAE,IAAI,CAAC,QAAQ,CAAC,mBAAmB,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YACnG,CAAC;QACH,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YACtC,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;YAChC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC1D,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YACpC,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;YAC5B,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;gBAAE,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;YAChE,IAAI,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,CAAC,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,UAAU;gBACpF,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;gBACrD,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;gBAC/B,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC5B,CAAC;YACD,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;IAED,IAAI;QACF,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;QAChE,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC;QAC/D,MAAM,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QAC1F,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,GAAG,GAAG,KAAK,GAAG,KAAK,IAAI,CAAC;QACjD,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,GAAG,GAAG,MAAM,GAAG,KAAK,IAAI,CAAC;QACnD,2EAA2E;QAC3E,MAAM,KAAK,GAAG,gBAAgB,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC9C,IAAI,CAAC,WAAW,GAAG,EAAE,KAAK,EAAE,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;QACtG,IAAI,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC;QAC7B,MAAM,GAAG,GAAG,MAAM,CAAC,gBAAgB,IAAI,CAAC,CAAC;QACzC,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,KAAK,GAAG,GAAG,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,KAAK,GAAG,GAAG,CAAC,EAAE,CAAC,CAAC;IACnH,CAAC;IAED,WAAW;QACT,OAAO,aAAa,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACjE,CAAC;IAED,YAAY,CAAC,YAAY,GAAG,KAAK;QAC/B,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,KAAK,OAAO,IAAI,CAAC,YAAY;YAAE,OAAO;QAC3D,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,IAAI,IAAI,CAAC,YAAY,KAAK,SAAS,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO;QAC5G,iFAAiF;QACjF,IAAI,CAAC,YAAY,GAAG,UAAU,CAAC,GAAG,EAAE;YAClC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;YAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;YAChC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;gBAAE,OAAO;YAChE,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;YAC3C,IAAI,CAAC,IAAI,CAAC,OAAO,KAAK,IAAI,CAAC,SAAS,CAAC,OAAO,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,IAAI,CAAC,cAAc;gBAAE,OAAO;YAC1H,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACvC,CAAC,EAAE,EAAE,CAAC,CAAC;IACT,CAAC;IAED,KAAK,CAAC,OAA2B,EAAE,WAA2B,EAAE;QAC9D,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC/C,CAAC;IAED,KAAK,CAAC,OAAwB;QAC5B,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;QAC1F,IAAI,IAAI,CAAC,SAAS,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,gBAAgB,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC;YACzG,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACzD,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,aAAa,CAAC,OAAqB;QACjC,IAAI,IAAI,CAAC,SAAS,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,OAAO;QAChD,iBAAiB,CAAC,OAAO,CAAC,CAAC;QAC3B,IAAI,CAAC,YAAY,EAAE,CAAC;QACpB,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC;YAChF,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;YACtB,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,KAAK,MAAM;gBAAE,IAAI,CAAC,cAAc,EAAE,CAAC;YAC5D,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO;gBAAE,IAAI,CAAC,YAAY,EAAE,CAAC;QAC7E,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACtB,CAAC;IAED,kBAAkB;QAChB,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC/B,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QACjC,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACvB,IAAI,SAAS,CAAC,MAAM,KAAK,aAAa;YAAE,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;QAC9D,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,IAAI,CAAC,WAAW,CAAC,eAAe,CAAC,GAAG,SAAS,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE;gBAC/D,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;gBAC/C,OAAO,CAAC,SAAS,GAAG,WAAW,CAAC;gBAChC,OAAO,CAAC,YAAY,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;gBACpD,OAAO,CAAC,KAAK,CAAC,OAAO,GAAG,QAAQ,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,GAAG,GAAG,SAAS,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,GAAG,GAAG,WAAW,CAAC,KAAK,CAAC,SAAS,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,GAAG,GAAG,YAAY,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,GAAG,CAAC;gBACtP,OAAO,OAAO,CAAC;YACjB,CAAC,CAAC,CAAC,CAAC;YACJ,MAAM,IAAI,GAAG,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,cAAc,EAAE,iBAAiB,CAAC,CAAC;YAClF,IAAI,CAAC,MAAM,GAAG,CAAC,QAAQ,CAAC,SAAS,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YAC/E,IAAI,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC;YACjC,IAAI,CAAC,uBAAuB,EAAE,CAAC;QACjC,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE,IAAI,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC;QAClD,IAAI,CAAC,QAAQ,CAAC,gBAAgB,EAAE,CAAC,QAAQ,CAAC,CAAC;QAC3C,IAAI,CAAC,QAAQ,CAAC,iBAAiB,EAAE,CAAC,SAAS,CAAC,CAAC;IAC/C,CAAC;IAED,WAAW,CAAC,KAAa,IAAU,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACjE,YAAY,KAAK,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IACxC,cAAc,KAAK,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAEvE,8EAA8E;IAC9E,kBAAkB;QAChB,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;QACjE,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,CAAC;IAED,uBAAuB;QACrB,IAAI,CAAC,IAAI,CAAC,WAAW;YAAE,OAAO;QAC9B,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QACjC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC/B,MAAM,MAAM,GAAG,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,qBAAqB,EAAE,eAAe,CAAC,CAAC;QACzF,MAAM,CAAC,WAAW,GAAG,IAAI,CAAC,iBAAiB,IAAI,IAAI,CAAC,gBAAgB;YAClE,CAAC,SAAS,CAAC,MAAM,KAAK,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC;YAC7D,CAAC,QAAQ,CAAC,SAAS,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,OAAO,GAAG,QAAQ,CAAC,GAAG,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC1G,MAAM,CAAC,MAAM,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC;QACpC,MAAM,CAAC,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC,iBAAiB,IAAI,IAAI,CAAC,gBAAgB;YACpE,SAAS,CAAC,MAAM,KAAK,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;IAC1D,CAAC;IAED,aAAa,CAAC,KAAc;QAC1B,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QAC1E,IAAI,CAAC,gBAAgB,GAAG,wBAAwB,OAAO,CAAC,OAAO,EAAE,CAAC;QAClE,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC1B,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,OAAO,CAAC,CAAC;IACxC,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,EAAE,KAAK,GAAG,KAAK,KAA2B,EAAE;QAC9D,IAAI,OAAO,KAAK,KAAK,SAAS;YAAE,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACpF,MAAM,MAAM,GAAG,EAAE,IAAI,CAAC,WAAW,CAAC;QAClC,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;QAC7C,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC;QAC5C,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;QAC3B,IAAI,CAAC;YACH,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,SAAS;gBAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;YAC1F,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,KAAK,IAAI,OAAO,aAAa,KAAK,UAAU,EAAE,CAAC;gBACvE,MAAM,IAAI,KAAK,CAAC,2FAA2F,CAAC,CAAC;YAC/G,CAAC;YACD,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;YAClC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;YACrB,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC1B,iGAAiG;YACjG,MAAM,IAAI,GAAG,IAAI,aAAa,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;YAChH,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YAC7E,IAAI,KAAK,IAAI,MAAM,KAAK,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,KAAK,OAAO;gBACzE,IAAI,CAAC,SAAS,CAAC,SAAS,KAAK,WAAW,IAAI,IAAI,CAAC,QAAQ,CAAC,UAAU,KAAK,UAAU;gBACrF,IAAI,CAAC,cAAc,EAAE,CAAC;YACxB,OAAO,KAAK,CAAC;QACf,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,MAAM,KAAK,IAAI,CAAC,WAAW,EAAE,CAAC;gBAChC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;gBAChC,IAAI,CAAC,gBAAgB,GAAG,gBAAgB,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;YAChE,CAAC;YACD,MAAM,KAAK,CAAC;QACd,CAAC;gBAAS,CAAC;YACT,IAAI,MAAM,KAAK,IAAI,CAAC,WAAW;gBAAE,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;YACvD,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC5B,CAAC;IACH,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,YAAY,IAAI,CAAC,IAAI,CAAC,SAAS;YAC5D,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACrD,CAAC;IAED,IAAI,YAAY;QACd,OAAO,MAAM,CAAC,MAAM,CAAC;YACnB,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ;YAClE,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,IAAI,IAAI,EAAE,aAAa,EAAE,CAAC,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,SAAS,CAAC,aAAa,KAAK,CAAC;YAC1G,UAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO;YAC7D,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI;SACtE,CAAC,CAAC;IACL,CAAC;IAED,aAAa,CAAC,KAAoB;QAChC,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;YAC/E,IAAI,IAAI,CAAC,SAAS,IAAI,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,WAAW;gBAC7D,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC;YAClH,OAAO,QAAQ,CAAC;QAClB,CAAC;QACD,OAAO,KAAK,EAAE,CAAC;YACb,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YAC1B,OAAO,EAAE,KAAK,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC;QACvC,CAAC;IACH,CAAC;IAED,mBAAmB,CAAC,QAAqD,EAAE,KAAoB;QAC7F,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IACvG,CAAC;IAWD,KAAK,CAAC,SAAS,CAAC,MAAmC,EAAE,IAAc,EAAE,KAAqB;QACxF,OAAO,IAAI,CAAC,cAAc,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,cAAc,CAAC,MAAmC,EAAE,IAAc,EAAE,KAAqB;QAC7F,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;QACjE,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;QAC3B,IAAI,OAAO,MAAM,KAAK,UAAU;YAAE,OAAO,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QAChF,MAAM,YAAY,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC/C,IAAI,YAAY;YAAE,OAAO,YAAY,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QACtE,QAAQ,MAAM,EAAE,CAAC;YACf,KAAK,cAAc,CAAC,aAAa;gBAC/B,IAAI,IAAI,KAAK,SAAS;oBAAE,OAAO,IAAI,CAAC,aAAa,EAAE,CAAC;gBACpD,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;oBAAE,MAAM,IAAI,SAAS,CAAC,gCAAgC,CAAC,CAAC;gBAC3E,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,IAAI,OAAO,IAAI,CAAC,KAAK,KAAK,SAAS;oBAAE,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;gBACrH,OAAO,IAAI,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;YACnD,KAAK,cAAc,CAAC,cAAc,CAAC,CAAC,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC;YACjE,KAAK,cAAc,CAAC,cAAc,CAAC,CAAC,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC;YACjE,KAAK,cAAc,CAAC,YAAY,CAAC,CAAC,OAAO,IAAI,CAAC,YAAY,EAAE,CAAC;YAC7D,KAAK,cAAc,CAAC,WAAW;gBAC7B,IAAI,OAAO,IAAI,KAAK,QAAQ;oBAAE,MAAM,IAAI,UAAU,CAAC,8CAA8C,CAAC,CAAC;gBACnG,OAAO,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;YAChC,KAAK,cAAc,CAAC,WAAW;gBAC7B,IAAI,IAAI,CAAC,gBAAgB;oBAAE,MAAM,IAAI,KAAK,CAAC,sEAAsE,CAAC,CAAC;gBACnH,IAAI,CAAC,gBAAgB,GAAG,IAAI,CAAC;gBAC7B,IAAI,CAAC;oBACH,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,IAAI,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;wBAC/E,OAAO,MAAM,IAAI,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;oBACnD,IAAI,CAAC,IAAI,CAAC,SAAS;wBAAE,OAAO,MAAM,IAAI,CAAC,cAAc,EAAE,CAAC;oBACxD,OAAO;gBACT,CAAC;wBAAS,CAAC;oBAAC,IAAI,CAAC,gBAAgB,GAAG,KAAK,CAAC;gBAAC,CAAC;YAC9C,OAAO,CAAC,CAAC,MAAM,IAAI,SAAS,CAAC,4BAA4B,MAAM,EAAE,CAAC,CAAC;QACrE,CAAC;IACH,CAAC;IAED,+EAA+E;IAC/E,KAAK,CAAC,IAAY;QAChB,IAAI,OAAO,IAAI,KAAK,QAAQ;YAAE,MAAM,IAAI,SAAS,CAAC,6BAA6B,CAAC,CAAC;QACjF,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC9E,IAAI,IAAI;YAAE,IAAI,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC9E,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,QAAQ;YAChC,MAAM,IAAI,KAAK,CAAC,6EAA6E,CAAC,CAAC;QACjG,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC;QACjC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC;QAC5C,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;QAC7C,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC;QACvC,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC;QAClD,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,MAAM,KAAK,IAAI,CAAC,YAAY,IAAI,UAAU,KAAK,IAAI,CAAC,QAAQ,CAAC,UAAU;YAC5F,WAAW,KAAK,IAAI,CAAC,SAAS,CAAC,SAAS,IAAI,QAAQ,CAAC,aAAa,KAAK,OAAO;YAChF,MAAM,IAAI,KAAK,CAAC,+FAA+F,CAAC,CAAC;QACnH,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACjB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,mGAAmG;IACnG,WAAW,CAAC,QAAiB;QAC3B,IAAI,OAAO,QAAQ,KAAK,SAAS;YAAE,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACrF,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;QACjE,IAAI,IAAI,CAAC,SAAS,KAAK,QAAQ;YAAE,OAAO;QACxC,MAAM,YAAY,GAAG,QAAQ,CAAC,aAAa,KAAK,IAAI,CAAC,OAAO;YAC1D,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,aAAa,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,aAAa,KAAK,IAAI,CAAC,MAAM,CAAC,CAAC;QACrG,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC;QAC1B,IAAI,CAAC,YAAY,EAAE,CAAC;QACpB,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC;QAC3B,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChB,IAAI,CAAC,MAAM,CAAC,KAAK,GAAG,EAAE,CAAC;YACvB,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;QAC3C,CAAC;QACD,qFAAqF;QACrF,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;QACtB,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACvB,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAChC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;QAC9B,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;QAChC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;QACxB,IAAI,YAAY;YAAE,IAAI,CAAC,KAAK,EAAE,CAAC;QAC/B,IAAI,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC;IAC/B,CAAC;IAED,aAAa,CAAC,KAAoB;QAChC,IAAI,KAAK,CAAC,IAAI,KAAK,KAAK,EAAE,CAAC;YACzB,IAAI,KAAK,CAAC,IAAI;gBAAE,MAAM,IAAI,KAAK,CAAC,kFAAkF,CAAC,CAAC;YACpH,IAAI,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC;QAC5G,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YAClC,IAAI,IAAI,CAAC,SAAS,EAAE;gBAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC/C,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YACjC,IAAI,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QAC1D,CAAC;IACH,CAAC;IAED,cAAc,CAAC,KAAoB,EAAE,KAAa,EAAE,gBAAgB,GAAG,IAAI;QACzE,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC3C,6FAA6F;QAC7F,IAAI,CAAC,gBAAgB,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS,IAAI,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,OAAO;YAAE,OAAO;QACxG,IAAI,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,OAAO;YACrC,CAAC,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,QAAQ,IAAI,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO;QAC7E,KAAK,EAAE,cAAc,EAAE,CAAC;QACxB,KAAK,EAAE,eAAe,EAAE,CAAC;QACzB,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS;YAAE,IAAI,CAAC,mBAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;aACxE,IAAI,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,OAAO,EAAE,CAAC;YAC/C,IAAI,CAAC;gBAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YAAC,CAAC;YAClC,OAAO,KAAK,EAAE,CAAC;gBAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YAAC,CAAC;QAC9C,CAAC;IACH,CAAC;IAED,aAAa;QACX,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC;QAC1B,MAAM,OAAO,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC;QACnD,IAAI,SAAS,GAAG,KAAK,CAAC;QACtB,IAAI,iBAAiB,GAAkB,IAAI,CAAC;QAC5C,IAAI,CAAC,iBAAiB,GAAG,GAAG,EAAE;YAC5B,SAAS,GAAG,KAAK,CAAC;YAClB,iBAAiB,GAAG,IAAI,CAAC;YACzB,YAAY,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACvC,CAAC,CAAC;QACF,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE;YAC/C,IAAI,KAAK,CAAC,gBAAgB;gBAAE,OAAO;YACnC,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC;gBAAE,OAAO;YAClE,MAAM,MAAM,GAAG,KAAK,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC;YACvC,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,KAAK,CAAC,WAAW,IAAI,SAAS,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS,IAAI,KAAK,CAAC,GAAG,KAAK,MAAM;gBAAE,OAAO;YAClH,IAAI,KAAK,CAAC,gBAAgB,CAAC,UAAU,CAAC;gBAAE,OAAO;YAC/C,IAAI,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM;gBACvF,GAAG,cAAc,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,IAAI,CAAC,OAAO,CAAC,CAAC;QACpF,CAAC,EAAE,EAAE,GAAG,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;QAClC,KAAK,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE;YACtC,IAAI,KAAK,CAAC,gBAAgB,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,KAAK,CAAC,aAAa;gBAAE,OAAO;YAC/E,IAAI,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,aAAa,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;YAC/F,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC;QACnB,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,KAAK,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,GAAG,EAAE;YAC9C,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;gBAAE,OAAO;YAC9B,SAAS,GAAG,IAAI,CAAC;YACjB,iBAAiB,GAAG,IAAI,CAAC;YACzB,YAAY,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACvC,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,EAAE,KAAK,CAAC,EAAE;YAC/C,IAAI,CAAC,SAAS;gBAAE,OAAO;YACvB,SAAS,GAAG,KAAK,CAAC;YAClB,+EAA+E;YAC/E,iBAAiB,GAAG,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC;YAC9E,IAAI,CAAC,iBAAiB,GAAG,UAAU,CAAC,GAAG,EAAE;gBACvC,IAAI,iBAAiB;oBAAE,IAAI,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC,CAAC;gBACtF,iBAAiB,GAAG,IAAI,CAAC;gBACzB,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC;YACnB,CAAC,EAAE,CAAC,CAAC,CAAC;QACR,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,KAAK,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE;YACtC,MAAM,UAAU,GAAG,KAAK,YAAY,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;YACnE,IAAI,UAAU,EAAE,WAAW,IAAI,SAAS;gBAAE,OAAO;YACjD,YAAY,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;YACrC,MAAM,IAAI,GAAG,iBAAiB,IAAI,UAAU,EAAE,IAAI,IAAI,KAAK,CAAC,KAAK,CAAC;YAClE,iBAAiB,GAAG,IAAI,CAAC;YACzB,IAAI,IAAI,IAAI,UAAU,EAAE,SAAS,KAAK,iBAAiB;gBAAE,IAAI,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,KAAK,CAAC,CAAC;YAC5G,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC;QACnB,CAAC,EAAE,OAAO,CAAC,CAAC;IACd,CAAC;IAED,KAAK;QACH,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO;QAC3B,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC;QAC7D,MAAM,CAAC,KAAK,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;IACxC,CAAC;IAED,8EAA8E;IAC9E,cAAc;QACZ,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC9E,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;QAC1H,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QAChC,IAAI,CAAC,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;QAChF,IAAI,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC;YAC3B,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS;gBAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;YAC9E,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;YACrC,OAAO;QACT,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,GAAG,IAAI,EAAE,CAAC,CAAC;IAClD,CAAC;IAED,8EAA8E;IAC9E,MAAM,CAAC,OAAe,EAAE,IAAY;QAClC,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACvC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC9E,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;QAClG,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,CAAC,CAAC;QACxC,IAAI,CAAC,cAAc,GAAG,GAAG,OAAO,IAAI,IAAI,EAAE,CAAC;IAC7C,CAAC;IAED,8FAA8F;IAC9F,SAAS,CAAC,MAAsB;QAC9B,MAAM,IAAI,GAAG,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC5D,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;QAC1F,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC3E,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC;QAC/F,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;QACpB,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAChC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;QAC9B,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;QAChC,IAAI,CAAC,IAAI,EAAE,CAAC;QACZ,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;QACxB,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC9C,CAAC;IAED,MAAM,KAAK,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,CAAC;IAE5C,WAAW;QACT,IAAI,CAAC,YAAY,EAAE,CAAC;QACpB,IAAI,CAAC,UAAU,GAAG,KAAK,CAAC;QACxB,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC5B,IAAI,IAAI,CAAC,MAAM;YAAE,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,CAAC;QAC7C,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QAC7B,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,CAAC;QAC3B,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC1B,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAChC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;IAChC,CAAC;IAED,KAAK,CAAC,KAAY;QAChB,IAAI,CAAC,WAAW,EAAE,CAAC;QACnB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC1B,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACjD,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,CAAC;IAC5B,CAAC;IAED,2EAA2E;IAC3E,OAAO;QACL,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO;QAC3B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,WAAW,EAAE,CAAC;QACnB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,YAAY,CAAC,4BAA4B,EAAE,YAAY,CAAC,CAAC,CAAC;QACjF,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC/B,YAAY,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACrC,IAAI,CAAC,SAAS,EAAE,UAAU,EAAE,CAAC;QAC7B,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACvB,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;QACxB,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC7B,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;IACxB,CAAC;CACF","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark,\n WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #closed = false;\n #readOnly: boolean;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #resetComposition: (() => void) | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #title = \"\";\n #hasTitle = false;\n #progress: TerminalProgress = { state: \"none\", percentage: null };\n #shellIntegration: TerminalShellIntegration = { phase: \"unknown\", lastExitCode: null };\n #workingDirectory: TerminalWorkingDirectory = { uri: null, host: null, path: null };\n #commandMark: TerminalCommandMark | null = null;\n #hasActivity = false;\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n if (options.readOnly !== undefined && typeof options.readOnly !== \"boolean\")\n throw new TypeError(\"readOnly must be a boolean\");\n this.#readOnly = options.readOnly ?? false;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get readOnly(): boolean { return this.#readOnly; }\n /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */\n get title(): string { return this.#title; }\n get progress(): TerminalProgress { return { ...this.#progress }; }\n get shellIntegration(): TerminalShellIntegration { return { ...this.#shellIntegration }; }\n get workingDirectory(): TerminalWorkingDirectory { return { ...this.#workingDirectory }; }\n get commandMark(): TerminalCommandMark | null { return this.#commandMark ? { ...this.#commandMark } : null; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: this.#readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: this.#readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,\n openHyperlink: uri => {\n try { window.open(uri, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n }\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"closed\") {\n if (this.#closed) return;\n this.#closed = true;\n clearTimeout(this.#readyTimer);\n try {\n this.#disconnect();\n if (!this.#disposed) this.#options.onClose?.(Object.freeze({ ...message.details }));\n } finally {\n this.#ready.reject(new Error(`Terminal WebSocket closed (${message.details.code}${\n message.details.reason ? `: ${message.details.reason}` : \"\"}) before mounting completed`));\n }\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n const titleChanged = !this.#hasTitle || this.#title !== message.title;\n const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state ||\n this.#progress.percentage !== message.progress.percentage;\n const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase ||\n this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode;\n const workingDirectoryChanged = !this.#hasActivity ||\n this.#workingDirectory.uri !== message.workingDirectory.uri;\n const commandMarkChanged = !this.#hasActivity || this.#commandMark?.phase !== message.commandMark?.phase ||\n this.#commandMark?.exitCode !== message.commandMark?.exitCode ||\n this.#commandMark?.rawParameters !== message.commandMark?.rawParameters;\n this.#title = message.title;\n this.#hasTitle = true;\n this.#progress = { ...message.progress };\n this.#shellIntegration = { ...message.shellIntegration };\n this.#workingDirectory = { ...message.workingDirectory };\n this.#commandMark = message.commandMark ? { ...message.commandMark } : null;\n this.#hasActivity = true;\n if (titleChanged) this.#options.onTitleChange?.(this.#title);\n if (!this.#disposed && progressChanged) this.#options.onProgressChange?.(this.progress);\n if (!this.#disposed && shellChanged) this.#options.onShellIntegrationChange?.(this.shellIntegration);\n if (!this.#disposed && workingDirectoryChanged) this.#options.onWorkingDirectoryChange?.(this.workingDirectory);\n if (!this.#disposed && commandMarkChanged) this.#options.onCommandMarkChange?.(this.commandMark);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected &&\n (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#canInput() || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#canInput()) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly && [\"input\", \"paste\", \"key\", \"mouse\", \"resize\", \"requestPrimary\"].includes(command.type))\n throw new Error(\"Terminal view does not accept input\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: !this.#readOnly && this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: this.#readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try {\n const decision = this.#policy.resolve(Object.freeze(input), this.inputContext);\n if (this.#readOnly && decision.route === InputRoute.Application)\n return { route: input.type === \"pointer\" || input.type === \"wheel\" ? InputRoute.Continue : InputRoute.Consume };\n return decision;\n }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */\n setReadOnly(readOnly: boolean): void {\n if (typeof readOnly !== \"boolean\") throw new TypeError(\"readOnly must be a boolean\");\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n if (this.#readOnly === readOnly) return;\n const inputFocused = document.activeElement === this.element &&\n (!this.element.shadowRoot?.activeElement || this.element.shadowRoot.activeElement === this.#input);\n this.#readOnly = readOnly;\n this.#inputSerial++;\n this.#resetComposition?.();\n if (this.#input) {\n this.#input.value = \"\";\n this.#input.disabled = !this.#canInput();\n }\n // Set the policy before cancelling so pending moves and button releases cannot leak.\n this.#mouse?.cancel();\n this.#mouse?.refresh();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#queueResize(true);\n if (inputFocused) this.focus();\n this.#selectionUI?.refresh();\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.#resetComposition = () => {\n composing = false;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n };\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n if (!this.#canInput()) return;\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n if (!composing) return;\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts index 35fb7c57e52..c50630cf91d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts @@ -1,4 +1,4 @@ -import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration, TerminalCloseDetails } from "./types.js"; +import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark, TerminalCloseDetails } from "./types.js"; export type SelectionText = { status: "valid"; text: string; @@ -78,6 +78,8 @@ export interface FrameMetadata extends TerminalGeometry { title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration; + workingDirectory: TerminalWorkingDirectory; + commandMark: TerminalCommandMark | null; defaultBackground?: number; defaultForeground?: number; cursor: { @@ -221,6 +223,8 @@ export type WorkerOutputMessage = { title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration; + workingDirectory: TerminalWorkingDirectory; + commandMark: TerminalCommandMark | null; text: string; hyperlinks: HyperlinkRange[]; } & TerminalGeometry) | { diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map index a503bd32f7f..ddb454e9626 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map index 69a48061ee6..c55434a2dac 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration,\n TerminalCloseDetails } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n title: string;\n progress: TerminalProgress;\n shellIntegration: TerminalShellIntegration;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" }\n | { type: \"closed\"; details: TerminalCloseDetails }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration;\n text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file +{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration,\n TerminalWorkingDirectory, TerminalCommandMark, TerminalCloseDetails } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n title: string;\n progress: TerminalProgress;\n shellIntegration: TerminalShellIntegration;\n workingDirectory: TerminalWorkingDirectory;\n commandMark: TerminalCommandMark | null;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" }\n | { type: \"closed\"; details: TerminalCloseDetails }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration;\n workingDirectory: TerminalWorkingDirectory; commandMark: TerminalCommandMark | null;\n text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index c25024ef588..a7be335454b 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.166.0", + "version": "0.167.0-alpha.1565.1.6eea363", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index ff89bf83c7e..df79218db70 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -4,6 +4,7 @@ using System.Threading.Channels; using Hex1b; using Hex1b.Automation; +using Hex1b.Reflow; using Microsoft.Extensions.Logging; #pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. @@ -163,6 +164,8 @@ private Hex1bTerminal EnsureStarted() // domain socket. _terminal = _builder .WithHmp1Server(_clients.Reader.ReadAllAsync) + .WithReflow(GhosttyReflowStrategy.Instance) + .WithScrollback(10000) .Build(); _automator = new Hex1bTerminalAutomator(_terminal, TerminalAutomation.DefaultTimeout); diff --git a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs index 517decd2725..aa8b038c438 100644 --- a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs @@ -3,6 +3,7 @@ using Hex1b; using Hex1b.Automation; +using Hex1b.Reflow; using Microsoft.Extensions.Logging; #pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. @@ -195,6 +196,7 @@ private async Task RunConnectionAsync(ConnectionAttempt connection) // The AppHost has no controlling terminal. Headless suppresses local console I/O but still // maintains the replicated screen used by automation. .WithHeadless() + .WithReflow(GhosttyReflowStrategy.Instance) .WithDimensions(80, 24) .WithHmp1UdsClient(_consumerUdsPath, options => { diff --git a/src/Aspire.TerminalHost/TerminalReplica.cs b/src/Aspire.TerminalHost/TerminalReplica.cs index 1492af0a8f0..ff5601ddfd7 100644 --- a/src/Aspire.TerminalHost/TerminalReplica.cs +++ b/src/Aspire.TerminalHost/TerminalReplica.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Aspire.Shared.TerminalHost; using Hex1b; +using Hex1b.Reflow; using Microsoft.Extensions.Logging; namespace Aspire.TerminalHost; @@ -444,6 +445,7 @@ private Hex1bTerminal BuildTerminal() .WithDimensions(currentColumns, currentRows) .WithWorkload(upstream) .WithPresentation(downstream) + .WithScrollback(10000) .AddPresentationFilter(listener) .Build(); } @@ -459,7 +461,8 @@ private Hmp1PresentationAdapter CreateDownstream( int currentColumns, int currentRows) { - var downstream = new Hmp1PresentationAdapter(currentColumns, currentRows); + var downstream = new Hmp1PresentationAdapter(currentColumns, currentRows) + .WithReflow(GhosttyReflowStrategy.Instance); // Track every HMP1 peer that connects/disconnects so the host can answer // "who's currently attached to this replica?" via the control RPC. PeerId is diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index a108bd1264c..0d56cf936dd 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -1055,13 +1055,13 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.166.0"); + assert.equal(version, "0.167.0-alpha.1565.1.6eea363"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); // Central package rows have the form: - // + // // Match the exact Include value, not Hex1b.Tool or Hex1b.McpServer; // whitespace, attribute order and either XML quote style are allowed. const packages = await readFile(new URL("../../Directory.Packages.props", dashboard), "utf8"); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index c8898c5d3e6..4bf8e02d947 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -54,8 +54,11 @@ public async Task ResizeDock_UpdatesAccessibleBoundsWithoutRemountingTerminals( Assert.Equal(expectedHeight.ToString(), handle.GetAttribute("aria-valuenow")); Assert.Equal($"{expectedHeight} pixels high", handle.GetAttribute("aria-valuetext")); Assert.Equal("ArrowUp ArrowDown Shift+ArrowUp Shift+ArrowDown Home End", handle.GetAttribute("aria-keyshortcuts")); + Assert.Null(handle.GetAttribute("title")); + var resizeHelp = cut.Find($"#{handle.GetAttribute("aria-describedby")}"); + Assert.True(resizeHelp.HasAttribute("hidden")); Assert.Equal("Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height.", - cut.Find($"#{handle.GetAttribute("aria-describedby")}").TextContent); + resizeHelp.TextContent); Assert.Equal(terminals, cut.FindComponents().Select(view => view.Instance).ToArray()); Assert.Equal("first", cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); Assert.Empty(client.ClosedTerminals); diff --git a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs index 367d0baebe3..40ed2da6a2d 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs @@ -13,6 +13,7 @@ using Grpc.Core; using Hex1b; using Hex1b.Automation; +using Hex1b.Reflow; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -21,7 +22,7 @@ namespace Aspire.Dashboard.Tests.Shared; internal sealed class TerminalTestHost : ITerminalConnectionResolver, IAsyncDisposable { private readonly DashboardWebApplication _app; - private readonly TerminalTestProducer _producer = new(100, 30, 100); + private readonly TerminalTestProducer _producer = new(100, 30, 10000); private readonly bool _useGrpc; private readonly ConcurrentBag _attachmentDisposals = []; private int _disposedAttachments; @@ -228,7 +229,8 @@ internal sealed class TerminalTestProducer : IAsyncDisposable public TerminalTestProducer(int width, int height, int scrollback) { Workload = new Hex1bAppWorkloadAdapter(); - Presentation = new Hmp1PresentationAdapter(width, height); + Presentation = new Hmp1PresentationAdapter(width, height) + .WithReflow(GhosttyReflowStrategy.Instance); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(Workload) .WithPresentation(Presentation) diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs index 92e9116ea2c..0960ed4f264 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -13,6 +13,49 @@ namespace Aspire.Dashboard.Tests.Terminal; public class TerminalWebSocketTests(ITestOutputHelper output) { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BrowserView_ReflowsRetainedHistoryAndPreservesSoftWrapsAfterReconnect(bool useGrpc) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(timeout.Token); + using var browser = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(browser, _ => true, timeout.Token); + await SendAsync(browser, """{"type":"requestPrimary","columns":100,"rows":30}""", timeout.Token); + await ReadUntilAsync(browser, frame => frame.GetProperty("peer").GetProperty("isPrimary").GetBoolean(), timeout.Token); + + var text = new string('A', 19) + "\u754ce\u0301" + new string('B', 43) + "-END"; + var lines = Enumerable.Range(0, 35).Select(i => $"{i:D2}:{text}").ToArray(); + host.Workload.Write(string.Join("\r\n", lines) + "\r\nready"); + await ReadUntilAsync(browser, frame => frame.GetProperty("history").GetProperty("totalRows").GetInt32() >= 36, timeout.Token); + + var requestId = 1; + foreach (var width in new[] { 20, 40, 100 }) + { + await SendAsync(browser, JsonSerializer.Serialize(new { type = "resize", columns = width, rows = 30 }), timeout.Token); + // Each line occupies 72 cells: the wide glyph and combining mark cancel in the UTF-16 length. + var expectedRows = lines.Length * ((72 + width - 1) / width) + 1; + var resized = await ReadUntilAsync(browser, frame => frame.GetProperty("columns").GetInt32() == width && + frame.GetProperty("history").GetProperty("totalRows").GetInt32() == expectedRows, timeout.Token); + Assert.Equal(expectedRows, resized.GetProperty("history").GetProperty("totalRows").GetInt32()); + Assert.Equal(lines[0], await ReadFirstLogicalLineAsync(browser, requestId, timeout.Token)); + requestId += 4; + } + + // Clear the screen, then leave a complete wrapped logical line on it for a fresh peer's replay. + host.Workload.Write("\u001b[3J\u001b[2J\u001b[H" + text + "\r\nreconnect-ready"); + await host.WaitForProducerTextAsync("reconnect-ready", timeout.Token); + await SendAsync(browser, """{"type":"resize","columns":20,"rows":30}""", timeout.Token); + await ReadUntilAsync(browser, frame => frame.GetProperty("columns").GetInt32() == 20, timeout.Token); + await browser.CloseAsync(WebSocketCloseStatus.NormalClosure, "Reconnect", timeout.Token); + using var reconnected = await host.ConnectBrowserAsync(timeout.Token); + await ReadUntilAsync(reconnected, _ => true, timeout.Token); + Assert.Equal(text, await ReadFirstLogicalLineAsync(reconnected, 1, timeout.Token)); + await reconnected.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", timeout.Token); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -575,6 +618,39 @@ public async Task BrowserView_ProducerDisconnectClosesBrowserWhileWaitingForAckn Assert.Equal(useGrpc ? 1 : 0, host.DisposedAttachments); } + private static async Task ReadFirstLogicalLineAsync(WebSocket socket, int requestId, CancellationToken cancellationToken) + { + // Scroll to retained history, rather than only testing the freshly replayed live screen. + await SendAsync(socket, JsonSerializer.Serialize(new { type = "viewport", requestId, delta = -10000 }), cancellationToken); + var frame = await ReadUntilAsync(socket, + frame => frame.GetProperty("history").GetProperty("requestId").GetInt32() == requestId, cancellationToken); + Assert.Equal(0, frame.GetProperty("history").GetProperty("top").GetInt32()); + var history = frame.GetProperty("history"); + // HWT line selection returns the logical line, including soft-wrapped continuations. + await SendAsync(socket, JsonSerializer.Serialize(new + { + type = "selection", + action = "start", + mode = "line", + requestId = requestId + 1, + column = 0, + generation = history.GetProperty("generation").GetString(), + rowId = history.GetProperty("rowIds")[0].GetString() + }), cancellationToken); + var selection = await ReadUntilAsync(socket, + frame => frame.GetProperty("history").GetProperty("selection").GetProperty("status").GetString() == "valid", + cancellationToken); + var text = selection.GetProperty("history").GetProperty("selection").GetProperty("text").GetString(); + await SendAsync(socket, JsonSerializer.Serialize(new { type = "selection", action = "clear", requestId = requestId + 2 }), cancellationToken); + await ReadUntilAsync(socket, + frame => frame.GetProperty("history").GetProperty("selection").GetProperty("status").GetString() != "valid", + cancellationToken); + await SendAsync(socket, JsonSerializer.Serialize(new { type = "viewport", live = true, requestId = requestId + 3 }), cancellationToken); + await ReadUntilAsync(socket, + frame => frame.GetProperty("history").GetProperty("requestId").GetInt32() == requestId + 3, cancellationToken); + return text; + } + private static async Task ReadCloseAsync(WebSocket socket, CancellationToken cancellationToken) { var buffer = new byte[64 * 1024]; diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs index 7d71ea24d6d..6e65a75ab1e 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.IO.Pipelines; +using System.Text; using Aspire.Hosting.Terminals; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; @@ -15,6 +16,57 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class Hex1bAspireTerminalTests { + [Theory] + [InlineData(TerminalPlacement.Dock)] + [InlineData(TerminalPlacement.Dialog)] + public async Task Resize_ReflowsMainScreenAndRetainsHistory(TerminalPlacement placement) + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var terminal = service.CreateTerminal("Reflow", placement, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null))); + await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); + var lines = Enumerable.Range(0, 7).Select(i => $"{i}:" + new string('x', 63) + "-END").ToArray(); + var expected = string.Join('\n', lines.Select(line => line.PadRight(80)).Append("ready")); + await outputWriter.WriteAsync(Encoding.UTF8.GetBytes(string.Join("\r\n", lines) + "\r\nready")); + await terminal.WaitForTextAsync("ready").DefaultTimeout(); + Assert.Equal(expected, terminal.GetScreenText().TrimEnd()); + + // Narrowing pushes wrapped rows into history. Widening must restore them without new output. + await viewer.ResizeAsync(20, 4); + await viewer.ResizeAsync(80, 24); + Assert.Equal(expected, terminal.GetScreenText().TrimEnd()); + } + + [Fact] + public async Task Resize_CropsAlternateScreenAndReflowsSavedMainScreen() + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var terminal = service.CreateTerminal("Alternate", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null))); + await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); + const string main = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-END"; + await outputWriter.WriteAsync(Encoding.UTF8.GetBytes(main + "\r\nready")); + await terminal.WaitForTextAsync("ready").DefaultTimeout(); + // DECSET 1049 enters the alternate screen; CUP positions a fixed-layout row. + await outputWriter.WriteAsync("\u001b[?1049h\u001b[HALTERNATE-ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\nalt-ready"u8.ToArray()); + await terminal.WaitForTextAsync("alt-ready").DefaultTimeout(); + + await viewer.ResizeAsync(20, 24); + Assert.Equal("ALTERNATE-ABCDEFGHIJ\nalt-ready", terminal.GetScreenText().TrimEnd()); + await outputWriter.WriteAsync("\u001b[?1049l"u8.ToArray()); + await terminal.WaitForTextAsync("ABCDEFGHIJKLMNOPQRST").DefaultTimeout(); + Assert.Equal(string.Join('\n', main.Chunk(20).Select(chunk => new string(chunk).PadRight(20)).Append("ready")), + terminal.GetScreenText().TrimEnd()); + await viewer.ResizeAsync(80, 24); + Assert.Equal(main.PadRight(80) + "\nready", terminal.GetScreenText().TrimEnd()); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs b/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs index f2f4c4e024e..b982a213c2c 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs @@ -4,6 +4,7 @@ using Aspire.Hosting.Terminals; using Hex1b; using Hex1b.Automation; +using Hex1b.Reflow; using Microsoft.AspNetCore.InternalTesting; #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. @@ -14,7 +15,7 @@ internal sealed class TestAppHostTerminalViewer : IAsyncDisposable { private readonly CancellationTokenSource _attachmentCts = new(); private readonly CancellationTokenSource _clientCts = new(); - private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TestDuplexStream _serverStream; private readonly TestDuplexStream _clientStream; private readonly Hex1bTerminal _client; @@ -27,13 +28,14 @@ private TestAppHostTerminalViewer(TerminalService service, string terminalId) (_serverStream, _clientStream) = TestDuplexStream.CreatePair(); _client = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithReflow(GhosttyReflowStrategy.Instance) .WithDimensions(80, 24) .WithHmp1Stream(_clientStream, options => { options.DefaultRole = Hmp1Role.Secondary; - options.OnConnected = (_, _) => + options.OnConnected = (e, _) => { - _connected.TrySetResult(); + _connected.TrySetResult(e.Connection); return Task.CompletedTask; }; }) @@ -66,6 +68,16 @@ public Task WaitForTextAsync(string text) public Task SendTextAsync(string text) => new Hex1bTerminalAutomator(_client, TimeSpan.FromSeconds(30)).TypeAsync(text); + public async Task ResizeAsync(int width, int height) + { + var connection = await _connected.Task.DefaultTimeout(); + await connection.RequestPrimaryAsync(width, height).DefaultTimeout(); + using var snapshot = await new Hex1bTerminalInputSequenceBuilder() + .WaitUntil(snapshot => snapshot.Width == width && snapshot.Height == height, + TimeSpan.FromSeconds(30), "The producer did not acknowledge the requested dimensions.") + .Build().ApplyAsync(_client, _clientCts.Token).DefaultTimeout(); + } + public async Task DisconnectPeerAsync() { await StopClientAsync(); diff --git a/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs b/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs index 3065b0e8128..a26b0cd4943 100644 --- a/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs +++ b/tests/Aspire.TerminalHost.Tests/TerminalHostAppTests.cs @@ -2,9 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net.Sockets; +using System.Text; using System.Text.Json; using Aspire.Shared.TerminalHost; using Hex1b; +using Hex1b.Automation; +using Hex1b.Reflow; using Microsoft.Extensions.Logging.Abstractions; using StreamJsonRpc; @@ -518,6 +521,76 @@ await WaitForAsync( } } + [Fact] + public async Task DownstreamResizeReflowsOutputAndRetainsProducerHistory() + { + var (args, workspace, control) = BuildArgs(80, 24); + using var disp = workspace; + await using var app = new TerminalHostApp(args, NullLoggerFactory.Instance); + using var hostCts = new CancellationTokenSource(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var hostTask = app.RunAsync(hostCts.Token); + try + { + await WaitForFileAsync(control, TimeSpan.FromSeconds(10)); + await using var producer = await ConnectProducerAsync(args.ProducerUdsPath, TimeSpan.FromSeconds(5)); + await producer.SendHelloAsync(80, 24, timeout.Token); + await WaitForFileAsync(args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); + await using var consumer = new Hmp1WorkloadAdapter(new Hmp1ClientOptions + { + StreamFactory = ct => Hmp1Transports.ConnectUnixSocket(args.ConsumerUdsPath, ct), + DefaultRole = Hmp1Role.Secondary + }); + await consumer.ConnectAsync(timeout.Token); + await using var mirror = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithWorkload(consumer) + .WithReflow(GhosttyReflowStrategy.Instance) + .WithScrollback(10000) + .Build(); + var lines = Enumerable.Range(0, 7).Select(i => $"{i}:" + new string('x', 63) + "-END").ToArray(); + await producer.SendOutputAsync(Encoding.UTF8.GetBytes(string.Join("\r\n", lines) + "\r\nready"), timeout.Token); + await new Hex1bTerminalAutomator(mirror, TimeSpan.FromSeconds(10)).WaitUntilTextAsync("ready").WaitAsync(timeout.Token); + + foreach (var (width, height) in new[] { (20, 4), (80, 24) }) + { + await consumer.RequestPrimaryAsync(width, height, timeout.Token); + using var resized = await new Hex1bTerminalInputSequenceBuilder() + .WaitUntil(snapshot => snapshot.Width == width && snapshot.Height == height, + TimeSpan.FromSeconds(10), "The terminal host did not resize.") + .Build().ApplyAsync(mirror, timeout.Token); + } + + var expected = string.Join('\n', lines.Select(line => line.PadRight(80)).Append("ready")); + using var restored = await new Hex1bTerminalInputSequenceBuilder() + .WaitUntil(snapshot => snapshot.GetScreenText().TrimEnd() == expected, + TimeSpan.FromSeconds(10), "The terminal host did not restore reflowed history.") + .Build().ApplyAsync(mirror, timeout.Token); + Assert.Equal(expected, restored.GetScreenText().TrimEnd()); + + // A fresh peer proves the producer retained the content, not just the existing mirror. + await using var lateConsumer = await TestHmp1Consumer.ConnectAsync(args.ConsumerUdsPath, TimeSpan.FromSeconds(5)); + await lateConsumer.SendClientHelloAsync("late-reflow-peer", "secondary", timeout.Token); + await lateConsumer.ReceiveHandshakeAsync(TimeSpan.FromSeconds(5)); + var replayWorkload = new Hex1bAppWorkloadAdapter(); + await using var replay = Hex1bTerminal.CreateBuilder() + .WithHeadless().WithDimensions(80, 24).WithWorkload(replayWorkload).Build(); + replayWorkload.Write(Encoding.UTF8.GetString(lateConsumer.InitialState) + "\r\nreplay-complete"); + using var lateSnapshot = await new Hex1bTerminalInputSequenceBuilder() + .WaitUntil(snapshot => snapshot.ContainsText("replay-complete"), + TimeSpan.FromSeconds(10), "The late peer did not consume its initial state.") + .Build().ApplyAsync(replay, timeout.Token); + Assert.Equal(expected + new string(' ', 80 - "ready".Length) + "\nreplay-complete", + lateSnapshot.GetScreenText().TrimEnd()); + } + finally + { + app.RequestShutdown(); + hostCts.Cancel(); + await hostTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + [Fact] public async Task DownstreamPrimaryResizeIsForwardedUpstreamAsRawResizeFrame() { @@ -913,6 +986,8 @@ private sealed class TestHmp1Consumer : IAsyncDisposable private readonly NetworkStream _stream; private bool _disposed; + public byte[] InitialState { get; private set; } = []; + private TestHmp1Consumer(Socket socket) { _socket = socket; @@ -954,7 +1029,7 @@ public async Task ReceiveHandshakeAsync(TimeSpan timeout) { using var cts = new CancellationTokenSource(timeout); var (helloType, helloPayload) = await ReadFrameAsync(cts.Token).ConfigureAwait(false); - var (stateSyncType, _) = await ReadFrameAsync(cts.Token).ConfigureAwait(false); + var (stateSyncType, stateSyncPayload) = await ReadFrameAsync(cts.Token).ConfigureAwait(false); if (helloType != FrameHello || stateSyncType != FrameStateSync) { @@ -962,6 +1037,7 @@ public async Task ReceiveHandshakeAsync(TimeSpan timeout) $"Expected Hello and StateSync frames, received 0x{helloType:X2} and 0x{stateSyncType:X2}."); } + InitialState = stateSyncPayload; return helloPayload; } From 879d9b1e566ce237fc05c08b1b28cd0f58d8a5a1 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 13 Sep 2026 20:52:35 +1000 Subject: [PATCH 063/106] Update Hex1b to 0.167.0 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 2 +- src/Aspire.Dashboard/package-lock.json | 8 ++++---- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 4 ++-- .../wwwroot/js/hex1b-web-terminal/package.json | 2 +- .../JavaScript/TerminalView.test.mjs | 4 ++-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4b5c798e4d0..cc9cd52d5c0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,7 +117,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index b338a38fe7e..ff06814c32b 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -166,7 +166,7 @@ it does not lock the terminal, its creator's automation, or other viewers. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.167.0-alpha.1565.1.6eea363`. HWT1 is experimental state transfer +exactly `0.167.0`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 161110d3250..8657c009cc9 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1565.1.6eea363" + "@hex1b/web-terminal": "0.167.0" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.167.0-alpha.1565.1.6eea363", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0-alpha.1565.1.6eea363.tgz", - "integrity": "sha512-JsY22DHBHYK0hMzbcnQ8wR4x6+N6UzxrfSx8BHeqRLLE7IvQB2jZtkZUZyQm+FoxhAILR7q6vvkyu/zVrADqrA==", + "version": "0.167.0", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0.tgz", + "integrity": "sha512-o6KmaSoSJ3OdNw1asBWFoCVFOGdgi/gxdgvmiviYCl7Rg+PhOSglNbmfu6/WiuRAd3siyXgYoS8bf8DDqwUqFw==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index cdb6bfb844f..17de219d858 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.167.0-alpha.1565.1.6eea363" + "@hex1b/web-terminal": "0.167.0" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index 2a9ca183ee5..1b561fc7f95 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -18,8 +18,8 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0-alpha.1565.1.6eea363**, -paired with the Hex1b NuGet package **0.167.0-alpha.1565.1.6eea363**. The client and server use the evolving +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0**, +paired with the Hex1b NuGet package **0.167.0**. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index a7be335454b..ed199547c1b 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.167.0-alpha.1565.1.6eea363", + "version": "0.167.0", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 0d56cf936dd..e8f1aef8b41 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -1055,13 +1055,13 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.167.0-alpha.1565.1.6eea363"); + assert.equal(version, "0.167.0"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); // Central package rows have the form: - // + // // Match the exact Include value, not Hex1b.Tool or Hex1b.McpServer; // whitespace, attribute order and either XML quote style are allowed. const packages = await readFile(new URL("../../Directory.Packages.props", dashboard), "utf8"); From f1117a5917935a79138ba6cf4d3070d49ba787eb Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 14 Sep 2026 11:08:18 +1000 Subject: [PATCH 064/106] Address terminal review feedback and update Hex1b Validate terminal titles, preserve detached-window PathBase, await terminal teardown, and correct ownership documentation. Update paired Hex1b packages to 0.168.0-alpha.1573.1.2917e83. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 2 +- .../Dialogs/InteractionsInputDialog.razor.css | 5 +- .../Components/Layout/TerminalDock.razor.cs | 4 +- .../Components/Pages/ConsoleLogs.razor.cs | 6 +- .../Model/TerminalWindowLauncher.cs | 10 +- src/Aspire.Dashboard/package-lock.json | 8 +- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 4 +- .../js/hex1b-web-terminal/package.json | 2 +- .../Terminals/AspireTerminal.cs | 4 +- .../Terminals/Hex1bAspireTerminal.cs | 44 +++--- .../Terminals/TerminalLaunchOptions.cs | 3 + src/Aspire.Hosting/Terminals/TerminalOwner.cs | 13 +- .../Terminals/TerminalService.cs | 91 +++++------ .../JavaScript/TerminalView.test.mjs | 4 +- .../Layout/TerminalDockTests.cs | 28 +++- .../Pages/ConsoleLogsTerminalTests.cs | 27 +++- .../Shared/TerminalSetupHelpers.cs | 14 +- .../Terminals/Hex1bAspireTerminalTests.cs | 48 ++++++ .../Terminals/TerminalServiceTests.cs | 142 +++++++++++++++++- .../Utils/GatedTerminalWorkloadAdapter.cs | 47 ++++++ 22 files changed, 385 insertions(+), 125 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Utils/GatedTerminalWorkloadAdapter.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index cc9cd52d5c0..b786f6c6c0d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,7 +117,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index ff06814c32b..67c5f4f0a60 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -166,7 +166,7 @@ it does not lock the terminal, its creator's automation, or other viewers. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.167.0`. HWT1 is experimental state transfer +exactly `0.168.0-alpha.1573.1.2917e83`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css index eb3c7d269fc..48043e5d0af 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css @@ -57,9 +57,8 @@ margin-inline-end: 0; } -/* Terminals are the only input that needs an explicit box: xterm.js measures its container, so a zero-height - container renders nothing at all. The container also has to opt out of the 75%/500px width cap that keeps - ordinary form fields from stretching across the dialog. */ +/* The Hex1b web terminal measures its container, so it needs an explicit, nonzero height. Its container also + opts out of the 75%/500px width cap that keeps ordinary form fields from stretching across the dialog. */ .interaction-input-dialog .interaction-input ::deep .interaction-terminal-container { width: 100%; max-width: none; diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index d805695f1ec..d6531b5c076 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -204,7 +204,7 @@ private void Activate(string terminalId) private string GetPaneId(string terminalId) => $"{_elementIdPrefix}-pane-{terminalId}"; private TerminalWindowLauncher WindowLauncher - => _windowLauncher ??= new TerminalWindowLauncher(JS, OnDetachedWindowClosedAsync); + => _windowLauncher ??= new TerminalWindowLauncher(JS, NavigationManager, OnDetachedWindowClosedAsync); /// /// Pops the active terminal out into its own window. @@ -220,7 +220,7 @@ private async Task DetachActiveAsync() try { - var url = NavigationManager.ToAbsoluteUri($"/terminal-window/apphost/{Uri.EscapeDataString(terminalId)}").ToString(); + var url = NavigationManager.ToAbsoluteUri($"terminal-window/apphost/{Uri.EscapeDataString(terminalId)}").AbsoluteUri; var fontSize = _terminalViews.TryGetValue(terminalId, out var view) ? view.FontSize : null; var result = await WindowLauncher.OpenAsync(terminalId, url, fontSize).ConfigureAwait(true); diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 1453de45ee6..dd39aee2db0 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -1369,7 +1369,7 @@ private Task HandleViewChangedAsync(string? newView) // Resource terminals never reattach, so the close callback has nothing to do: the inline view was live the // whole time the window was open. private TerminalWindowLauncher TerminalWindowLauncher - => _terminalWindowLauncher ??= new TerminalWindowLauncher(JS, _ => Task.CompletedTask); + => _terminalWindowLauncher ??= new TerminalWindowLauncher(JS, NavigationManager, _ => Task.CompletedTask); /// /// Opens the selected resource's terminal in its own resizable window. @@ -1388,10 +1388,10 @@ private async Task OpenTerminalWindowAsync() try { - var path = $"/terminal-window/resource/{Uri.EscapeDataString(resourceName)}/{_terminalReplicaIndex}"; + var path = $"terminal-window/resource/{Uri.EscapeDataString(resourceName)}/{_terminalReplicaIndex}"; var result = await TerminalWindowLauncher.OpenAsync( key: $"resource:{resourceName}:{_terminalReplicaIndex}", - url: NavigationManager.ToAbsoluteUri(path).ToString(), + url: NavigationManager.ToAbsoluteUri(path).AbsoluteUri, fontSize: _terminalViewRef?.FontSize).ConfigureAwait(true); if (result is TerminalWindowOpenResult.Blocked) diff --git a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs index 0608d57ac3e..96ab2fd786e 100644 --- a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs +++ b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; using Microsoft.JSInterop; @@ -50,6 +51,7 @@ public sealed class TerminalWindowLauncher : IAsyncDisposable private const int DefaultWindowHeightPx = 600; private readonly IJSRuntime _js; + private readonly NavigationManager _navigationManager; private readonly Func _onWindowClosed; private readonly HashSet _tracked = []; @@ -60,16 +62,19 @@ public sealed class TerminalWindowLauncher : IAsyncDisposable /// Initializes a new instance of the class. /// /// The JS runtime for the owning component's circuit. + /// The navigation manager providing the dashboard's base URI. /// /// Invoked with the terminal key when the user closes a detached window. Not raised for windows closed through /// , because the caller already knows about those. /// - public TerminalWindowLauncher(IJSRuntime js, Func onWindowClosed) + public TerminalWindowLauncher(IJSRuntime js, NavigationManager navigationManager, Func onWindowClosed) { ArgumentNullException.ThrowIfNull(js); + ArgumentNullException.ThrowIfNull(navigationManager); ArgumentNullException.ThrowIfNull(onWindowClosed); _js = js; + _navigationManager = navigationManager; _onWindowClosed = onWindowClosed; } @@ -152,8 +157,9 @@ private async Task GetModuleAsync() // Imported lazily: most sessions never detach a terminal, and the import is only legal once the circuit can // reach the browser, which rules out doing it in a constructor. _selfRef ??= DotNetObjectReference.Create(this); + var moduleUri = new Uri(new Uri(_navigationManager.BaseUri), "js/app-terminalwindow.js"); return _module ??= await _js.InvokeAsync( - "import", "/js/app-terminalwindow.js").ConfigureAwait(false); + "import", moduleUri.PathAndQuery).ConfigureAwait(false); } /// diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 8657c009cc9..85f5a767ceb 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.167.0" + "@hex1b/web-terminal": "0.168.0-alpha.1573.1.2917e83" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.167.0", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.167.0.tgz", - "integrity": "sha512-o6KmaSoSJ3OdNw1asBWFoCVFOGdgi/gxdgvmiviYCl7Rg+PhOSglNbmfu6/WiuRAd3siyXgYoS8bf8DDqwUqFw==", + "version": "0.168.0-alpha.1573.1.2917e83", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.168.0-alpha.1573.1.2917e83.tgz", + "integrity": "sha512-Cpq50P0V7UAhkeCI0AICETum4dDw/EFm7ZduVElBIRzA9eXVuK/9mkpt/xvoNN+3h02MNokxgrY7PIm+Z1E9zg==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index 17de219d858..e67804b166d 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.167.0" + "@hex1b/web-terminal": "0.168.0-alpha.1573.1.2917e83" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index 1b561fc7f95..8d993caa898 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -18,8 +18,8 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.167.0**, -paired with the Hex1b NuGet package **0.167.0**. The client and server use the evolving +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.168.0-alpha.1573.1.2917e83**, +paired with the Hex1b NuGet package **0.168.0-alpha.1573.1.2917e83**. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index ed199547c1b..a45b5e1938c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.167.0", + "version": "0.168.0-alpha.1573.1.2917e83", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/src/Aspire.Hosting/Terminals/AspireTerminal.cs b/src/Aspire.Hosting/Terminals/AspireTerminal.cs index 59dab042de3..3138a004cf5 100644 --- a/src/Aspire.Hosting/Terminals/AspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/AspireTerminal.cs @@ -15,8 +15,8 @@ namespace Aspire.Hosting.Terminals; /// Aspire manages their registration and connection to the underlying terminal implementation. /// /// -/// What disposal means depends on . For the workload -/// runs in the AppHost, so disposing cancels it and removes the terminal from the dashboard; whoever creates +/// What disposal means depends on . For the AppHost +/// owns the workload, so disposing stops it and removes the terminal from the dashboard; whoever creates /// such a terminal owns it and must dispose it, and showing one in an interaction does not transfer that /// ownership, so the terminal survives the dialog it was displayed in. For /// the workload belongs to the resource, so disposing only releases diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index df79218db70..79b7c953894 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -17,7 +17,7 @@ namespace Aspire.Hosting.Terminals; /// /// Clients are handed to Hex1b's HMP1 server through a channel, which lets a single terminal serve several /// attached viewers (for example two dashboard browser tabs, or a dock tab reopened after being closed) -/// using HMP1's multi-head support. The workload lives in the AppHost, so terminal state survives a viewer +/// using HMP1's multi-head support. The AppHost owns the workload, so terminal state survives a viewer /// disconnecting entirely. /// internal sealed class Hex1bAspireTerminal : ITerminalBackend @@ -43,6 +43,7 @@ internal sealed class Hex1bAspireTerminal : ITerminalBackend private Hex1bTerminal? _terminal; private Hex1bTerminalAutomator? _automator; private Task? _runTask; + private Task? _stopTask; private bool _stopped; public Hex1bAspireTerminal(TerminalService owner, string id, string title, TerminalPlacement placement, Hex1bTerminalBuilder builder, ILogger logger) @@ -87,6 +88,8 @@ public void Show() public void Retitle(string title) { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + lock (_gate) { if (string.Equals(Title, title, StringComparison.Ordinal)) @@ -214,13 +217,13 @@ private async Task RunTerminalAsync(Hex1bTerminal terminal) try { await terminal.DisposeAsync().ConfigureAwait(false); + _sessionEnded.TrySetResult(); } catch (Exception ex) { - _logger.LogDebug(ex, "Disposing terminal {TerminalId} ({Title}) failed.", Id, Title); + _logger.LogError(ex, "Disposing terminal {TerminalId} ({Title}) failed.", Id, Title); + _sessionEnded.TrySetException(ex); } - - _sessionEnded.TrySetResult(); } } @@ -266,9 +269,9 @@ public Task StopAsync() { lock (_gate) { - if (_stopped) + if (_stopTask is not null) { - return _sessionEnded.Task; + return _stopTask; } _stopped = true; @@ -277,24 +280,31 @@ public Task StopAsync() if (_runTask is null) { // Registered but never started, so there is nothing to wind down. - _workloadCts.Cancel(); _workloadCts.Dispose(); _workloadEnded.TrySetResult(); _sessionEnded.TrySetResult(); - return _sessionEnded.Task; + return _stopTask = _sessionEnded.Task; } - } - _workloadCts.Cancel(); + return _stopTask = StopCoreAsync(); + } + } - _ = _sessionEnded.Task.ContinueWith( - static (_, state) => ((CancellationTokenSource)state!).Dispose(), - _workloadCts, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + private async Task StopCoreAsync() + { + // Cancellation callbacks must not run under _gate or prevent other terminals from beginning shutdown. + await Task.Yield(); - return _sessionEnded.Task; + try + { + // Cancellation interrupts Hex1b's process-exit wait. Its PTY disposal forcibly terminates any + // remaining child, so join that disposal rather than relying on the AppHost process exiting. + await Task.WhenAll(_workloadCts.CancelAsync(), _sessionEnded.Task).ConfigureAwait(false); + } + finally + { + _workloadCts.Dispose(); + } } public async ValueTask DisposeAsync() diff --git a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs index 6e9d6987ea6..d0bec68ed03 100644 --- a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs +++ b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs @@ -15,6 +15,9 @@ public sealed class TerminalLaunchOptions /// Gets or sets the title shown on the terminal's dock tab, and in the title bar when the terminal is /// detached into its own window. /// + /// + /// The title must not be empty or consist only of white-space characters. + /// public required string Title { get; set; } /// diff --git a/src/Aspire.Hosting/Terminals/TerminalOwner.cs b/src/Aspire.Hosting/Terminals/TerminalOwner.cs index 0317a068b87..9bac3788db9 100644 --- a/src/Aspire.Hosting/Terminals/TerminalOwner.cs +++ b/src/Aspire.Hosting/Terminals/TerminalOwner.cs @@ -6,7 +6,7 @@ namespace Aspire.Hosting.Terminals; /// -/// Identifies which process owns a terminal's workload, and therefore controls its lifetime. +/// Identifies whether the AppHost or an application resource controls a terminal's workload lifetime. /// /// /// This is fixed when the terminal is created and never changes. It is distinct from @@ -17,17 +17,20 @@ namespace Aspire.Hosting.Terminals; public enum TerminalOwner { /// - /// The workload runs in the AppHost process itself, and its lifetime is controlled by whoever created it. + /// The terminal is created by AppHost code, and its lifetime is controlled by whoever created it. /// + /// + /// Commands run as child processes of the AppHost. Ownership does not imply in-process execution. + /// AppHost, /// /// The workload belongs to a resource in the application model, and its lifetime follows that resource. /// /// - /// These terminals run out-of-process in a per-replica terminal host rather than in the AppHost, so - /// disposing the releases Aspire's handle on the terminal without stopping - /// the underlying workload. + /// The resource's workload is orchestrated by DCP and exposed through a per-replica terminal host. + /// Disposing the releases Aspire's handle on the terminal without stopping + /// that workload. /// Resource } diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 5990533c71a..480249886d4 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -15,7 +15,7 @@ namespace Aspire.Hosting.Terminals; /// -/// Owns every terminal whose process is hosted by the AppHost itself. +/// Manages terminals created and owned by the AppHost. /// /// /// @@ -28,8 +28,8 @@ namespace Aspire.Hosting.Terminals; /// Those are owned by the resource, reachable over a Unix domain socket, and are not tracked here. /// /// -/// Resolve it from the AppHost's service provider: -/// builder.Services.GetRequiredService<TerminalService>(). Only creation and lookup are public; +/// Resolve it from the built AppHost's service provider: +/// app.Services.GetRequiredService<TerminalService>(). Only creation and lookup are public; /// the members the dashboard uses to attach transports and watch the dock's tab list are internal, because /// they are transport plumbing rather than something an AppHost author calls. /// @@ -51,6 +51,7 @@ public sealed class TerminalService : IAsyncDisposable private readonly object _syncLock = new(); private ImmutableHashSet> _outgoingChannels = []; private int _disposed; + private Task? _disposeTask; internal TerminalService(ILogger logger, IConfiguration configuration) { @@ -82,6 +83,13 @@ internal TerminalService(ILogger logger, IConfiguration configu /// terminal that is meant to outlive the call that created it should be left undisposed, and is torn down /// when the AppHost shuts down. /// + /// + /// , its , or its + /// is . + /// + /// + /// The is empty or consists only of white-space characters. + /// /// /// The placement in is not , /// , or . @@ -132,7 +140,7 @@ private static Hex1bTerminalBuilder CreateBuilder(TerminalCommand command) /// internal AspireTerminal CreateTerminal(string title, TerminalPlacement placement, Hex1bTerminalBuilder builder) { - ArgumentNullException.ThrowIfNull(title); + ArgumentException.ThrowIfNullOrWhiteSpace(title); ArgumentNullException.ThrowIfNull(builder); // AppHost-owned terminals have no resource view. Validate both creation paths here before registration, @@ -342,37 +350,6 @@ private void Unsubscribe(Channel channel) internal void NotifyActivated(Hex1bAspireTerminal terminal) => Notify(terminal, TerminalChangeType.Activated); - /// - /// Removes a terminal from the registry and tears its workload down without waiting for it. - /// - /// - /// Used on the interaction completion path, which runs under a lock held by the interaction collection and - /// must not block on a workload that may be ignoring cancellation. The registry entry is removed - /// synchronously so the terminal is unreachable the moment the dialog closes. - /// - internal void RemoveAndDisposeInBackground(string terminalId) - { - if (!_terminals.TryGetValue(terminalId, out var terminal)) - { - return; - } - - Remove(terminal); - _ = DisposeQuietlyAsync(terminal); - - async Task DisposeQuietlyAsync(Hex1bAspireTerminal target) - { - try - { - await target.DisposeAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error disposing terminal {TerminalId}.", target.Id); - } - } - } - internal void NotifyRetitled(Hex1bAspireTerminal terminal) => Notify(terminal, TerminalChangeType.Retitled); @@ -445,20 +422,23 @@ private void Publish(TerminalChange change) } /// - /// Tears down every terminal this service owns. + /// Stops and awaits teardown of every terminal this service owns. /// - public async ValueTask DisposeAsync() + /// A task that completes when all terminal workloads and transports have been disposed. + /// + /// Teardown starts concurrently for all terminals. Repeated calls await the same cleanup operation. + /// + public ValueTask DisposeAsync() { - Hex1bAspireTerminal[] terminals; lock (_syncLock) { - if (_disposed != 0) + if (_disposeTask is not null) { - return; + return new ValueTask(_disposeTask); } _disposed = 1; - terminals = [.. _terminals.Values]; + var terminals = _terminals.Values.ToArray(); _terminals.Clear(); foreach (var terminal in terminals) @@ -473,20 +453,31 @@ public async ValueTask DisposeAsync() { channel.Writer.TryComplete(); } + + _disposeTask = DisposeTerminalsAsync(terminals, ResourceTerminals); + return new ValueTask(_disposeTask); } + } - foreach (var terminal in terminals) - { - _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title); + private async Task DisposeTerminalsAsync(Hex1bAspireTerminal[] terminals, ResourceTerminalCatalog? resourceTerminals) + { + // Workload cancellation can invoke user callbacks. Do not begin teardown under the registry lock. + await Task.Yield(); - // Don't await the workload winding down. AppHost shutdown should not be held up by a terminal - // whose process ignores cancellation; the process is torn down with the AppHost regardless. - _ = terminal.StopAsync(); + try + { + await Task.WhenAll(terminals.Select(async terminal => + { + _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title); + await terminal.StopAsync().ConfigureAwait(false); + })).ConfigureAwait(false); } - - if (ResourceTerminals is { } resourceTerminals) + finally { - await resourceTerminals.DisposeAsync().ConfigureAwait(false); + if (resourceTerminals is not null) + { + await resourceTerminals.DisposeAsync().ConfigureAwait(false); + } } } } diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index e8f1aef8b41..44d94a0f0a9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -1055,13 +1055,13 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.167.0"); + assert.equal(version, "0.168.0-alpha.1573.1.2917e83"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); // Central package rows have the form: - // + // // Match the exact Include value, not Hex1b.Tool or Hex1b.McpServer; // whitespace, attribute order and either XML quote style are allowed. const packages = await readFile(new URL("../../Directory.Packages.props", dashboard), "utf8"); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 4bf8e02d947..2bda36027e1 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -10,6 +10,7 @@ using Aspire.DashboardService.Proto.V1; using Bunit; using Grpc.Core; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.FluentUI.AspNetCore.Components; @@ -499,15 +500,21 @@ public async Task CloseTab_ResponseAfterComponentDisposal_DoesNotNotify(StatusCo Assert.Empty(toasts.FindComponents()); } - [Fact] - public async Task DetachActiveTerminal_CarriesItsFontAndReturnResumesAutoFit() + [Theory] + [InlineData("", "second", "second")] + [InlineData("/aspire/nested", "second", "second")] + [InlineData("", "second #1/?%+", "second%20%231%2F%3F%25%2B")] + [InlineData("/aspire/nested", "second #1/?%+", "second%20%231%2F%3F%25%2B")] + public async Task DetachActiveTerminal_CarriesItsFontAndReturnResumesAutoFit(string pathBase, string terminalId, string escapedTerminalId) { var updates = Channel.CreateUnbounded(); var client = new TestDashboardClient(terminalChannelProvider: () => updates); - TerminalSetupHelpers.SetupTerminalComponents(this, client); + Services.AddSingleton(new TestNavigationManager($"http://localhost{pathBase}/")); + TerminalSetupHelpers.SetupTerminalComponents(this, client, pathBase); + Services.GetRequiredService().NavigateTo("consolelogs/resource/first"); var cut = RenderComponent(); await cut.InvokeAsync(cut.Instance.ToggleAsync); - await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", terminalId)); cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents().Count)); var views = cut.FindComponents(); for (var i = 0; i < views.Count; i++) @@ -520,13 +527,18 @@ await cut.InvokeAsync(() => view.OnTerminalStateChanged(new TerminalToolbarState })); } await cut.FindAll(".terminal-dock-tab-select")[1].ClickAsync(new()); - await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second", "third")); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", terminalId, "third")); cut.WaitForAssertion(() => Assert.Equal(3, cut.FindComponents().Count)); await cut.Find(".terminal-dock-detach").ClickAsync(new()); var open = Assert.Single(JSInterop.Invocations, i => i.Identifier == "openTerminalWindow"); - Assert.Equal("second", open.Arguments[0]); - Assert.Equal("http://localhost/terminal-window/apphost/second?fontSize=19", open.Arguments[1]); + Assert.Equal(terminalId, open.Arguments[0]); + Assert.Equal($"http://localhost{pathBase}/terminal-window/apphost/{escapedTerminalId}?fontSize=19", open.Arguments[1]); + Assert.Equal(960, open.Arguments[2]); + Assert.Equal(600, open.Arguments[3]); + var moduleImport = Assert.Single(JSInterop.Invocations, i => i.Identifier == "import" + && i.Arguments[0] is string path && path.EndsWith("/js/app-terminalwindow.js", StringComparison.Ordinal)); + Assert.Equal($"{pathBase}/js/app-terminalwindow.js", moduleImport.Arguments[0]); Assert.Equal(2, cut.FindComponents().Count); Assert.Single(cut.FindAll(".terminal-dock-detached")); @@ -537,7 +549,7 @@ await cut.InvokeAsync(() => view.OnTerminalStateChanged(new TerminalToolbarState var returned = cut.FindComponents().Select(c => c.Instance).ToArray(); Assert.Equal(3, returned.Length); Assert.Equal([false, true, false], returned.Select(view => view.AutoFit)); - Assert.Equal("dock:second", returned[1].SizeMemoryKey); + Assert.Equal($"dock:{terminalId}", returned[1].SizeMemoryKey); }); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index e44963850ca..eb015954b6e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -91,21 +91,27 @@ public async Task TerminalResource_Live_Selected_RendersBothViews_DefaultsToTerm await Task.CompletedTask; } - [Fact] - public async Task TerminalResource_OpenWindow_CarriesCurrentFontAndKeepsInlineView() + [Theory] + [InlineData("", "terminal-resource", "terminal-resource", 0)] + [InlineData("/aspire/nested", "terminal-resource", "terminal-resource", 0)] + [InlineData("", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B", 2)] + [InlineData("/aspire/nested", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B", 2)] + public async Task TerminalResource_OpenWindow_CarriesCurrentFontAndKeepsInlineView( + string pathBase, string resourceName, string escapedResourceName, int replicaIndex) { var consoleLogsChannel = Channel.CreateUnbounded>(); var resourceChannel = Channel.CreateUnbounded>(); - var resource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Running); + var resource = CreateTerminalResource(resourceName, replicaIndex, replicaCount: replicaIndex + 1, state: KnownResourceState.Running); var client = new TestDashboardClient( isEnabled: true, consoleLogsChannelProvider: _ => consoleLogsChannel, resourceChannelProvider: () => resourceChannel, initialResources: [resource]); + Services.AddSingleton(new TestNavigationManager($"http://localhost{pathBase}/")); SetupConsoleLogsServices(client); - SetupTerminalViewJsInterop(); - TerminalSetupHelpers.SetupTerminalDock(this); - Services.GetRequiredService().NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: resource.Name)); + TerminalSetupHelpers.SetupTerminalView(this, pathBase); + TerminalSetupHelpers.SetupTerminalDock(this, pathBase); + Services.GetRequiredService().NavigateTo($"{pathBase}{DashboardUrls.ConsoleLogsUrl(resource: resource.Name)}"); var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); var cut = RenderComponent(builder => builder @@ -123,8 +129,13 @@ await cut.InvokeAsync(() => terminal.OnTerminalStateChanged(new TerminalToolbarS await cut.InvokeAsync(open.OnClick!); var invocation = Assert.Single(JSInterop.Invocations, i => i.Identifier == "openTerminalWindow"); - Assert.Equal("resource:terminal-resource:0", invocation.Arguments[0]); - Assert.Equal("http://localhost/terminal-window/resource/terminal-resource/0?fontSize=17", invocation.Arguments[1]); + Assert.Equal($"resource:{resourceName}:{replicaIndex}", invocation.Arguments[0]); + Assert.Equal($"http://localhost{pathBase}/terminal-window/resource/{escapedResourceName}/{replicaIndex}?fontSize=17", invocation.Arguments[1]); + Assert.Equal(960, invocation.Arguments[2]); + Assert.Equal(600, invocation.Arguments[3]); + var moduleImport = Assert.Single(JSInterop.Invocations, i => i.Identifier == "import" + && i.Arguments[0] is string path && path.EndsWith("/js/app-terminalwindow.js", StringComparison.Ordinal)); + Assert.Equal($"{pathBase}/js/app-terminalwindow.js", moduleImport.Arguments[0]); Assert.Same(terminal, cut.FindComponent().Instance); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index a424717de1e..8bd4f3c0877 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -13,20 +13,20 @@ namespace Aspire.Dashboard.Components.Tests.Shared; internal static class TerminalSetupHelpers { - public static void SetupTerminalComponents(TestContext context, TestDashboardClient client) + public static void SetupTerminalComponents(TestContext context, TestDashboardClient client, string pathBase = "") { FluentUISetupHelpers.AddCommonDashboardServices(context); FluentUISetupHelpers.SetupFluentUIComponents(context); FluentUISetupHelpers.SetupFluentButton(context); context.Services.AddSingleton(client); context.JSInterop.Setup("Blazor._internal.PageTitle.getAndRemoveExistingTitle", _ => true).SetResult(string.Empty); - SetupTerminalView(context); - SetupTerminalDock(context); + SetupTerminalView(context, pathBase); + SetupTerminalDock(context, pathBase); } - public static void SetupTerminalView(TestContext context) + public static void SetupTerminalView(TestContext context, string pathBase = "") { - var module = SetupTerminalViewModule(context, "/Components/Controls/TerminalView.razor.js"); + var module = SetupTerminalViewModule(context, $"{pathBase}/Components/Controls/TerminalView.razor.js"); module.Setup("initTerminal", _ => true).SetResult(1); module.SetupVoid("setReadOnly", _ => true).SetVoidResult(); } @@ -46,7 +46,7 @@ public static BunitJSModuleInterop SetupTerminalViewModule(TestContext context, return module; } - public static void SetupTerminalDock(TestContext context) + public static void SetupTerminalDock(TestContext context, string pathBase = "") { var dock = context.JSInterop.SetupModule("./Components/Layout/TerminalDock.razor.js"); dock.SetupVoid("registerResizeHandle", _ => true).SetVoidResult(); @@ -54,7 +54,7 @@ public static void SetupTerminalDock(TestContext context) dock.SetupVoid("registerTabNavigation", _ => true).SetVoidResult(); dock.SetupVoid("unregisterTabNavigation", _ => true).SetVoidResult(); - var windows = context.JSInterop.SetupModule("/js/app-terminalwindow.js"); + var windows = context.JSInterop.SetupModule($"{pathBase}/js/app-terminalwindow.js"); windows.Setup("openTerminalWindow", _ => true).SetResult("opened"); windows.Setup("focusTerminalWindow", _ => true).SetResult(true); windows.SetupVoid("closeTerminalWindow", _ => true).SetVoidResult(); diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs index 6e65a75ab1e..099f60598a9 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -1,6 +1,8 @@ // 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 System.Globalization; using System.IO.Pipelines; using System.Text; using Aspire.Hosting.Terminals; @@ -16,6 +18,52 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class Hex1bAspireTerminalTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DisposeAsync_TerminatesPtyProcessIgnoringHangupAndTermination(bool disposeService) + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses POSIX signals."); + + await using var service = TestTerminalService.Create(); + await using var terminal = service.CreateTerminal(new TerminalLaunchOptions + { + Title = "Signal-resistant process", + Placement = TerminalPlacement.None, + Command = new TerminalCommand("/bin/sh") + { + // Ignored signals survive exec. The fixed sleep is a backstop if the test host is killed. + Arguments = ["-c", "trap '' HUP TERM; printf 'pid:%s\\nprocess-ready\\n' \"$$\"; exec sleep 300"] + } + }); + terminal.Start(); + await terminal.WaitForTextAsync("process-ready").DefaultTimeout(); + + // The workload emits "pid:12345\r\nprocess-ready\r\n"; terminal rows can have trailing spaces. + var pidLine = Assert.Single( + terminal.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), + line => line.StartsWith("pid:", StringComparison.Ordinal)); + var pid = int.Parse(pidLine["pid:".Length..], CultureInfo.InvariantCulture); + using var process = Process.GetProcessById(pid); + try + { + Assert.False(process.HasExited); + var disposal = disposeService ? service.DisposeAsync().AsTask() : terminal.DisposeAsync().AsTask(); + await disposal.DefaultTimeout(); + + Assert.True(process.HasExited); + Assert.False(service.TryGetTerminal(terminal.Id, out _)); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().DefaultTimeout(); + } + } + } + [Theory] [InlineData(TerminalPlacement.Dock)] [InlineData(TerminalPlacement.Dialog)] diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 61958299439..32ae0a70a14 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -7,6 +7,7 @@ using System.Threading.Channels; using Aspire.Hosting.Terminals; using Aspire.Hosting.Tests.Dcp; +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Hex1b; using Microsoft.AspNetCore.InternalTesting; @@ -87,6 +88,64 @@ public void CreateTerminal_NullCommand_Throws() })); } + [Theory] + [InlineData(null, false)] + [InlineData(null, true)] + [InlineData("", false)] + [InlineData("", true)] + [InlineData(" ", false)] + [InlineData(" ", true)] + [InlineData("\t\r\n", false)] + [InlineData("\t\r\n", true)] + [InlineData("\u00a0", false)] + [InlineData("\u00a0", true)] + public async Task CreateTerminal_InvalidTitle_ThrowsBeforeRegistration(string? title, bool useBuilder) + { + await using var service = TestTerminalService.Create(); + + void Create() => CreateTerminal(service, TerminalPlacement.Dock, useBuilder, title!); + + if (title is null) + { + Assert.Throws(nameof(title), Create); + } + else + { + Assert.Throws(nameof(title), Create); + } + + Assert.Empty(service.ListAll()); + using var subscription = service.SubscribeDockTerminals(); + Assert.Empty(subscription.InitialState); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + [InlineData("\u00a0")] + public async Task Retitle_InvalidTitle_LeavesTitleUnchanged(string? title) + { + await using var service = TestTerminalService.Create(); + var terminal = CreateDockTerminal(service, "Shell"); + using var subscription = service.SubscribeDockTerminals(); + var channel = Assert.Single(GetOutgoingChannels(service)); + + if (title is null) + { + Assert.Throws(nameof(title), () => terminal.Retitle(title!)); + } + else + { + Assert.Throws(nameof(title), () => terminal.Retitle(title)); + } + + Assert.Equal("Shell", terminal.Handle.Title); + Assert.Equal("Shell", Assert.Single(service.ListAll()).Title); + Assert.False(channel.Reader.TryRead(out _)); + } + [Theory] [InlineData(TerminalPlacement.ResourceView, false)] [InlineData(TerminalPlacement.ResourceView, true)] @@ -98,7 +157,7 @@ public async Task CreateTerminal_UnsupportedPlacement_ThrowsBeforeRegistration(T { await using var service = TestTerminalService.Create(); - var ex = Assert.Throws(nameof(placement), () => CreateTerminal(service, placement, useBuilder)); + var ex = Assert.Throws(nameof(placement), () => CreateTerminal(service, placement, useBuilder, "Shell")); Assert.Equal(placement, ex.ActualValue); Assert.Empty(service.ListAll()); @@ -114,8 +173,9 @@ public async Task CreateTerminal_UnsupportedPlacement_ThrowsBeforeRegistration(T public async Task CreateTerminal_SupportedPlacement_RegistersTerminal(TerminalPlacement placement, bool useBuilder) { await using var service = TestTerminalService.Create(); - await using var terminal = CreateTerminal(service, placement, useBuilder); + await using var terminal = CreateTerminal(service, placement, useBuilder, "Shell"); + Assert.Equal("Shell", terminal.Title); Assert.Equal(TerminalOwner.AppHost, terminal.Owner); Assert.Equal(placement, terminal.Placement); Assert.True(service.TryGetTerminal(terminal.Id, out var registered)); @@ -219,7 +279,7 @@ public void SubscribeDockTerminals_SnapshotExcludesInteractionTerminals() using var subscription = service.SubscribeDockTerminals(); - // An interaction terminal lives and dies with its dialog, so it must never appear as a dock tab. + // Dialog terminals are surfaced by their interaction rather than the dock. var descriptor = Assert.Single(subscription.InitialState); Assert.Equal(dock.Id, descriptor.Id); } @@ -509,6 +569,76 @@ public async Task DisposeAsync_TearsDownRegisteredTerminals() Assert.False(service.TryGetTerminal(terminal.Id, out _)); } + [Fact] + public async Task DisposeAsync_WaitsForAllWorkloadsAndRepeatedCalls() + { + await using var service = TestTerminalService.Create(); + GatedTerminalWorkloadAdapter[] workloads = [new(), new()]; + var terminals = workloads.Select((workload, index) => + service.CreateTerminal($"Terminal {index}", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(workload))).ToArray(); + foreach (var terminal in terminals) + { + terminal.Start(); + } + + Task disposal = Task.CompletedTask; + try + { + await Task.WhenAll(workloads.Select(workload => workload.ReadStarted)).DefaultTimeout(); + disposal = service.DisposeAsync().AsTask(); + await Task.WhenAll(workloads.Select(workload => workload.DisposeStarted)).DefaultTimeout(); + + Assert.False(disposal.IsCompleted); + Assert.Same(disposal, service.DisposeAsync().AsTask()); + Assert.Empty(service.ListAll()); + + var terminalDisposal = terminals[0].DisposeAsync().AsTask(); + Assert.False(terminalDisposal.IsCompleted); + workloads[0].ReleaseDispose(); + await terminalDisposal.DefaultTimeout(); + Assert.False(disposal.IsCompleted); + + workloads[1].ReleaseDispose(); + await disposal.DefaultTimeout(); + Assert.All(workloads, workload => Assert.True(workload.IsDisposed)); + } + finally + { + foreach (var workload in workloads) + { + workload.ReleaseDispose(); + } + + await disposal.DefaultTimeout(); + } + } + + [Fact] + public async Task DisposeAsync_ObservesWorkloadDisposalFailure() + { + var service = TestTerminalService.Create(); + var expected = new IOException("Workload disposal failed."); + var workload = new GatedTerminalWorkloadAdapter { DisposalException = expected }; + var terminal = service.CreateTerminal("Failure", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + terminal.Start(); + var disposal = service.DisposeAsync().AsTask(); + try + { + await workload.DisposeStarted.DefaultTimeout(); + Assert.False(disposal.IsCompleted); + } + finally + { + workload.ReleaseDispose(); + } + + Assert.Same(expected, await Assert.ThrowsAsync(() => disposal).DefaultTimeout()); + Assert.Same(disposal, service.DisposeAsync().AsTask()); + Assert.Same(expected, await Assert.ThrowsAsync(() => terminal.DisposeAsync().AsTask()).DefaultTimeout()); + } + [Fact] public async Task CreateTerminal_AfterDispose_Throws() { @@ -693,12 +823,12 @@ public void ListAll_WithoutAResourceCatalogReturnsOnlyAppHostTerminals() Assert.Single(service.ListAll()); } - private static AspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement, bool useBuilder) + private static AspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement, bool useBuilder, string title) => useBuilder - ? service.CreateTerminal("Shell", placement, Hex1bTerminal.CreateBuilder().WithPtyProcess("bash")) + ? service.CreateTerminal(title, placement, Hex1bTerminal.CreateBuilder().WithPtyProcess("bash")) : service.CreateTerminal(new TerminalLaunchOptions { - Title = "Shell", + Title = title, Command = new TerminalCommand("bash"), Placement = placement }); diff --git a/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWorkloadAdapter.cs b/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWorkloadAdapter.cs new file mode 100644 index 00000000000..8846eed4b0f --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWorkloadAdapter.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b; + +namespace Aspire.Hosting.Tests.Utils; + +internal sealed class GatedTerminalWorkloadAdapter : IHex1bTerminalWorkloadAdapter +{ + private readonly TaskCompletionSource _readStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _disposeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseDispose = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task ReadStarted => _readStarted.Task; + public Task DisposeStarted => _disposeStarted.Task; + public bool IsDisposed { get; private set; } + public Exception? DisposalException { get; init; } + public event Action? Disconnected; + + public void ReleaseDispose() => _releaseDispose.TrySetResult(); + + public async ValueTask> ReadOutputAsync(CancellationToken ct = default) + { + _readStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return ReadOnlyMemory.Empty; + } + + public ValueTask WriteInputAsync(ReadOnlyMemory data, CancellationToken ct = default) + => ValueTask.CompletedTask; + + public ValueTask ResizeAsync(int width, int height, CancellationToken ct = default) + => ValueTask.CompletedTask; + + public async ValueTask DisposeAsync() + { + _disposeStarted.TrySetResult(); + await _releaseDispose.Task; + if (DisposalException is { } exception) + { + throw exception; + } + + IsDisposed = true; + Disconnected?.Invoke(); + } +} From 9fed0322b1f8997d1afc689d1128525abd053538 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 14 Sep 2026 11:38:55 +1000 Subject: [PATCH 065/106] Keep detached terminal window names collision-free Use lossless key encoding while preserving same-key window reuse. Add browser behavior regressions and CI test-selection coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- eng/github-ci/test-trigger-map.yml | 1 + .../wwwroot/js/app-terminalwindow.js | 4 +- .../JavaScript/TerminalWindow.test.mjs | 117 ++++++++++++++++++ .../TestTriggerMap/TestTriggerMapTests.cs | 13 ++ .../DashboardTerminalScriptTests.cs | 7 ++ 5 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs diff --git a/eng/github-ci/test-trigger-map.yml b/eng/github-ci/test-trigger-map.yml index 2a1a3dc9f39..ad775375924 100644 --- a/eng/github-ci/test-trigger-map.yml +++ b/eng/github-ci/test-trigger-map.yml @@ -103,6 +103,7 @@ path_rules: - paths: - src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js - src/Aspire.Dashboard/wwwroot/js/app.js + - src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js - src/Aspire.Dashboard/package.json - src/Aspire.Dashboard/package-lock.json - src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/** diff --git a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js index 612781f7866..5675632e61c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js @@ -85,7 +85,9 @@ export function isTerminalWindowOpen(key) { } function windowNameFor(key) { - return `aspire-terminal-${key.replace(/[^a-zA-Z0-9_-]/g, '_')}`; + // Named targets reuse browsing contexts, so preserve distinctions such as "a.b" versus "a_b". + // https://developer.mozilla.org/en-US/docs/Web/API/Window/open#target + return `aspire-terminal-${encodeURIComponent(key)}`; } function ensurePolling() { diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs new file mode 100644 index 00000000000..aeb01388c64 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import assert from "node:assert/strict"; +import { afterEach, beforeEach, mock, test } from "node:test"; +import * as terminalWindows from "../../../src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js"; + +let keys; +let calls; +let poll; +let windowDescriptor; +const owner = { invokeMethodAsync: async () => {} }; + +beforeEach(() => { + keys = new Set(); + calls = []; + poll = null; + const contexts = new Map(); + windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + open(url, name) { + // Browsers reuse and navigate an existing browsing context with the same target name. + let popup = contexts.get(name); + if (!popup || popup.closed) { + popup = { + closed: false, + focusCalls: 0, + focus() { this.focusCalls++; }, + close() { this.closed = true; }, + }; + contexts.set(name, popup); + } + popup.url = url; + calls.push({ name, popup }); + return popup; + }, + }, + }); + mock.method(globalThis, "setInterval", callback => { + poll = callback; + return 1; + }); + mock.method(globalThis, "clearInterval", () => { poll = null; }); +}); + +afterEach(() => { + for (const key of keys) { + terminalWindows.closeTerminalWindow(key); + } + poll?.(); + mock.restoreAll(); + if (windowDescriptor) { + Object.defineProperty(globalThis, "window", windowDescriptor); + } else { + delete globalThis.window; + } +}); + +function open(key, url) { + keys.add(key); + return terminalWindows.openTerminalWindow(key, url, 800, 600, owner); +} + +for (const [firstKey, secondKey] of [ + ["resource:a.b:0", "resource:a_b:0"], + ["resource:a:b:0", "resource:a_b:0"], + ["resource:a/b:0", "resource:a_b:0"], + ["resource:caf\u00e9:0", "resource:caf\u00e8:0"], + ["resource:a%3Ab:0", "resource:a:b:0"], +]) { + test(`distinct keys keep separate windows: ${firstKey} and ${secondKey}`, () => { + const firstUrl = "https://localhost/dashboard/terminal-window/resource/first/0"; + const secondUrl = "https://localhost/dashboard/terminal-window/resource/second/0"; + assert.equal(open(firstKey, firstUrl), "opened"); + assert.equal(open(secondKey, secondUrl), "opened"); + + const [first, second] = calls; + assert.notEqual(first.name, second.name); + assert.notEqual(first.popup, second.popup); + assert.equal(first.popup.url, firstUrl); + assert.equal(second.popup.url, secondUrl); + + assert.equal(terminalWindows.focusTerminalWindow(firstKey), true); + assert.equal(first.popup.focusCalls, 1); + assert.equal(second.popup.focusCalls, 0); + + terminalWindows.closeTerminalWindow(firstKey); + assert.equal(first.popup.closed, true); + assert.equal(second.popup.closed, false); + assert.equal(terminalWindows.isTerminalWindowOpen(firstKey), false); + assert.equal(terminalWindows.isTerminalWindowOpen(secondKey), true); + }); +} + +test("the same key focuses its window and reuses its stable name after untracking", () => { + const key = "resource:a.b:0"; + const firstUrl = "https://localhost/dashboard/terminal-window/resource/a.b/0"; + const nextUrl = `${firstUrl}?fontSize=16`; + assert.equal(open(key, firstUrl), "opened"); + const first = calls[0]; + assert.equal(open(key, nextUrl), "focused"); + assert.equal(calls.length, 1); + assert.equal(first.popup.focusCalls, 1); + assert.equal(first.popup.url, firstUrl); + + terminalWindows.untrackTerminalWindow(key); + assert.equal(first.popup.closed, false); + assert.equal(terminalWindows.isTerminalWindowOpen(key), false); + + assert.equal(open(key, nextUrl), "opened"); + assert.equal(calls.length, 2); + assert.equal(calls[1].name, first.name); + assert.equal(calls[1].popup, first.popup); + assert.equal(first.popup.url, nextUrl); +}); diff --git a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs index 2f47284256f..5b57fb163fa 100644 --- a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs +++ b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs @@ -502,6 +502,19 @@ public void AuditedLoosePathSelectsExactConsumerSetWithoutRunAllFallback(string Assert.Equal(expectedTargets.Order(StringComparer.Ordinal), actualTargets); } + [Theory] + [InlineData("src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js")] + [InlineData("tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs")] + public void DashboardTerminalWindowInputsSelectInfrastructureTests(string path) + { + var result = SelectWithRealMap(path); + + Assert.False(result.SelectsAll); + Assert.Empty(result.UnmatchedFiles); + // Infrastructure executes the scripts in addition to the consumers attributed by the project graph. + Assert.Contains("Infrastructure.Tests", result.TestProjects); + } + [Theory] [InlineData(".gitattributes")] [InlineData("eng/scripts/gha-testreport.ps1")] diff --git a/tests/Infrastructure.Tests/WorkflowScripts/DashboardTerminalScriptTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/DashboardTerminalScriptTests.cs index ebe52b7a6f5..4ef11943998 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/DashboardTerminalScriptTests.cs +++ b/tests/Infrastructure.Tests/WorkflowScripts/DashboardTerminalScriptTests.cs @@ -22,6 +22,13 @@ public async Task TerminalInputDoesNotActivateDashboardShortcuts() await RunScriptAsync("KeyboardShortcuts.test.mjs"); } + [Fact] + [RequiresTools(["node"])] + public async Task DetachedTerminalWindowsKeepDistinctNames() + { + await RunScriptAsync("TerminalWindow.test.mjs"); + } + private async Task RunScriptAsync(string script) { using var command = new NodeCommand(output) From 76def428cd71cfe99d7413cc991c500441a28c8f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 14 Sep 2026 12:29:37 +1000 Subject: [PATCH 066/106] Preserve dashboard base paths for AppHost terminal sockets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Dialogs/InteractionsInputDialog.razor.cs | 2 +- .../Components/Layout/TerminalDock.razor.cs | 2 +- .../Components/Pages/TerminalWindow.razor.cs | 2 +- .../ServiceClient/IDashboardClient.cs | 7 ++-- .../Dialogs/InteractionsInputDialogTests.cs | 34 ++++++++++++++++++- .../Layout/TerminalDockTests.cs | 21 ++++++++++++ .../Pages/TerminalWindowTests.cs | 28 ++++++++++++--- .../Shared/TerminalSetupHelpers.cs | 10 ++++++ 8 files changed, 94 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs index e001ab5023e..778641e866e 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs @@ -306,7 +306,7 @@ private async Task ToggleSecretTextVisibilityAsync(InputViewModel inputModel) /// private static string BuildInteractionTerminalEndpoint(InputViewModel inputModel) { - return $"/api/apphost-terminal?terminalId={Uri.EscapeDataString(inputModel.Input.TerminalId ?? string.Empty)}"; + return $"api/apphost-terminal?terminalId={Uri.EscapeDataString(inputModel.Input.TerminalId ?? string.Empty)}"; } private static Icon GetSecretTextIcon(InputViewModel inputModel) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index d6531b5c076..f10b660b8db 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -462,7 +462,7 @@ private async Task CloseDetachedWindowAsync(string terminalId) } private static string BuildEndpoint(string terminalId) - => $"/api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}"; + => $"api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}"; public async ValueTask DisposeAsync() { diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs index 8eb7de98809..537351efaa5 100644 --- a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor.cs @@ -78,7 +78,7 @@ protected override async Task OnParametersSetAsync() _routeIdentity = routeIdentity; var generation = ++_watchGeneration; _ended = false; - _endpoint = terminalId is not null ? $"/api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}" : null; + _endpoint = terminalId is not null ? $"api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}" : null; _title = terminalId ?? (resourceName is not null ? replicaIndex > 0 ? $"{resourceName} #{replicaIndex}" : resourceName : string.Empty); diff --git a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs index 955aa9d7680..93e956c13b2 100644 --- a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs @@ -67,11 +67,12 @@ public interface IDashboardClient : IResourceRepository, IAsyncDisposable Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken); /// - /// Opens a duplex byte stream to a terminal-typed interaction input hosted by the AppHost. + /// Opens a duplex byte stream to an AppHost-owned terminal. /// /// - /// The returned stream carries opaque HMP1 frames in both directions. The dashboard relays them verbatim between - /// the browser's WebSocket and the AppHost, exactly as it does for resource terminals. + /// Used by terminal interaction inputs, docked terminals, and detached terminal windows. + /// The returned stream carries HMP1 frames between the dashboard and the AppHost. The dashboard's terminal + /// replica bridges this stream to the browser's HWT1 WebSocket connection. /// Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs index 3cd77e14fc7..0168ad77f21 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs @@ -25,6 +25,38 @@ namespace Aspire.Dashboard.Components.Tests.Dialogs; [UseCulture("en-US")] public sealed class InteractionsInputDialogTests : DashboardTestContext { + [Theory] + [InlineData("", "terminal", "terminal")] + [InlineData("/aspire/nested", "terminal", "terminal")] + [InlineData("", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] + [InlineData("/aspire/nested", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] + public async Task AppHostTerminalEndpoint_UsesDashboardBaseUri(string pathBase, string terminalId, string escapedTerminalId) + { + Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); + TerminalSetupHelpers.SetupTerminalView(this, pathBase); + var getCut = SetUpDialog(out var dialogService); + Services.GetRequiredService().NavigateTo("consolelogs/resource/other"); + var viewModel = new InteractionsInputsDialogViewModel + { + Interaction = new WatchInteractionsResponseUpdate + { + InteractionId = 1, + InputsDialog = new InteractionInputsDialog + { + InputItems = { new InteractionInput { Name = "shell", InputType = InputType.Terminal, TerminalId = terminalId } } + } + }, + Message = string.Empty, + DashboardClient = new TestDashboardClient(), + OnSubmitCallback = (_, _) => Task.CompletedTask + }; + + await dialogService.ShowDialogAsync(viewModel, new DialogParameters { Title = "Shell" }); + + getCut().WaitForAssertion(() => TerminalSetupHelpers.AssertSingleTerminalConnection(this, + $"wss://dashboard.example{pathBase}/api/apphost-terminal?terminalId={escapedTerminalId}")); + } + [Theory] [InlineData(false, false)] [InlineData(false, true)] @@ -73,7 +105,7 @@ public async Task Render_TerminalRespectsDisabledAndLoading(bool disabled, bool var current = cut.FindComponent().Instance; Assert.Same(terminal, current); Assert.Equal(state.Disabled || state.Loading, current.ReadOnly); - Assert.Equal("/api/apphost-terminal?terminalId=terminal", current.EndpointPathAndQuery); + Assert.Equal("api/apphost-terminal?terminalId=terminal", current.EndpointPathAndQuery); }); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 2bda36027e1..78f74bcb90e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -23,6 +23,27 @@ namespace Aspire.Dashboard.Components.Tests.Layout; [UseCulture("en-US")] public class TerminalDockTests : DashboardTestContext { + [Theory] + [InlineData("", "terminal", "terminal")] + [InlineData("/aspire/nested", "terminal", "terminal")] + [InlineData("", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] + [InlineData("/aspire/nested", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] + public async Task AppHostTerminalEndpoint_UsesDashboardBaseUri(string pathBase, string terminalId, string escapedTerminalId) + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); + TerminalSetupHelpers.SetupTerminalComponents(this, client, pathBase); + Services.GetRequiredService().NavigateTo("consolelogs/resource/other"); + var cut = RenderComponent(); + + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot(terminalId)); + + cut.WaitForAssertion(() => TerminalSetupHelpers.AssertSingleTerminalConnection(this, + $"wss://dashboard.example{pathBase}/api/apphost-terminal?terminalId={escapedTerminalId}")); + } + [Theory] [InlineData(400, 900, 120, 900, 400)] [InlineData(-1, 900, 120, 900, 120)] diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs index 380091e539a..347fba8fc7e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs @@ -18,6 +18,24 @@ namespace Aspire.Dashboard.Components.Tests.Pages; public class TerminalWindowTests : DashboardTestContext { + [Theory] + [InlineData("", "terminal", "terminal")] + [InlineData("/aspire/nested", "terminal", "terminal")] + [InlineData("", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] + [InlineData("/aspire/nested", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] + public void AppHostTerminalEndpoint_UsesDashboardBaseUri(string pathBase, string terminalId, string escapedTerminalId) + { + var updates = Channel.CreateUnbounded(); + Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); + TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient(terminalChannelProvider: () => updates), pathBase); + Services.GetRequiredService().NavigateTo($"terminal-window/apphost/{escapedTerminalId}"); + + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, terminalId)); + + cut.WaitForAssertion(() => TerminalSetupHelpers.AssertSingleTerminalConnection(this, + $"wss://dashboard.example{pathBase}/api/apphost-terminal?terminalId={escapedTerminalId}")); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -98,7 +116,7 @@ await AsyncTestHelpers.AssertIsTrueRetryAsync( { Assert.Equal(2, client.TerminalSubscriptionCount); Assert.Equal(1, client.ActiveTerminalSubscriptionCount); - Assert.Equal("/api/apphost-terminal?terminalId=second", cut.FindComponent().Instance.EndpointPathAndQuery); + Assert.Equal("api/apphost-terminal?terminalId=second", cut.FindComponent().Instance.EndpointPathAndQuery); Assert.Equal("second", head.Find("title").TextContent); Assert.Empty(cut.FindAll(".terminal-window-ended")); }); @@ -154,7 +172,7 @@ await AsyncTestHelpers.AssertIsTrueRetryAsync( Assert.Equal(2, client.TerminalSubscriptionCount); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "next", "Next shell")); head.WaitForAssertion(() => Assert.Equal("Next shell", head.Find("title").TextContent)); - Assert.Equal("/api/apphost-terminal?terminalId=next", cut.FindComponent().Instance.EndpointPathAndQuery); + Assert.Equal("api/apphost-terminal?terminalId=next", cut.FindComponent().Instance.EndpointPathAndQuery); } [Theory] @@ -195,11 +213,11 @@ public async Task RapidRouteChanges_IgnoreOldUpdatesAndOnlyWatchLatest(bool retu await firstUpdates.Writer.WriteAsync(delayedUpdate); await updateReceived.Task.DefaultTimeout(); var intermediateRoute = SetTerminalAsync(cut, "intermediate"); - cut.WaitForAssertion(() => Assert.Equal("/api/apphost-terminal?terminalId=intermediate", + cut.WaitForAssertion(() => Assert.Equal("api/apphost-terminal?terminalId=intermediate", cut.FindComponent().Instance.EndpointPathAndQuery)); var latestId = returnToFirst ? "first" : "latest"; var latestRoute = SetTerminalAsync(cut, latestId); - cut.WaitForAssertion(() => Assert.Equal($"/api/apphost-terminal?terminalId={latestId}", + cut.WaitForAssertion(() => Assert.Equal($"api/apphost-terminal?terminalId={latestId}", cut.FindComponent().Instance.EndpointPathAndQuery)); Assert.False(intermediateRoute.IsCompleted); Assert.False(latestRoute.IsCompleted); @@ -247,7 +265,7 @@ public async Task DisposeDuringRouteChange_JoinsOldWatchWithoutStartingReplaceme await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot()); await updateReceived.Task.DefaultTimeout(); var routeChange = SetTerminalAsync(cut, "next"); - cut.WaitForAssertion(() => Assert.Equal("/api/apphost-terminal?terminalId=next", + cut.WaitForAssertion(() => Assert.Equal("api/apphost-terminal?terminalId=next", cut.FindComponent().Instance.EndpointPathAndQuery)); var disposal = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); Assert.False(disposal.IsCompleted); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 8bd4f3c0877..223a052c3d6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -8,6 +8,7 @@ using Bunit; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Assert = Xunit.Assert; namespace Aspire.Dashboard.Components.Tests.Shared; @@ -61,6 +62,15 @@ public static void SetupTerminalDock(TestContext context, string pathBase = "") windows.SetupVoid("untrackTerminalWindow", _ => true).SetVoidResult(); } + public static void AssertSingleTerminalConnection(TestContext context, string expectedWebSocketUrl) + { + var invocation = Assert.Single(context.JSInterop.Invocations, invocation => invocation.Identifier == "initTerminal"); + var options = Assert.IsType(invocation.Arguments[3]); + Assert.Equal($"{expectedWebSocketUrl}&viewId={options.ViewId}", invocation.Arguments[1]); + Assert.True(context.Services.GetRequiredService().TryGet( + options.ViewId, new Uri(expectedWebSocketUrl).PathAndQuery, out _)); + } + public static WatchTerminalsUpdate Snapshot(params string[] terminalIds) => new() { Snapshot = new TerminalDescriptorList From 0c940c6e63a21124561abec9911b9040cd0b2a44 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 14 Sep 2026 16:45:44 +1000 Subject: [PATCH 067/106] Configure bundled ConPTY for DCP terminals Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Terminals/Terminals.AppHost/AppHost.cs | 5 +- .../TerminalInteractionCommands.cs | 34 +++++++++++ src/Aspire.Hosting/Dcp/DcpHost.cs | 56 ++++++++++++++++++ .../Dcp/DcpHostNotificationTests.cs | 59 +++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index 3ac99f9680d..0052df8a70e 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -34,7 +34,7 @@ // Long-running container that the "Shell into container" interaction command execs into. Aspire is not orchestrating // the exec — the AppHost shells out to `docker exec` — so the container needs a stable, predictable name. -builder.AddContainer("shellbox", "alpine") +var shellbox = builder.AddContainer("shellbox", "alpine") .WithContainerName("terminals-playground-shellbox") .WithArgs("sleep", "infinity") .WithContainerShellCommand() @@ -52,6 +52,9 @@ if (OperatingSystem.IsWindows()) { + // Local PowerShell launched directly by Hex1b, providing a Docker- and DCP-independent PTY debugging path. + shellbox.WithPowerShellDockCommand(); + // Single-replica executable wrapping cmd.exe to demonstrate that // WithTerminal() also works for arbitrary executables, not just projects. builder.AddExecutable("shell", "cmd.exe", ".") diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index a5be1ed0987..d0faacfaef7 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -226,6 +226,40 @@ public static IResourceBuilder WithDockShellCommand(this IRes }); } + /// + /// Adds a command that opens a local PowerShell session in the terminal dock. + /// + /// + /// The PowerShell process is launched directly by the AppHost-owned Hex1b terminal, so this command provides a + /// debugging control that does not depend on Docker or DCP's PTY implementation. + /// + [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")] + public static IResourceBuilder WithPowerShellDockCommand(this IResourceBuilder container) + { + return container.WithCommand( + "terminal-dock-powershell", + "Open PowerShell (terminal dock)", + executeCommand: commandContext => + { + var terminalService = commandContext.Services.GetRequiredService(); + + // The terminal remains open until the user closes its dock tab or the AppHost shuts down. + var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + { + Title = "PowerShell", + Command = new TerminalCommand("pwsh.exe") + { + Arguments = ["-NoLogo"] + } + }); + + terminal.Start(); + terminal.Show(); + + return Task.FromResult(CommandResults.Success()); + }); + } + /// /// Adds a command that plays a terminal-based guessing game by driving the process from AppHost code. /// diff --git a/src/Aspire.Hosting/Dcp/DcpHost.cs b/src/Aspire.Hosting/Dcp/DcpHost.cs index a22e4fdd7e5..79425ec8f88 100644 --- a/src/Aspire.Hosting/Dcp/DcpHost.cs +++ b/src/Aspire.Hosting/Dcp/DcpHost.cs @@ -3,8 +3,10 @@ using System.Buffers; using System.Collections; +using System.Diagnostics.CodeAnalysis; using System.IO.Pipelines; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Text; using Aspire.Dashboard.Utils; @@ -24,6 +26,7 @@ namespace Aspire.Hosting.Dcp; internal sealed class DcpHost { + private const string DcpConPtyPathEnvironmentVariable = "DCP_CONPTY_PATH"; private const int LoggingSocketConnectionBacklog = 3; private readonly DistributedApplicationModel _applicationModel; @@ -346,6 +349,8 @@ public ProcessSpec CreateDcpProcessSpec(Locations locations) } } + ConfigureBundledConPty(dcpProcessSpec.EnvironmentVariables); + // DCP intentionally owns DCP_OTEL_* names instead of reading Aspire's ASPIRE_* profiling // names. Apply the mapping after copying the AppHost environment so this capture's // profiling settings win over any inherited DCP_OTEL_* values. @@ -372,6 +377,57 @@ public ProcessSpec CreateDcpProcessSpec(Locations locations) return dcpProcessSpec; } + private void ConfigureBundledConPty(IDictionary environmentVariables) + { + if (!OperatingSystem.IsWindows() || + environmentVariables.Keys.Any(key => string.Equals(key, DcpConPtyPathEnvironmentVariable, StringComparison.OrdinalIgnoreCase))) + { + // An explicitly inherited value, including an empty value, is authoritative. DCP treats an empty + // value as a request to use the inbox provider and reports invalid nonempty paths itself. + return; + } + + if (TryGetBundledConPtyPath(_dcpOptions.TerminalHostPath, RuntimeInformation.OSArchitecture, out var conPtyPath)) + { + environmentVariables[DcpConPtyPathEnvironmentVariable] = conPtyPath; + _logger.LogDebug("Configured DCP to use the bundled ConPTY provider at '{ConPtyPath}'.", conPtyPath); + } + else + { + // Older or customized layouts may not contain Hex1b's native payload. Leaving the variable unset + // preserves DCP's inbox CreatePseudoConsole behavior instead of turning an optional enhancement into + // an application startup failure. + _logger.LogDebug("A complete bundled ConPTY provider was not found; DCP will use the inbox Windows provider."); + } + } + + internal static bool TryGetBundledConPtyPath(string? terminalHostPath, Architecture osArchitecture, [NotNullWhen(true)] out string? conPtyPath) + { + conPtyPath = null; + if (string.IsNullOrWhiteSpace(terminalHostPath) || + Path.GetDirectoryName(Path.GetFullPath(terminalHostPath)) is not { } directory) + { + return false; + } + + var architectureDirectory = osArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => null + }; + + if (architectureDirectory is null || + !File.Exists(Path.Combine(directory, "conpty.dll")) || + !File.Exists(Path.Combine(directory, architectureDirectory, "OpenConsole.exe"))) + { + return false; + } + + conPtyPath = directory; + return true; + } + private void SetDcpProfilingEnvironment(IDictionary environmentVariables) { if (_configuration.GetBool(KnownConfigNames.ProfilingEnabled, KnownConfigNames.Legacy.StartupProfilingEnabled) is { } profilingEnabled) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs index 550381f7142..5a5d0f6b0ff 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Globalization; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Aspire.Hosting.Dcp; @@ -570,6 +571,64 @@ public void CreateDcpProcessSpec_WithContainerRuntime_IncludesContainerRuntimeAr Assert.Contains("--container-runtime \"podman\"", processSpec.Arguments); } + [Theory] + [InlineData(Architecture.X64, "x64")] + [InlineData(Architecture.Arm64, "arm64")] + public void TryGetBundledConPtyPath_WithCompletePayload_ReturnsTerminalHostDirectory(Architecture architecture, string architectureDirectory) + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var terminalHostPath = Path.Combine(directory.FullName, "aspire-managed.exe"); + File.WriteAllText(terminalHostPath, ""); + File.WriteAllText(Path.Combine(directory.FullName, "conpty.dll"), ""); + Directory.CreateDirectory(Path.Combine(directory.FullName, architectureDirectory)); + File.WriteAllText(Path.Combine(directory.FullName, architectureDirectory, "OpenConsole.exe"), ""); + + var found = DcpHost.TryGetBundledConPtyPath(terminalHostPath, architecture, out var conPtyPath); + + Assert.True(found); + Assert.Equal(directory.FullName, conPtyPath); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void TryGetBundledConPtyPath_WithIncompletePayload_ReturnsFalse(bool includeConPty, bool includeOpenConsole) + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var terminalHostPath = Path.Combine(directory.FullName, "aspire-managed.exe"); + File.WriteAllText(terminalHostPath, ""); + if (includeConPty) + { + File.WriteAllText(Path.Combine(directory.FullName, "conpty.dll"), ""); + } + + if (includeOpenConsole) + { + var architectureDirectory = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + Directory.CreateDirectory(Path.Combine(directory.FullName, architectureDirectory)); + File.WriteAllText(Path.Combine(directory.FullName, architectureDirectory, "OpenConsole.exe"), ""); + } + + var found = DcpHost.TryGetBundledConPtyPath(terminalHostPath, RuntimeInformation.OSArchitecture, out var conPtyPath); + + Assert.False(found); + Assert.Null(conPtyPath); + } + finally + { + directory.Delete(recursive: true); + } + } + [Fact] public void CreateDcpProcessSpec_DoesNotInheritExcludedEnvironmentVariables() { From 3f063cdbcfcdf8d6bd952a3693d516665f848a41 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 11:36:02 +1000 Subject: [PATCH 068/106] Flatten terminal launch options and update Hex1b alpha Move process configuration onto TerminalLaunchOptions and migrate Hosting callers, playground examples, and tests. Update the Hex1b and web-terminal pair to 0.168.0-alpha.1585.1.1de7974 with the complete vendored distribution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 46 ++- .../TerminalInteractionCommands.cs | 43 +-- src/Aspire.Dashboard/package-lock.json | 8 +- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 4 +- .../wwwroot/js/hex1b-web-terminal/README.md | 239 ++++++++++++++- .../js/hex1b-web-terminal/dist/index.d.ts | 1 + .../js/hex1b-web-terminal/dist/index.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/index.js | 1 + .../js/hex1b-web-terminal/dist/index.js.map | 2 +- .../dist/link-detection-worker.d.ts | 3 + .../dist/link-detection-worker.d.ts.map | 1 + .../dist/link-detection-worker.js | 100 ++++++ .../dist/link-detection-worker.js.map | 1 + .../dist/link-detection.d.ts | 29 ++ .../dist/link-detection.d.ts.map | 1 + .../hex1b-web-terminal/dist/link-detection.js | 290 ++++++++++++++++++ .../dist/link-detection.js.map | 1 + .../hex1b-web-terminal/dist/link-options.d.ts | 19 ++ .../dist/link-options.d.ts.map | 1 + .../hex1b-web-terminal/dist/link-options.js | 108 +++++++ .../dist/link-options.js.map | 1 + .../dist/link-presentation.d.ts | 41 +++ .../dist/link-presentation.d.ts.map | 1 + .../dist/link-presentation.js | 125 ++++++++ .../dist/link-presentation.js.map | 1 + .../js/hex1b-web-terminal/dist/link-text.d.ts | 18 ++ .../dist/link-text.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/link-text.js | 92 ++++++ .../hex1b-web-terminal/dist/link-text.js.map | 1 + .../hex1b-web-terminal/dist/link-types.d.ts | 72 +++++ .../dist/link-types.d.ts.map | 1 + .../js/hex1b-web-terminal/dist/link-types.js | 2 + .../hex1b-web-terminal/dist/link-types.js.map | 1 + .../dist/link-worker-protocol.d.ts | 31 ++ .../dist/link-worker-protocol.d.ts.map | 1 + .../dist/link-worker-protocol.js | 10 + .../dist/link-worker-protocol.js.map | 1 + .../hex1b-web-terminal/dist/mouse-input.d.ts | 10 +- .../dist/mouse-input.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/mouse-input.js | 26 +- .../dist/mouse-input.js.map | 2 +- .../js/hex1b-web-terminal/dist/renderer.d.ts | 3 +- .../hex1b-web-terminal/dist/renderer.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/renderer.js | 9 +- .../hex1b-web-terminal/dist/renderer.js.map | 2 +- .../dist/terminal-worker.js | 41 ++- .../dist/terminal-worker.js.map | 2 +- .../js/hex1b-web-terminal/dist/types.d.ts | 10 + .../js/hex1b-web-terminal/dist/types.d.ts.map | 2 +- .../js/hex1b-web-terminal/dist/types.js.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.d.ts | 3 +- .../dist/web-terminal.d.ts.map | 2 +- .../hex1b-web-terminal/dist/web-terminal.js | 214 ++++++++++++- .../dist/web-terminal.js.map | 2 +- .../hex1b-web-terminal/dist/wire-types.d.ts | 25 +- .../dist/wire-types.d.ts.map | 2 +- .../hex1b-web-terminal/dist/wire-types.js.map | 2 +- .../js/hex1b-web-terminal/package.json | 2 +- src/Aspire.Hosting/IInteractionService.cs | 2 +- .../Terminals/TerminalCommand.cs | 127 -------- .../Terminals/TerminalLaunchOptions.cs | 94 +++++- .../Terminals/TerminalService.cs | 24 +- .../JavaScript/TerminalView.test.mjs | 4 +- .../Dashboard/DashboardServiceTests.cs | 4 +- .../ResourceCommandServiceTests.cs | 2 +- .../Terminals/AspireTerminalTests.cs | 2 +- .../Terminals/Hex1bAspireTerminalTests.cs | 66 +++- .../InteractionServiceTerminalTests.cs | 2 +- .../Terminals/ResourceTerminalCatalogTests.cs | 2 +- .../Terminals/TerminalCommandTests.cs | 102 ------ .../Terminals/TerminalLaunchOptionsTests.cs | 146 +++++++++ .../Terminals/TerminalServiceTests.cs | 20 +- 74 files changed, 1911 insertions(+), 355 deletions(-) create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.js.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.d.ts create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.d.ts.map create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.js create mode 100644 src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.js.map delete mode 100644 src/Aspire.Hosting/Terminals/TerminalCommand.cs delete mode 100644 tests/Aspire.Hosting.Tests/Terminals/TerminalCommandTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b786f6c6c0d..4b8f715a563 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,7 +117,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 67c5f4f0a60..4f64566402a 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -18,6 +18,50 @@ builder.AddProject("agent") The dashboard then renders a Hex1b web terminal per replica, and the CLI exposes the same session as `aspire terminal agent --replica 0`. +## AppHost-owned terminals + +For processes that the AppHost launches directly rather than as resources, use +the experimental `TerminalService` API (`ASPIRETERMINAL002`). +`TerminalLaunchOptions` holds the executable, arguments, working directory, +environment variables, initial grid dimensions, title, and dashboard placement: + +```csharp +using Aspire.Hosting.Terminals; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable ASPIRETERMINAL002 + +var terminalService = app.Services.GetRequiredService(); +var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions +{ + Title = "Shell", + Executable = "/bin/zsh", + Arguments = ["-i", "-l"], + WorkingDirectory = builder.AppHostDirectory, + EnvironmentVariables = + { + ["MY_SETTING"] = "value" + }, + Columns = 120, + Rows = 32, + Placement = TerminalPlacement.Dock +}); +terminal.Start(); +terminal.Show(); +``` + +Here, `app` is the built `DistributedApplication`. Environment entries add to or +override the AppHost's inherited environment. Requested dimensions default to 120 +columns and 32 rows. The current HMP server overrides those initial dimensions to +80 columns and 24 rows; viewer-driven resizing still applies after attachment. +Placement defaults to the dock; +use `Dialog` for terminal interaction inputs or `None` for automation-only terminals. + +The creator owns the terminal. A dock terminal can outlive the command that +created it: closing its tab or shutting down the AppHost disposes it. For a +dialog-scoped terminal, use `await using` around creation and the interaction; +closing the interaction alone does not dispose the terminal. + ## Process topology ```text @@ -166,7 +210,7 @@ it does not lock the terminal, its creator's automation, or other viewers. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.168.0-alpha.1573.1.2917e83`. HWT1 is experimental state transfer +exactly `0.168.0-alpha.1585.1.1de7974`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index d0faacfaef7..46ebc531ff2 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -9,7 +9,7 @@ // InputType.Terminal is an experimental spike. PromptInputsAsync is also experimental. #pragma warning disable ASPIREINTERACTION001 -// AppHost-owned terminals - TerminalService, AspireTerminal, TerminalCommand - are experimental. +// AppHost-owned terminals - TerminalService, AspireTerminal, TerminalLaunchOptions - are experimental. #pragma warning disable ASPIRETERMINAL002 namespace Terminals.AppHost; @@ -53,15 +53,12 @@ public static IResourceBuilder WithAppHostShellCommand(this IResourceBuild var interactionService = commandContext.Services.GetRequiredService(); var terminalService = commandContext.Services.GetRequiredService(); - var command = OperatingSystem.IsWindows() - ? new TerminalCommand("cmd.exe") - : new TerminalCommand("/bin/bash") { Arguments = ["-i", "-l"] }; - // The caller owns the terminal: it starts it, and disposes it here rather than the dialog doing so. await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = "Shell", - Command = command, + Executable = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/bash", + Arguments = OperatingSystem.IsWindows() ? [] : ["-i", "-l"], Placement = TerminalPlacement.Dialog }); @@ -147,10 +144,8 @@ private static async Task ExecIntoContainerAsync( await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = title, - Command = new TerminalCommand("docker") - { - Arguments = ["exec", "-it", containerName, .. command] - }, + Executable = "docker", + Arguments = ["exec", "-it", containerName, .. command], Placement = TerminalPlacement.Dialog }); @@ -200,10 +195,8 @@ public static IResourceBuilder WithDockShellCommand(this IRes var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = container.Resource.Name, - Command = new TerminalCommand("docker") - { - Arguments = ["exec", "-it", containerName, "/bin/sh"] - } + Executable = "docker", + Arguments = ["exec", "-it", containerName, "/bin/sh"] }); // Reveals the dock in every connected browser and switches it to this tab. @@ -247,10 +240,8 @@ public static IResourceBuilder WithPowerShellDockCommand(this var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = "PowerShell", - Command = new TerminalCommand("pwsh.exe") - { - Arguments = ["-NoLogo"] - } + Executable = "pwsh.exe", + Arguments = ["-NoLogo"] }); terminal.Start(); @@ -318,12 +309,7 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde // The command owns the terminal for its whole life: it starts it, drives the game through the // handle, and disposes it once the answer has been shown. - await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions - { - Title = "Number guess", - Command = BuildNumberGuessCommand(limit), - Placement = TerminalPlacement.Dialog - }); + await using var terminal = terminalService.CreateTerminal(BuildNumberGuessLaunchOptions(limit)); // Start before the dialog rather than letting the first attach do it, so `dotnet run --file` is // already compiling the script while the dialog is being raised. @@ -547,7 +533,7 @@ private static async Task ReadReplyAsync(AspireTerminal termin } /// - /// Builds the command that runs the numberguess.cs file-based app. + /// Builds the launch options for the numberguess.cs file-based app. /// /// /// The script is copied next to the AppHost binary (see the Scripts\ item group in the project file) so it @@ -555,13 +541,16 @@ private static async Task ReadReplyAsync(AspireTerminal termin /// dotnet so the game runs on the same SDK as the AppHost when one is pinned; file-based apps need .NET 10 /// or later, which whatever is first on PATH may not be. /// - private static TerminalCommand BuildNumberGuessCommand(int limit) + private static TerminalLaunchOptions BuildNumberGuessLaunchOptions(int limit) { var scriptPath = Path.Combine(AppContext.BaseDirectory, "Scripts", "numberguess.cs"); var dotnet = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } hostPath ? hostPath : "dotnet"; - return new TerminalCommand(dotnet) + return new TerminalLaunchOptions { + Title = "Number guess", + Placement = TerminalPlacement.Dialog, + Executable = dotnet, Arguments = ["run", "--file", scriptPath, "--", limit.ToString(CultureInfo.InvariantCulture)] }; } diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 85f5a767ceb..925395bdea5 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.168.0-alpha.1573.1.2917e83" + "@hex1b/web-terminal": "0.168.0-alpha.1585.1.1de7974" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.168.0-alpha.1573.1.2917e83", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.168.0-alpha.1573.1.2917e83.tgz", - "integrity": "sha512-Cpq50P0V7UAhkeCI0AICETum4dDw/EFm7ZduVElBIRzA9eXVuK/9mkpt/xvoNN+3h02MNokxgrY7PIm+Z1E9zg==", + "version": "0.168.0-alpha.1585.1.1de7974", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.168.0-alpha.1585.1.1de7974.tgz", + "integrity": "sha512-9mBe3106v9d3a+ewrSNjKI+z9dhQ99vYJSh2PNovAgp75BTzRUj9o96f1SlsG4go/xAK41uAVhqe6qKKRqgaGg==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index e67804b166d..f23d5c265e6 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.168.0-alpha.1573.1.2917e83" + "@hex1b/web-terminal": "0.168.0-alpha.1585.1.1de7974" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index 8d993caa898..7dd2460fd1e 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -18,8 +18,8 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.168.0-alpha.1573.1.2917e83**, -paired with the Hex1b NuGet package **0.168.0-alpha.1573.1.2917e83**. The client and server use the evolving +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.168.0-alpha.1585.1.1de7974**, +paired with the Hex1b NuGet package **0.168.0-alpha.1585.1.1de7974**. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md index 11eabc7f467..7f109df39a1 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/README.md @@ -176,6 +176,9 @@ workers, fonts, and the intended WebSocket endpoint. | Option | Meaning | | --- | --- | | `workerUrl` | Optional module-worker entry; useful when worker assets are deployed separately. | +| `linkDetectionWorkerUrl` | Optional detection module-worker entry (`string \| URL`), parallel to `workerUrl`. | +| `links` | Opt-in per-view text detection and OSC 8 interaction policy; `false` disables all local links. | +| `onLinkDetectionError` | Feature-local detection diagnostics; also reported through `onStatus`. | | `scale` | GPU backing scale `0.5`–`3`, or `"auto"` (default, bounded device pixel ratio). | | `renderer` | `"auto"` (prefer WebGPU), `"webgpu"`, or `"webgl2"`; selected once per mount. | | `font` | One family and optional downloadable font faces; see below. | @@ -500,7 +503,241 @@ Shift and Alt/Option continue to reserve selection gestures. Only absolute `http:`, `https:`, and `mailto:` destinations are activated (`mailto:` handling depends on the browser). New tabs use `noopener,noreferrer`. Script, data, file, relative, and custom-scheme URLs are not activated. -Plain URL text is not automatically detected; the workload must emit OSC 8. +This is the default when `links` is omitted: text detection is off and legacy +allowlisted OSC 8 navigation is preserved. + +### Opt-in text links and host actions + +Detection is browser-local and per view. It does not create server hyperlinks, +emit OSC/SGR, change copied text, or modify HWT1. Detected text **never opens a +browser, application, or file automatically**. Register actions at mount time, +even if detection starts disabled; `setLinks` does not register actions. + +This example creates a plain-text preview, not a navigation or file-access UI: + +```ts +import { WebTerminal, linkAction, type TerminalLinkOptions } from "@hex1b/web-terminal"; + +const container = document.createElement("div"); +container.style.cssText = "width:800px;height:480px"; +const preview = document.createElement("pre"); +document.body.append(container, preview); + +const links: TerminalLinkOptions = { + osc8: { action: "previewUri" }, // Optional: replace legacy navigation as well. + detection: { + activation: "modifierClick", + decoration: "always", + underlineStyle: "solid", + rules: [ + { id: "web", builtin: "url", action: "previewUri" }, + { id: "files", builtin: "absolutePath", action: "remoteFile" }, + { id: "home", builtin: "homePath", action: "remoteFile" }, + { id: "uris", builtin: "uri", action: "previewUri" }, + { + id: "issues", pattern: /\bPROJ-(?\d+)\b/gu, + kind: "custom", text: "logicalLine", action: "issue", + resolve(match) { + const number = match.groups.number; + return number ? { target: number, data: { label: match.text } } : null; + } + } + ] + } +}; + +const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", + links, + actions: { + previewUri: linkAction((_context, activation, _input) => { + preview.textContent = `URI preview: ${activation.target}`; + }), + remoteFile: linkAction((context, activation) => { + preview.textContent = `Remote path: ${activation.target}\n` + + `Remote cwd: ${context.terminal.workingDirectory.path ?? "unknown"}`; + }), + issue: linkAction((_context, activation) => { + preview.textContent = `Issue ${activation.target}: ${activation.text}`; + }) + }, + onLinkDetectionError(error) { + console.warn(error.code, error.ruleId, error.revision, error.message); + }, + onStatus(message, level) { + console.log(level, message); + } +}); + +// Replace the entire configuration, disabling only the "home" rule. +if (links.detection) { + terminal.setLinks({ + ...links, + detection: { + ...links.detection, + rules: links.detection.rules.map(rule => + rule.id === "home" ? { ...rule, enabled: false } : rule) + } + }); +} +terminal.setLinks({ detection: false }); // Reset to legacy OSC 8, no detection. +terminal.setLinks(false); // Disable every local link interaction. +terminal.setLinks(links); // Re-enable the original configuration. +``` + +`setLinks(options)` replaces, rather than merges, the complete configuration. +Omitted fields reset to defaults. Validation occurs before replacing active +state; malformed rules, duplicate IDs, unsupported regex flags, and missing +named actions throw. `enabled: false` disables an individual rule. +`osc8: false` disables OSC 8 interactions while allowing configured detection; +`osc8: { action }` delegates OSC 8 activation to a consumer action. An omitted +`osc8` keeps legacy allowlisted navigation, even when detection is configured. + +Actions use the existing `actions` registry, not an `onLinkClick` callback. +`linkAction((context, activation, input) => unknown)` returns an +`InputActionHandler` that validates and types its activation argument. +`context` is `TerminalInputContext`; `input` is a readonly `TerminalInput` or +`undefined`. Async action completion uses the existing dispatcher. A rule or +OSC 8 action can also be an inline handler. Built-in terminal action names +cannot serve as link actions; use a registered custom action or callback. + +`TerminalLinkActivation` contains `source` (`"detected"` or `"osc8"`), `ruleId` +(`null` for OSC 8), `kind` (`"uri"`, `"path"`, or `"custom"`), matched `text`, +resolved `target`, visible end-exclusive cell `ranges`, presented `revision`, +and optional consumer `data`. Core activation fields and ranges are frozen; +consumer-owned `data` is not deep-frozen. Targets, OSC 8 destinations, and data remain untrusted: authorize any +navigation or remote operation in your application and display text with +`textContent`, not `innerHTML`. A custom OSC 8 action can receive schemes outside +the legacy allowlist; this is not permission to open them. + +#### Rules, text modes, and resolution + +Rules compete in array order; the first accepted match owns its cells. Presets +recognize HTTP/HTTPS URLs (`url`), general including opaque URIs (`uri`), POSIX +and Windows drive-absolute paths (`absolutePath`), and literal `~/...` +(`homePath`). Paths are lexical, whitespace-delimited **remote terminal paths**, +not browser-local files. There is no `~` expansion, percent decoding, existence +check, home-directory inference, or resolution against the page URL. Use custom +rules for quoted/spaced paths, UNC paths, or `file:line:column` suffix grammars. + +Custom rules supply `pattern: RegExp`, `kind`, and an action. `text` applies to +built-ins and custom rules: + +| `text` | Match input | +| --- | --- | +| `"logicalLine"` (default) | Displayed rows joined only across authoritative soft wraps. | +| `"physicalRow"` | Each displayed physical row independently. | +| `"viewport"` | Displayed text with soft wraps joined and hard breaks retained as `\n`. | + +An optional synchronous `resolve(match)` returns `null` to reject a match, or +`{ target, action?, data? }` to transform its destination, override the action, +and attach local data. Without a resolver, `target` is the recognized text. +`match` exposes matched `text`, UTF-16 `index`, `captures` (excluding the full +match), named `groups`, and `chunk: { text, mode, start, end }`. Boundary values +are `"complete"`, `"clipped"`, or `"unknown"`. The full regex match determines +the highlight; a resolver cannot replace its range. Regexes scan all matches, +with or without `g`, without changing the caller's `lastIndex`; sticky `y` is +unsupported. Empty matches have no clickable cells. + +#### Visible-only limits and styling + +Only the currently displayed text is scanned, including history **while it is +displayed**. There is no off-screen fetch, unseen-history scan, independent +reflow, or reconstruction of missing text. HWT1 already carries the soft-wrap +flag but does not carry wide-wrap padding markers. Such blanks must remain +spaces, so some Unicode targets wrapped at a wide glyph will not match. +Candidates depending on uncertain/clipped edges or unavailable continuations +are not active. Complete visible delimiters are important; false negatives +are intentional rather than activating truncated destinations. + +Cell mapping respects wide/combining characters and rejects partial-grapheme +matches. Hidden cells and graphics placeholders are barriers, not text to +silently remove. Authoritative OSC 8 spans reserve cells **even when disabled +or blocked**; an overlapping detected candidate is rejected in full. + +Underline visibility and appearance are independent: + +| Option | Values | Default | +|---|---|---| +| `decoration` | `"always"`, `"hover"`, `"none"` | `"always"` | +| `underlineStyle` | `"solid"`, `"dashed"` | `"solid"` | + +For example, use `decoration: "hover", underlineStyle: "dashed"` for dashed +underlines only while the pointer is over a detected link. Hover decorates +the whole match, including its visible wrapped spans; no modifier key is needed +to reveal it. `"none"` retains hit-testing/activation without inferred underlines. +Change either option at runtime by passing the updated configuration to `setLinks`. +Decorations are local. Existing SGR underline style and color are preserved; +disabling links cannot erase application-authored underlines. + +`activation` defaults to `"modifierClick"` (Ctrl/Cmd+click); `"click"` is an +explicit alternative that takes ownership only on a link. Input policy retains +first refusal. Activation occurs on release, is canceled by dragging or stale +content/configuration, and does not forward the consumed gesture to the +workload. Read-only views may still invoke local link actions. + +#### Detection isolation and deployment + +Regex scanning uses a separate, lazily created detection module worker so a +pathological regex cannot stall the rendering worker. Work is bounded by +internal text, rule, match, and time budgets; oversized work is diagnosed, not +silently presented as complete. These are implementation limits, not public +scheduling options. Disabling detection or disposing the view releases its +detection worker. + +Current internal limits (not benchmark-derived performance guarantees): + +| Budget | Limit | +| --- | --- | +| Configured rules | 32 | +| Regex source length | 8,192 UTF-16 code units | +| One text chunk | 65,536 UTF-16 code units | +| Total scan text | 262,144 UTF-16 code units | +| Mapped cells | 262,144 | +| Matches | 2,048 per rule and 2,048 visible resolved matches | +| Estimated result payload | 262,144 budget units (bounds capture amplification) | +| Cache entries / estimated retained payload | 2,048 entries / 1,048,576 budget units, including keys and all matched/captured/group text | +| Per-rule timeout | 250 ms, including worker startup | + +Payload budgets count UTF-16 text units plus estimated overhead (16 units per +match and 8 per capture/group), including empty captures. They bound estimated +payload size, not exact JavaScript heap bytes. + +The cell budget does not override the chunk budget: oversized logical-line or +viewport chunks are rejected, not split into apparently complete targets. + +The first displayed row's start is treated as unknown, as are full right +edges. A visible delimiter is required when a candidate would otherwise +depend on an uncertain edge. Budget diagnostics remain feature-local; a regex +timeout disables its rule until `setLinks` reconfigures detection. + +**Resolvers are trusted synchronous main-thread JavaScript and cannot be +preempted by the regex watchdog.** Keep them fast, side-effect-free, and +nonblocking; they can run repeatedly during detection. Promises are invalid. +Timeouts and resolver failures disable the affected rule until reconfiguration. +`onLinkDetectionError` receives +`{ code: "timeout" | "limit" | "resolver" | "worker", ruleId: string | null, revision, message }`; +errors also use `onStatus`. Detection failure leaves the terminal running. +Activation failures instead use existing `onInputError`/status handling and +never fall back to navigation. + +Deploy the complete package tree, including the detection worker and its +relative dependencies. If your bundler requires explicit worker entries: + +```ts +const terminal = await WebTerminal.mount(container, { + url: "/ws/terminal", + workerUrl: "/web-terminal/terminal-worker.js", + linkDetectionWorkerUrl: "/web-terminal/link-detection-worker.js", + links: { detection: false } +}); +``` + +`linkDetectionWorkerUrl` accepts `string | URL`, resolves relative strings +against the page like `workerUrl`, and is a mount-time override. Without it the +entry resolves relative to the package module. Worker origin/CSP restrictions +still apply. Rules and matched text are not sent to an external service. +See the [opt-in demo](../../samples/WebTerminalDemo/README.md#try-local-link-previews). ## Selection UI hooks diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts index 29477ba91ae..fbc53840e57 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts @@ -2,5 +2,6 @@ export { WebTerminal } from "./web-terminal.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; export { MIN_FONT_SIZE, MAX_FONT_SIZE } from "./terminal-sizing.js"; export { parseCommandMarkParameters, getCmdlineUrl } from "./command-mark.js"; +export { linkAction } from "./link-options.js"; export type * from "./types.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map index 7d5c1311e2c..f7076cc5fda 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACpE,OAAO,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC9E,mBAAmB,YAAY,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACpE,OAAO,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,mBAAmB,YAAY,CAAC"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js index 341c167aa50..e01d1630aa9 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js @@ -2,4 +2,5 @@ export { WebTerminal } from "./web-terminal.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; export { MIN_FONT_SIZE, MAX_FONT_SIZE } from "./terminal-sizing.js"; export { parseCommandMarkParameters, getCmdlineUrl } from "./command-mark.js"; +export { linkAction } from "./link-options.js"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map index 37177b81837..3e980691b6c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACpE,OAAO,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC","sourcesContent":["export { WebTerminal } from \"./web-terminal.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\nexport { MIN_FONT_SIZE, MAX_FONT_SIZE } from \"./terminal-sizing.js\";\nexport { parseCommandMarkParameters, getCmdlineUrl } from \"./command-mark.js\";\nexport type * from \"./types.js\";\n"]} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACpE,OAAO,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC","sourcesContent":["export { WebTerminal } from \"./web-terminal.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\nexport { MIN_FONT_SIZE, MAX_FONT_SIZE } from \"./terminal-sizing.js\";\nexport { parseCommandMarkParameters, getCmdlineUrl } from \"./command-mark.js\";\nexport { linkAction } from \"./link-options.js\";\nexport type * from \"./types.js\";\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.d.ts new file mode 100644 index 00000000000..287c6dd6a6d --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.d.ts @@ -0,0 +1,3 @@ +import type { LinkScanRequest, LinkScanResponse } from "./link-worker-protocol.js"; +export declare function scanLinks(request: LinkScanRequest): LinkScanResponse; +//# sourceMappingURL=link-detection-worker.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.d.ts.map new file mode 100644 index 00000000000..999f56b2576 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"link-detection-worker.d.ts","sourceRoot":"","sources":["../src/link-detection-worker.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAiB,MAAM,2BAA2B,CAAC;AAkBlG,wBAAgB,SAAS,CAAC,OAAO,EAAE,eAAe,GAAG,gBAAgB,CA4DpE"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.js new file mode 100644 index 00000000000..b2198ad28bd --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.js @@ -0,0 +1,100 @@ +import { LINK_LIMITS } from "./link-options.js"; +import { linkMatchTextSize } from "./link-worker-protocol.js"; +function trimProse(text) { + let end = text.length; + const pairs = { ")": "(", "]": "[", "}": "{" }; + while (end > 0) { + const char = text[end - 1]; + if (".,;:!?".includes(char)) { + end--; + continue; + } + if (pairs[char]) { + const current = text.slice(0, end); + if (current.split(char).length > current.split(pairs[char]).length) { + end--; + continue; + } + } + break; + } + return text.slice(0, end); +} +export function scanLinks(request) { + try { + const builtin = "builtin" in request.rule ? request.rule.builtin : undefined; + let regex; + if ("source" in request.rule) { + const flags = request.rule.flags.replaceAll("g", ""); + regex = new RegExp(request.rule.source, `${flags}g`); + } + else { + const sources = { + url: String.raw `https?:\/\/[^\s\0<>"'\x60]+`, + uri: String.raw `[A-Za-z][A-Za-z0-9+.-]*:[^\s\0<>"'\x60]+`, + absolutePath: String.raw `(?:[A-Za-z]:[\\/]|/)[^\s\0<>"'\x60]*`, + homePath: String.raw `~/[^\s\0<>"'\x60]+`, + }; + regex = new RegExp(sources[request.rule.builtin], "giu"); + } + let count = 0, attempts = 0, total = 0, resultText = 0; + const results = []; + for (const chunk of request.chunks) { + total += chunk.text.length; + if (chunk.text.length > LINK_LIMITS.chunk || total > LINK_LIMITS.totalText) { + return { id: request.id, error: "limit", message: "Link scan text limit exceeded" }; + } + regex.lastIndex = 0; + const matches = []; + let match; + while ((match = regex.exec(chunk.text)) !== null) { + if (++attempts > LINK_LIMITS.totalText || count >= LINK_LIMITS.matches) { + return { id: request.id, error: "limit", message: "Link match limit exceeded" }; + } + if (!match[0].length) { + const point = chunk.text.codePointAt(regex.lastIndex); + regex.lastIndex += (regex.unicode || regex.unicodeSets) && point !== undefined && point > 0xffff ? 2 : 1; + continue; + } + let text = match[0]; + if (builtin) { + const before = chunk.text[match.index - 1]; + if (before !== undefined && !/[\s([{"'<>=]/u.test(before)) + continue; + if (builtin === "uri" && /^[a-z]:[\\/]/iu.test(text)) + continue; + text = trimProse(text); + if (!text || (builtin === "absolutePath" && (text === "/" || /^[a-z]:[\\/]$/iu.test(text)))) + continue; + if (builtin === "url") { + try { + if (!new URL(text).hostname) + continue; + } + catch { + continue; + } + } + } + const candidate = { index: match.index, text, captures: match.slice(1), groups: { ...match.groups } }; + resultText += linkMatchTextSize(candidate); + if (resultText > LINK_LIMITS.resultText) { + return { id: request.id, error: "limit", message: "Link result text limit exceeded" }; + } + matches.push(candidate); + count++; + } + results.push({ key: chunk.key, matches }); + } + return { id: request.id, results }; + } + catch (error) { + return { id: request.id, error: "worker", message: error instanceof Error ? error.message : String(error) }; + } +} +if (typeof self !== "undefined" && typeof self.postMessage === "function") { + self.addEventListener("message", (event) => { + self.postMessage(scanLinks(event.data)); + }); +} +//# sourceMappingURL=link-detection-worker.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.js.map new file mode 100644 index 00000000000..7b0447355b6 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection-worker.js.map @@ -0,0 +1 @@ +{"version":3,"file":"link-detection-worker.js","sourceRoot":"","sources":["../src/link-detection-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { LINK_LIMITS } from \"./link-options.js\";\nimport type { LinkScanRequest, LinkScanResponse, LinkScanMatch } from \"./link-worker-protocol.js\";\nimport { linkMatchTextSize } from \"./link-worker-protocol.js\";\n\nfunction trimProse(text: string): string {\n let end = text.length;\n const pairs: Record = { \")\": \"(\", \"]\": \"[\", \"}\": \"{\" };\n while (end > 0) {\n const char = text[end - 1];\n if (\".,;:!?\".includes(char)) { end--; continue; }\n if (pairs[char]) {\n const current = text.slice(0, end);\n if (current.split(char).length > current.split(pairs[char]).length) { end--; continue; }\n }\n break;\n }\n return text.slice(0, end);\n}\n\nexport function scanLinks(request: LinkScanRequest): LinkScanResponse {\n try {\n const builtin = \"builtin\" in request.rule ? request.rule.builtin : undefined;\n let regex: RegExp;\n if (\"source\" in request.rule) {\n const flags = request.rule.flags.replaceAll(\"g\", \"\");\n regex = new RegExp(request.rule.source, `${flags}g`);\n } else {\n const sources = {\n url: String.raw`https?:\\/\\/[^\\s\\0<>\"'\\x60]+`,\n uri: String.raw`[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\0<>\"'\\x60]+`,\n absolutePath: String.raw`(?:[A-Za-z]:[\\\\/]|/)[^\\s\\0<>\"'\\x60]*`,\n homePath: String.raw`~/[^\\s\\0<>\"'\\x60]+`,\n };\n regex = new RegExp(sources[request.rule.builtin], \"giu\");\n }\n let count = 0, attempts = 0, total = 0, resultText = 0;\n const results: NonNullable = [];\n for (const chunk of request.chunks) {\n total += chunk.text.length;\n if (chunk.text.length > LINK_LIMITS.chunk || total > LINK_LIMITS.totalText) {\n return { id: request.id, error: \"limit\", message: \"Link scan text limit exceeded\" };\n }\n regex.lastIndex = 0;\n const matches: LinkScanMatch[] = [];\n let match: RegExpExecArray | null;\n while ((match = regex.exec(chunk.text)) !== null) {\n if (++attempts > LINK_LIMITS.totalText || count >= LINK_LIMITS.matches) {\n return { id: request.id, error: \"limit\", message: \"Link match limit exceeded\" };\n }\n if (!match[0].length) {\n const point = chunk.text.codePointAt(regex.lastIndex);\n regex.lastIndex += (regex.unicode || regex.unicodeSets) && point !== undefined && point > 0xffff ? 2 : 1;\n continue;\n }\n let text = match[0];\n if (builtin) {\n const before = chunk.text[match.index - 1];\n if (before !== undefined && !/[\\s([{\"'<>=]/u.test(before)) continue;\n if (builtin === \"uri\" && /^[a-z]:[\\\\/]/iu.test(text)) continue;\n text = trimProse(text);\n if (!text || (builtin === \"absolutePath\" && (text === \"/\" || /^[a-z]:[\\\\/]$/iu.test(text)))) continue;\n if (builtin === \"url\") {\n try { if (!new URL(text).hostname) continue; } catch { continue; }\n }\n }\n const candidate = { index: match.index, text, captures: match.slice(1), groups: { ...match.groups } };\n resultText += linkMatchTextSize(candidate);\n if (resultText > LINK_LIMITS.resultText) {\n return { id: request.id, error: \"limit\", message: \"Link result text limit exceeded\" };\n }\n matches.push(candidate);\n count++;\n }\n results.push({ key: chunk.key, matches });\n }\n return { id: request.id, results };\n } catch (error) {\n return { id: request.id, error: \"worker\", message: error instanceof Error ? error.message : String(error) };\n }\n}\n\nif (typeof self !== \"undefined\" && typeof self.postMessage === \"function\") {\n self.addEventListener(\"message\", (event: MessageEvent) => {\n self.postMessage(scanLinks(event.data));\n });\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.d.ts new file mode 100644 index 00000000000..297d5b374a9 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.d.ts @@ -0,0 +1,29 @@ +import type { HyperlinkRange, TerminalCell } from "./wire-types.js"; +import type { TerminalLinkAction, TerminalLinkActivation, TerminalLinkDetectionError, TerminalLinkOptions } from "./link-types.js"; +export interface LinkDetectionSnapshot { + revision: number; + columns: number; + rows: number; + cells: readonly (TerminalCell | undefined)[]; + hyperlinks: readonly HyperlinkRange[]; +} +export interface DetectedLink { + id: string; + action: TerminalLinkAction; + activation: TerminalLinkActivation; +} +export declare class LinkDetection { + #private; + constructor(options: { + workerUrl?: string | URL; + actions: ReadonlySet; + onChange: (revision: number, links: readonly DetectedLink[]) => void; + onError: (error: TerminalLinkDetectionError) => void; + }); + configure(detection: TerminalLinkOptions["detection"]): void; + update(snapshot: LinkDetectionSnapshot): void; + advance(revision: number): void; + clear(): void; + dispose(): void; +} +//# sourceMappingURL=link-detection.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.d.ts.map new file mode 100644 index 00000000000..82a88c0d5db --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"link-detection.d.ts","sourceRoot":"","sources":["../src/link-detection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpE,OAAO,KAAK,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,0BAA0B,EAClF,mBAAmB,EAA0C,MAAM,iBAAiB,CAAC;AAOvF,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAChD,KAAK,EAAE,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,CAAC;IAC7C,UAAU,EAAE,SAAS,cAAc,EAAE,CAAC;CACvC;AACD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,sBAAsB,CAAC;CACpC;AA6BD,qBAAa,aAAa;;gBAqBZ,OAAO,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;QAAC,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;QAC5E,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,YAAY,EAAE,KAAK,IAAI,CAAC;QACrE,OAAO,EAAE,CAAC,KAAK,EAAE,0BAA0B,KAAK,IAAI,CAAA;KAAE;IAIxD,SAAS,CAAC,SAAS,EAAE,mBAAmB,CAAC,WAAW,CAAC,GAAG,IAAI;IAQ5D,MAAM,CAAC,QAAQ,EAAE,qBAAqB,GAAG,IAAI;IAsB7C,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAQ/B,KAAK,IAAI,IAAI;IAQb,OAAO,IAAI,IAAI;CA8JhB"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.js new file mode 100644 index 00000000000..e872983761d --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.js @@ -0,0 +1,290 @@ +import { LINK_LIMITS, normalizeLinks, validateLinkAction } from "./link-options.js"; +import { extractLinkText, mapLinkRange } from "./link-text.js"; +import { linkMatchTextSize } from "./link-worker-protocol.js"; +function validResponse(response, job) { + if (!Array.isArray(response.results) || response.results.length !== job.keys.size) + return false; + const keys = new Set(); + let count = 0, resultText = 0; + for (const result of response.results) { + if (!result || !job.keys.has(result.key) || keys.has(result.key) || !Array.isArray(result.matches)) + return false; + keys.add(result.key); + count += result.matches.length; + if (count > LINK_LIMITS.matches) + return false; + for (const match of result.matches) { + if (!match || !Number.isSafeInteger(match.index) || match.index < 0 || + typeof match.text !== "string" || !match.text.length || !Array.isArray(match.captures) || + !match.captures.every(capture => capture === undefined || typeof capture === "string") || + !match.groups || typeof match.groups !== "object" || Array.isArray(match.groups) || + !Object.values(match.groups).every(group => group === undefined || typeof group === "string")) + return false; + resultText += linkMatchTextSize(match); + if (resultText > LINK_LIMITS.resultText) + return false; + } + } + return true; +} +export class LinkDetection { + #options; + #detection; + #worker = null; + #timer; + #job = null; + #work = null; + #links = []; + #disabled = new Set(); + #cache = new Map(); + #cacheSizes = new Map(); + #cacheSize = 0; + #identity = 0; + #jobId = 0; + #revision = 0; + #disposed = false; + #workerFailed = false; + #pumping = false; + constructor(options) { + this.#options = { ...options, actions: new Set(options.actions) }; + } + configure(detection) { + const normalized = normalizeLinks({ detection }, this.#options.actions); + if (this.#disposed) + return; + this.#detection = normalized === false ? false : normalized.detection; + this.#disabled.clear(); + this.#workerFailed = false; + this.clear(); + } + update(snapshot) { + if (this.#disposed || snapshot.revision < this.#revision) + return; + this.#revision = snapshot.revision; + this.#work = null; + this.#publish([]); + if (!this.#detection || this.#workerFailed) + return; + const work = { identity: ++this.#identity, revision: snapshot.revision, + snapshot, chunks: new Map() }; + try { + for (const rule of this.#detection.rules) { + if (!rule.enabled || this.#disabled.has(rule.id)) + continue; + const mode = rule.text ?? "logicalLine"; + if (!work.chunks.has(mode)) + work.chunks.set(mode, extractLinkText(snapshot, mode)); + } + } + catch (error) { + this.#error("limit", null, error); + return; + } + this.#work = work; + this.#pump(); + } + advance(revision) { + if (this.#disposed || revision < this.#revision) + return; + this.#revision = revision; + if (this.#work) + this.#work.revision = revision; + this.#publish(this.#links.map(link => Object.freeze({ ...link, + activation: Object.freeze({ ...link.activation, revision }) }))); + } + clear() { + this.#terminate(); + this.#identity++; + this.#work = null; + this.#cache.clear(); + this.#cacheSizes.clear(); + this.#cacheSize = 0; + if (!this.#disposed) + this.#publish([]); + } + dispose() { + if (this.#disposed) + return; + this.clear(); + this.#disposed = true; + } + #publish(links) { + this.#links = Object.freeze([...links]); + this.#options.onChange(this.#revision, this.#links); + } + #error(code, ruleId, error) { + this.#options.onError(Object.freeze({ code, ruleId, revision: this.#revision, + message: error instanceof Error ? error.message : String(error) })); + } + #terminate() { + clearTimeout(this.#timer); + this.#timer = undefined; + if (this.#worker) { + this.#worker.terminate(); + this.#worker = null; + } + this.#job = null; + } + #failWorker(error) { + const ruleId = this.#job?.rule.id ?? null; + this.#terminate(); + this.#workerFailed = true; + this.#publish([]); + this.#error("worker", ruleId, error); + } + #pump() { + if (this.#pumping || this.#disposed || this.#job || !this.#work || !this.#detection || this.#workerFailed) + return; + this.#pumping = true; + try { + const work = this.#work; + for (const rule of this.#detection.rules) { + if (!rule.enabled || this.#disabled.has(rule.id)) + continue; + const chunks = work.chunks.get(rule.text ?? "logicalLine") ?? []; + const missing = chunks.filter(chunk => !this.#cache.has(this.#cacheKey(rule, chunk))); + if (!missing.length) + continue; + try { + if (!this.#worker) { + const worker = new Worker(this.#options.workerUrl ?? new URL("./link-detection-worker.js", import.meta.url), { type: "module", name: "hex1b-link-detection" }); + this.#worker = worker; + worker.addEventListener("message", event => { + if (this.#worker === worker) + this.#receive(event.data); + }); + worker.addEventListener("error", event => { + if (this.#worker !== worker) + return; + event.preventDefault(); + this.#failWorker(event.message); + }); + worker.addEventListener("messageerror", () => { + if (this.#worker === worker) + this.#failWorker("Invalid detection worker message"); + }); + } + const id = ++this.#jobId; + this.#job = { id, work, rule, keys: new Map(missing.map(chunk => [chunk.key, this.#cacheKey(rule, chunk)])) }; + this.#timer = setTimeout(() => { + if (this.#job?.id !== id) + return; + this.#terminate(); + this.#disabled.add(rule.id); + this.#error("timeout", rule.id, `Link rule exceeded ${LINK_LIMITS.timeoutMs} ms`); + this.#pump(); + }, LINK_LIMITS.timeoutMs); + const request = { id, + rule: rule.builtin ? { builtin: rule.builtin } : { source: rule.pattern.source, flags: rule.pattern.flags }, + chunks: missing.map(({ chunk, key }) => ({ key, text: chunk.text })) }; + this.#worker.postMessage(request); + } + catch (error) { + this.#failWorker(error); + } + return; + } + this.#resolve(work); + } + finally { + this.#pumping = false; + } + } + #cacheKey(rule, chunk) { + return JSON.stringify([rule.id, chunk.key]); + } + #receive(response) { + const job = this.#job; + if (!job || response?.id !== job.id || this.#disposed) + return; + clearTimeout(this.#timer); + this.#timer = undefined; + this.#job = null; + if (response.error) { + this.#disabled.add(job.rule.id); + this.#error(response.error, job.rule.id, response.message ?? "Link scan failed"); + } + else if (!validResponse(response, job)) { + this.#failWorker("Malformed detection response"); + return; + } + else { + for (const result of response.results) { + const key = job.keys.get(result.key); + const size = key.length + result.matches.reduce((total, match) => total + linkMatchTextSize(match), 0); + this.#cacheSize += size - (this.#cacheSizes.get(key) ?? 0); + this.#cacheSizes.set(key, size); + this.#cache.set(key, result.matches); + } + } + if (this.#work !== job.work) + this.#trimCache(); + this.#pump(); + } + #resolve(work) { + if (!this.#detection || this.#work !== work) + return; + const occupied = [...work.snapshot.hyperlinks]; + const links = []; + const overlaps = (ranges) => ranges.some(range => occupied.some(other => range.row === other.row && range.startColumn < other.endColumn && range.endColumn > other.startColumn)); + for (const rule of this.#detection.rules) { + if (!rule.enabled || this.#disabled.has(rule.id)) + continue; + const ruleLinks = [], ruleRanges = []; + try { + for (const mapped of work.chunks.get(rule.text ?? "logicalLine") ?? []) { + for (const candidate of this.#cache.get(this.#cacheKey(rule, mapped)) ?? []) { + const ranges = mapLinkRange(mapped, candidate.index, candidate.text.length); + if (!ranges || overlaps(ranges)) + continue; + const match = Object.freeze({ ...candidate, captures: Object.freeze([...candidate.captures]), + groups: Object.freeze({ ...candidate.groups }), chunk: mapped.chunk }); + const resolution = rule.resolve ? rule.resolve(match) : { target: candidate.text }; + if (this.#work !== work || this.#disposed) + return; + if (resolution === null) + continue; + if (typeof resolution !== "object" || resolution === undefined || + "then" in resolution || typeof resolution.target !== "string") + throw new TypeError("Resolver must return a synchronous link resolution or null"); + const action = resolution.action === undefined ? rule.action : resolution.action; + validateLinkAction(action, this.#options.actions); + if (links.length + ruleLinks.length >= LINK_LIMITS.matches) + throw new RangeError("Resolved link limit exceeded"); + const activation = Object.freeze({ + source: "detected", ruleId: rule.id, + kind: rule.builtin ? (rule.builtin === "url" || rule.builtin === "uri" ? "uri" : "path") : rule.kind, + text: candidate.text, target: resolution.target, + ranges: Object.freeze(ranges.map(range => Object.freeze(range))), + revision: work.revision, data: resolution.data, + }); + ruleLinks.push(Object.freeze({ id: JSON.stringify([work.identity, rule.id, + ranges[0].row, ranges[0].startColumn, candidate.index]), + action, activation })); + ruleRanges.push(...ranges); + } + } + links.push(...ruleLinks); + occupied.push(...ruleRanges); + } + catch (error) { + this.#disabled.add(rule.id); + this.#error(error instanceof RangeError ? "limit" : "resolver", rule.id, error); + if (this.#work !== work || this.#disposed) + return; + } + } + // Evict only after resolution so a viewport larger than the cache cannot cause endless rescans. + this.#trimCache(); + this.#publish(links); + } + #trimCache() { + while (this.#cache.size > LINK_LIMITS.cacheEntries || this.#cacheSize > LINK_LIMITS.cacheText) { + const key = this.#cache.keys().next().value; + this.#cache.delete(key); + this.#cacheSize -= this.#cacheSizes.get(key); + this.#cacheSizes.delete(key); + } + } +} +//# sourceMappingURL=link-detection.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.js.map new file mode 100644 index 00000000000..ad22059e834 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-detection.js.map @@ -0,0 +1 @@ +{"version":3,"file":"link-detection.js","sourceRoot":"","sources":["../src/link-detection.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HyperlinkRange, TerminalCell } from \"./wire-types.js\";\nimport type { SelectionRange } from \"./types.js\";\nimport type { TerminalLinkAction, TerminalLinkActivation, TerminalLinkDetectionError,\n TerminalLinkOptions, TerminalLinkRule, TerminalLinkTextMode } from \"./link-types.js\";\nimport { LINK_LIMITS, normalizeLinks, validateLinkAction } from \"./link-options.js\";\nimport { extractLinkText, mapLinkRange } from \"./link-text.js\";\nimport type { MappedLinkChunk } from \"./link-text.js\";\nimport type { LinkScanMatch, LinkScanRequest, LinkScanResponse } from \"./link-worker-protocol.js\";\nimport { linkMatchTextSize } from \"./link-worker-protocol.js\";\n\nexport interface LinkDetectionSnapshot {\n revision: number; columns: number; rows: number;\n cells: readonly (TerminalCell | undefined)[];\n hyperlinks: readonly HyperlinkRange[];\n}\nexport interface DetectedLink {\n id: string;\n action: TerminalLinkAction;\n activation: TerminalLinkActivation;\n}\ninterface Work {\n identity: number; revision: number; snapshot: LinkDetectionSnapshot;\n chunks: Map;\n}\ninterface Job { id: number; work: Work; rule: TerminalLinkRule; keys: Map }\n\nfunction validResponse(response: LinkScanResponse, job: Job): boolean {\n if (!Array.isArray(response.results) || response.results.length !== job.keys.size) return false;\n const keys = new Set();\n let count = 0, resultText = 0;\n for (const result of response.results) {\n if (!result || !job.keys.has(result.key) || keys.has(result.key) || !Array.isArray(result.matches)) return false;\n keys.add(result.key);\n count += result.matches.length;\n if (count > LINK_LIMITS.matches) return false;\n for (const match of result.matches) {\n if (!match || !Number.isSafeInteger(match.index) || match.index < 0 ||\n typeof match.text !== \"string\" || !match.text.length || !Array.isArray(match.captures) ||\n !match.captures.every(capture => capture === undefined || typeof capture === \"string\") ||\n !match.groups || typeof match.groups !== \"object\" || Array.isArray(match.groups) ||\n !Object.values(match.groups).every(group => group === undefined || typeof group === \"string\")) return false;\n resultText += linkMatchTextSize(match);\n if (resultText > LINK_LIMITS.resultText) return false;\n }\n }\n return true;\n}\n\nexport class LinkDetection {\n #options: { workerUrl?: string | URL; actions: ReadonlySet;\n onChange: (revision: number, links: readonly DetectedLink[]) => void;\n onError: (error: TerminalLinkDetectionError) => void };\n #detection: TerminalLinkOptions[\"detection\"];\n #worker: Worker | null = null;\n #timer: ReturnType | undefined;\n #job: Job | null = null;\n #work: Work | null = null;\n #links: readonly DetectedLink[] = [];\n #disabled = new Set();\n #cache = new Map();\n #cacheSizes = new Map();\n #cacheSize = 0;\n #identity = 0;\n #jobId = 0;\n #revision = 0;\n #disposed = false;\n #workerFailed = false;\n #pumping = false;\n\n constructor(options: { workerUrl?: string | URL; actions: ReadonlySet;\n onChange: (revision: number, links: readonly DetectedLink[]) => void;\n onError: (error: TerminalLinkDetectionError) => void }) {\n this.#options = { ...options, actions: new Set(options.actions) };\n }\n\n configure(detection: TerminalLinkOptions[\"detection\"]): void {\n const normalized = normalizeLinks({ detection }, this.#options.actions);\n if (this.#disposed) return;\n this.#detection = normalized === false ? false : normalized.detection;\n this.#disabled.clear(); this.#workerFailed = false;\n this.clear();\n }\n\n update(snapshot: LinkDetectionSnapshot): void {\n if (this.#disposed || snapshot.revision < this.#revision) return;\n this.#revision = snapshot.revision;\n this.#work = null;\n this.#publish([]);\n if (!this.#detection || this.#workerFailed) return;\n const work: Work = { identity: ++this.#identity, revision: snapshot.revision,\n snapshot, chunks: new Map() };\n try {\n for (const rule of this.#detection.rules) {\n if (!rule.enabled || this.#disabled.has(rule.id)) continue;\n const mode = rule.text ?? \"logicalLine\";\n if (!work.chunks.has(mode)) work.chunks.set(mode, extractLinkText(snapshot, mode));\n }\n } catch (error) {\n this.#error(\"limit\", null, error);\n return;\n }\n this.#work = work;\n this.#pump();\n }\n\n advance(revision: number): void {\n if (this.#disposed || revision < this.#revision) return;\n this.#revision = revision;\n if (this.#work) this.#work.revision = revision;\n this.#publish(this.#links.map(link => Object.freeze({ ...link,\n activation: Object.freeze({ ...link.activation, revision }) })));\n }\n\n clear(): void {\n this.#terminate();\n this.#identity++;\n this.#work = null;\n this.#cache.clear(); this.#cacheSizes.clear(); this.#cacheSize = 0;\n if (!this.#disposed) this.#publish([]);\n }\n\n dispose(): void {\n if (this.#disposed) return;\n this.clear();\n this.#disposed = true;\n }\n\n #publish(links: readonly DetectedLink[]): void {\n this.#links = Object.freeze([...links]);\n this.#options.onChange(this.#revision, this.#links);\n }\n\n #error(code: TerminalLinkDetectionError[\"code\"], ruleId: string | null, error: unknown): void {\n this.#options.onError(Object.freeze({ code, ruleId, revision: this.#revision,\n message: error instanceof Error ? error.message : String(error) }));\n }\n\n #terminate(): void {\n clearTimeout(this.#timer); this.#timer = undefined;\n if (this.#worker) {\n this.#worker.terminate(); this.#worker = null;\n }\n this.#job = null;\n }\n\n #failWorker(error: unknown): void {\n const ruleId = this.#job?.rule.id ?? null;\n this.#terminate(); this.#workerFailed = true;\n this.#publish([]);\n this.#error(\"worker\", ruleId, error);\n }\n\n #pump(): void {\n if (this.#pumping || this.#disposed || this.#job || !this.#work || !this.#detection || this.#workerFailed) return;\n this.#pumping = true;\n try {\n const work = this.#work;\n for (const rule of this.#detection.rules) {\n if (!rule.enabled || this.#disabled.has(rule.id)) continue;\n const chunks = work.chunks.get(rule.text ?? \"logicalLine\") ?? [];\n const missing = chunks.filter(chunk => !this.#cache.has(this.#cacheKey(rule, chunk)));\n if (!missing.length) continue;\n try {\n if (!this.#worker) {\n const worker = new Worker(this.#options.workerUrl ?? new URL(\"./link-detection-worker.js\", import.meta.url),\n { type: \"module\", name: \"hex1b-link-detection\" });\n this.#worker = worker;\n worker.addEventListener(\"message\", event => {\n if (this.#worker === worker) this.#receive(event.data as LinkScanResponse);\n });\n worker.addEventListener(\"error\", event => {\n if (this.#worker !== worker) return;\n event.preventDefault(); this.#failWorker(event.message);\n });\n worker.addEventListener(\"messageerror\", () => {\n if (this.#worker === worker) this.#failWorker(\"Invalid detection worker message\");\n });\n }\n const id = ++this.#jobId;\n this.#job = { id, work, rule, keys: new Map(missing.map(chunk => [chunk.key, this.#cacheKey(rule, chunk)])) };\n this.#timer = setTimeout(() => {\n if (this.#job?.id !== id) return;\n this.#terminate(); this.#disabled.add(rule.id);\n this.#error(\"timeout\", rule.id, `Link rule exceeded ${LINK_LIMITS.timeoutMs} ms`);\n this.#pump();\n }, LINK_LIMITS.timeoutMs);\n const request: LinkScanRequest = { id,\n rule: rule.builtin ? { builtin: rule.builtin } : { source: rule.pattern!.source, flags: rule.pattern!.flags },\n chunks: missing.map(({ chunk, key }) => ({ key, text: chunk.text })) };\n this.#worker.postMessage(request);\n } catch (error) { this.#failWorker(error); }\n return;\n }\n this.#resolve(work);\n } finally { this.#pumping = false; }\n }\n\n #cacheKey(rule: TerminalLinkRule, chunk: MappedLinkChunk): string {\n return JSON.stringify([rule.id, chunk.key]);\n }\n\n #receive(response: LinkScanResponse): void {\n const job = this.#job;\n if (!job || response?.id !== job.id || this.#disposed) return;\n clearTimeout(this.#timer); this.#timer = undefined; this.#job = null;\n if (response.error) {\n this.#disabled.add(job.rule.id);\n this.#error(response.error, job.rule.id, response.message ?? \"Link scan failed\");\n } else if (!validResponse(response, job)) {\n this.#failWorker(\"Malformed detection response\"); return;\n } else {\n for (const result of response.results!) {\n const key = job.keys.get(result.key)!;\n const size = key.length + result.matches.reduce((total, match) => total + linkMatchTextSize(match), 0);\n this.#cacheSize += size - (this.#cacheSizes.get(key) ?? 0);\n this.#cacheSizes.set(key, size);\n this.#cache.set(key, result.matches);\n }\n }\n if (this.#work !== job.work) this.#trimCache();\n this.#pump();\n }\n\n #resolve(work: Work): void {\n if (!this.#detection || this.#work !== work) return;\n const occupied: SelectionRange[] = [...work.snapshot.hyperlinks];\n const links: DetectedLink[] = [];\n const overlaps = (ranges: SelectionRange[]) => ranges.some(range => occupied.some(other =>\n range.row === other.row && range.startColumn < other.endColumn && range.endColumn > other.startColumn));\n for (const rule of this.#detection.rules) {\n if (!rule.enabled || this.#disabled.has(rule.id)) continue;\n const ruleLinks: DetectedLink[] = [], ruleRanges: SelectionRange[] = [];\n try {\n for (const mapped of work.chunks.get(rule.text ?? \"logicalLine\") ?? []) {\n for (const candidate of this.#cache.get(this.#cacheKey(rule, mapped)) ?? []) {\n const ranges = mapLinkRange(mapped, candidate.index, candidate.text.length);\n if (!ranges || overlaps(ranges)) continue;\n const match = Object.freeze({ ...candidate, captures: Object.freeze([...candidate.captures]),\n groups: Object.freeze({ ...candidate.groups }), chunk: mapped.chunk });\n const resolution = rule.resolve ? rule.resolve(match) : { target: candidate.text };\n if (this.#work !== work || this.#disposed) return;\n if (resolution === null) continue;\n if (typeof resolution !== \"object\" || resolution === undefined ||\n \"then\" in resolution || typeof resolution.target !== \"string\") throw new TypeError(\"Resolver must return a synchronous link resolution or null\");\n const action = resolution.action === undefined ? rule.action : resolution.action;\n validateLinkAction(action, this.#options.actions);\n if (links.length + ruleLinks.length >= LINK_LIMITS.matches) throw new RangeError(\"Resolved link limit exceeded\");\n const activation: TerminalLinkActivation = Object.freeze({\n source: \"detected\", ruleId: rule.id,\n kind: rule.builtin ? (rule.builtin === \"url\" || rule.builtin === \"uri\" ? \"uri\" : \"path\") : rule.kind!,\n text: candidate.text, target: resolution.target,\n ranges: Object.freeze(ranges.map(range => Object.freeze(range))),\n revision: work.revision, data: resolution.data,\n });\n ruleLinks.push(Object.freeze({ id: JSON.stringify([work.identity, rule.id,\n ranges[0].row, ranges[0].startColumn, candidate.index]),\n action, activation }));\n ruleRanges.push(...ranges);\n }\n }\n links.push(...ruleLinks); occupied.push(...ruleRanges);\n } catch (error) {\n this.#disabled.add(rule.id);\n this.#error(error instanceof RangeError ? \"limit\" : \"resolver\", rule.id, error);\n if (this.#work !== work || this.#disposed) return;\n }\n }\n // Evict only after resolution so a viewport larger than the cache cannot cause endless rescans.\n this.#trimCache();\n this.#publish(links);\n }\n\n #trimCache(): void {\n while (this.#cache.size > LINK_LIMITS.cacheEntries || this.#cacheSize > LINK_LIMITS.cacheText) {\n const key = this.#cache.keys().next().value!;\n this.#cache.delete(key); this.#cacheSize -= this.#cacheSizes.get(key)!;\n this.#cacheSizes.delete(key);\n }\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.d.ts new file mode 100644 index 00000000000..92ccdc44ef9 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.d.ts @@ -0,0 +1,19 @@ +import type { InputActionHandler, TerminalInput, TerminalInputContext } from "./types.js"; +import type { TerminalLinkAction, TerminalLinkActivation, TerminalLinkOptions } from "./link-types.js"; +export declare const LINK_LIMITS: Readonly<{ + rules: 32; + pattern: 8192; + chunk: 65536; + totalText: 262144; + cells: 262144; + matches: 2048; + resultText: 262144; + cacheEntries: 2048; + cacheText: 1048576; + timeoutMs: 250; +}>; +export declare function validateLinkAction(action: unknown, actions: ReadonlySet): asserts action is TerminalLinkAction; +export declare function normalizeLinks(options: false | TerminalLinkOptions | undefined, actions: ReadonlySet): false | TerminalLinkOptions; +export declare function isLinkActivation(input: unknown): input is TerminalLinkActivation; +export declare function linkAction(handler: (context: TerminalInputContext, link: TerminalLinkActivation, input: Readonly | undefined) => unknown): InputActionHandler; +//# sourceMappingURL=link-options.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.d.ts.map new file mode 100644 index 00000000000..55b1306e72b --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"link-options.d.ts","sourceRoot":"","sources":["../src/link-options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAC1F,OAAO,KAAK,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,mBAAmB,EAAoB,MAAM,iBAAiB,CAAC;AAEzH,eAAO,MAAM,WAAW;;;;;;;;;;;EAGtB,CAAC;AAEH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,IAAI,kBAAkB,CAItH;AAMD,wBAAgB,cAAc,CAAC,OAAO,EAAE,KAAK,GAAG,mBAAmB,GAAG,SAAS,EAC7E,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,KAAK,GAAG,mBAAmB,CAwD3D;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,sBAAsB,CAchF;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,oBAAoB,EAAE,IAAI,EAAE,sBAAsB,EAC9F,KAAK,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,SAAS,KAAK,OAAO,GAAG,kBAAkB,CAM5E"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.js new file mode 100644 index 00000000000..dd5e33b0b63 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.js @@ -0,0 +1,108 @@ +export const LINK_LIMITS = Object.freeze({ + rules: 32, pattern: 8192, chunk: 65536, totalText: 262144, cells: 262144, + matches: 2048, resultText: 262144, cacheEntries: 2048, cacheText: 1048576, timeoutMs: 250, +}); +export function validateLinkAction(action, actions) { + if (typeof action !== "function" && !(typeof action === "string" && actions.has(action))) { + throw new TypeError("Link action must be a registered custom action or callback"); + } +} +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +export function normalizeLinks(options, actions) { + if (options === false) + return false; + if (options === undefined) + return {}; + if (!object(options)) + throw new TypeError("Invalid links configuration"); + const normalized = {}; + if (options.osc8 === false) + normalized.osc8 = false; + else if (options.osc8 !== undefined) { + if (!object(options.osc8)) + throw new TypeError("Invalid OSC 8 configuration"); + validateLinkAction(options.osc8.action, actions); + normalized.osc8 = { action: options.osc8.action }; + } + const detection = options.detection; + if (detection === false) + normalized.detection = false; + else if (detection !== undefined) { + if (!object(detection) || !Array.isArray(detection.rules)) + throw new TypeError("Invalid detection configuration"); + if (detection.rules.length > LINK_LIMITS.rules) + throw new TypeError("Too many link rules"); + if (detection.activation !== undefined && !["modifierClick", "click"].includes(detection.activation)) { + throw new TypeError("Invalid link activation"); + } + if (detection.decoration !== undefined && !["always", "hover", "none"].includes(detection.decoration)) { + throw new TypeError("Invalid link decoration"); + } + if (detection.underlineStyle !== undefined && !["solid", "dashed"].includes(detection.underlineStyle)) { + throw new TypeError("Invalid link underline style"); + } + const ids = new Set(); + const rules = detection.rules.map((rule) => { + if (!object(rule) || typeof rule.id !== "string" || !rule.id.length || rule.id.length > 256 || ids.has(rule.id)) { + throw new TypeError("Link rule IDs must be nonempty and unique"); + } + ids.add(rule.id); + if (rule.enabled !== undefined && typeof rule.enabled !== "boolean") + throw new TypeError("Invalid enabled flag"); + if (rule.text !== undefined && !["physicalRow", "logicalLine", "viewport"].includes(rule.text)) { + throw new TypeError("Invalid link text mode"); + } + if (rule.resolve !== undefined && typeof rule.resolve !== "function") + throw new TypeError("Invalid resolver"); + validateLinkAction(rule.action, actions); + const common = { id: rule.id, action: rule.action, enabled: rule.enabled ?? true, + text: rule.text ?? "logicalLine", resolve: rule.resolve }; + if (rule.builtin !== undefined) { + if (!["url", "uri", "absolutePath", "homePath"].includes(rule.builtin) || + rule.pattern !== undefined || rule.kind !== undefined) + throw new TypeError("Invalid builtin rule"); + return { ...common, builtin: rule.builtin }; + } + if (!(rule.pattern instanceof RegExp) || !["uri", "path", "custom"].includes(rule.kind)) { + throw new TypeError("Custom rules require a RegExp and kind"); + } + if (rule.pattern.source.length > LINK_LIMITS.pattern || /[^dgimsuv]/u.test(rule.pattern.flags)) { + throw new TypeError("Unsupported regex flags or oversized pattern (sticky is not supported)"); + } + return { ...common, kind: rule.kind, pattern: new RegExp(rule.pattern.source, rule.pattern.flags) }; + }); + normalized.detection = { rules, activation: detection.activation ?? "modifierClick", + decoration: detection.decoration ?? "always", underlineStyle: detection.underlineStyle ?? "solid" }; + } + return normalized; +} +export function isLinkActivation(input) { + if (!object(input)) + return false; + const value = input; + if (!["detected", "osc8"].includes(value.source) || + !["uri", "path", "custom"].includes(value.kind) || + typeof value.text !== "string" || typeof value.target !== "string" || + !Number.isSafeInteger(value.revision) || value.revision < 0 || + !Array.isArray(value.ranges) || value.ranges.length === 0) + return false; + if (value.source === "osc8" ? value.ruleId !== null || value.kind !== "uri" + : typeof value.ruleId !== "string" || value.ruleId.length === 0) + return false; + return value.ranges.every(range => object(range) && + Number.isSafeInteger(range.row) && range.row >= 0 && + Number.isSafeInteger(range.startColumn) && range.startColumn >= 0 && + Number.isSafeInteger(range.endColumn) && range.endColumn > range.startColumn); +} +export function linkAction(handler) { + if (typeof handler !== "function") + throw new TypeError("Expected a link action callback"); + return (context, args, input) => { + if (!isLinkActivation(args)) + throw new TypeError("Expected a terminal link activation"); + return handler(context, args, input); + }; +} +//# sourceMappingURL=link-options.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.js.map new file mode 100644 index 00000000000..959968456c6 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-options.js.map @@ -0,0 +1 @@ +{"version":3,"file":"link-options.js","sourceRoot":"","sources":["../src/link-options.ts"],"names":[],"mappings":"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","sourcesContent":["import type { InputActionHandler, TerminalInput, TerminalInputContext } from \"./types.js\";\nimport type { TerminalLinkAction, TerminalLinkActivation, TerminalLinkOptions, TerminalLinkRule } from \"./link-types.js\";\n\nexport const LINK_LIMITS = Object.freeze({\n rules: 32, pattern: 8192, chunk: 65536, totalText: 262144, cells: 262144,\n matches: 2048, resultText: 262144, cacheEntries: 2048, cacheText: 1048576, timeoutMs: 250,\n});\n\nexport function validateLinkAction(action: unknown, actions: ReadonlySet): asserts action is TerminalLinkAction {\n if (typeof action !== \"function\" && !(typeof action === \"string\" && actions.has(action))) {\n throw new TypeError(\"Link action must be a registered custom action or callback\");\n }\n}\n\nfunction object(value: unknown): boolean {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nexport function normalizeLinks(options: false | TerminalLinkOptions | undefined,\n actions: ReadonlySet): false | TerminalLinkOptions {\n if (options === false) return false;\n if (options === undefined) return {};\n if (!object(options)) throw new TypeError(\"Invalid links configuration\");\n const normalized: TerminalLinkOptions = {};\n if (options.osc8 === false) normalized.osc8 = false;\n else if (options.osc8 !== undefined) {\n if (!object(options.osc8)) throw new TypeError(\"Invalid OSC 8 configuration\");\n validateLinkAction(options.osc8.action, actions);\n normalized.osc8 = { action: options.osc8.action };\n }\n const detection = options.detection;\n if (detection === false) normalized.detection = false;\n else if (detection !== undefined) {\n if (!object(detection) || !Array.isArray(detection.rules)) throw new TypeError(\"Invalid detection configuration\");\n if (detection.rules.length > LINK_LIMITS.rules) throw new TypeError(\"Too many link rules\");\n if (detection.activation !== undefined && ![\"modifierClick\", \"click\"].includes(detection.activation)) {\n throw new TypeError(\"Invalid link activation\");\n }\n if (detection.decoration !== undefined && ![\"always\", \"hover\", \"none\"].includes(detection.decoration)) {\n throw new TypeError(\"Invalid link decoration\");\n }\n if (detection.underlineStyle !== undefined && ![\"solid\", \"dashed\"].includes(detection.underlineStyle)) {\n throw new TypeError(\"Invalid link underline style\");\n }\n const ids = new Set();\n const rules = detection.rules.map((rule: TerminalLinkRule): TerminalLinkRule => {\n if (!object(rule) || typeof rule.id !== \"string\" || !rule.id.length || rule.id.length > 256 || ids.has(rule.id)) {\n throw new TypeError(\"Link rule IDs must be nonempty and unique\");\n }\n ids.add(rule.id);\n if (rule.enabled !== undefined && typeof rule.enabled !== \"boolean\") throw new TypeError(\"Invalid enabled flag\");\n if (rule.text !== undefined && ![\"physicalRow\", \"logicalLine\", \"viewport\"].includes(rule.text)) {\n throw new TypeError(\"Invalid link text mode\");\n }\n if (rule.resolve !== undefined && typeof rule.resolve !== \"function\") throw new TypeError(\"Invalid resolver\");\n validateLinkAction(rule.action, actions);\n const common = { id: rule.id, action: rule.action, enabled: rule.enabled ?? true,\n text: rule.text ?? \"logicalLine\", resolve: rule.resolve };\n if (rule.builtin !== undefined) {\n if (![\"url\", \"uri\", \"absolutePath\", \"homePath\"].includes(rule.builtin) ||\n rule.pattern !== undefined || rule.kind !== undefined) throw new TypeError(\"Invalid builtin rule\");\n return { ...common, builtin: rule.builtin };\n }\n if (!(rule.pattern instanceof RegExp) || ![\"uri\", \"path\", \"custom\"].includes(rule.kind)) {\n throw new TypeError(\"Custom rules require a RegExp and kind\");\n }\n if (rule.pattern.source.length > LINK_LIMITS.pattern || /[^dgimsuv]/u.test(rule.pattern.flags)) {\n throw new TypeError(\"Unsupported regex flags or oversized pattern (sticky is not supported)\");\n }\n return { ...common, kind: rule.kind, pattern: new RegExp(rule.pattern.source, rule.pattern.flags) };\n });\n normalized.detection = { rules, activation: detection.activation ?? \"modifierClick\",\n decoration: detection.decoration ?? \"always\", underlineStyle: detection.underlineStyle ?? \"solid\" };\n }\n return normalized;\n}\n\nexport function isLinkActivation(input: unknown): input is TerminalLinkActivation {\n if (!object(input)) return false;\n const value = input as Record;\n if (![\"detected\", \"osc8\"].includes(value.source as string) ||\n ![\"uri\", \"path\", \"custom\"].includes(value.kind as string) ||\n typeof value.text !== \"string\" || typeof value.target !== \"string\" ||\n !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 ||\n !Array.isArray(value.ranges) || value.ranges.length === 0) return false;\n if (value.source === \"osc8\" ? value.ruleId !== null || value.kind !== \"uri\"\n : typeof value.ruleId !== \"string\" || value.ruleId.length === 0) return false;\n return value.ranges.every(range => object(range) &&\n Number.isSafeInteger(range.row) && (range.row as number) >= 0 &&\n Number.isSafeInteger(range.startColumn) && (range.startColumn as number) >= 0 &&\n Number.isSafeInteger(range.endColumn) && (range.endColumn as number) > (range.startColumn as number));\n}\n\nexport function linkAction(handler: (context: TerminalInputContext, link: TerminalLinkActivation,\n input: Readonly | undefined) => unknown): InputActionHandler {\n if (typeof handler !== \"function\") throw new TypeError(\"Expected a link action callback\");\n return (context, args, input) => {\n if (!isLinkActivation(args)) throw new TypeError(\"Expected a terminal link activation\");\n return handler(context, args, input);\n };\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.d.ts new file mode 100644 index 00000000000..25817638789 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.d.ts @@ -0,0 +1,41 @@ +import type { LinkDetectionSnapshot } from "./link-detection.js"; +import type { SelectionRange, TerminalLinkUnderlineStyle } from "./types.js"; +import type { FrameMetadata, TerminalCell } from "./wire-types.js"; +type Cells = readonly (TerminalCell | undefined)[]; +type Decorations = { + revision: number; + generation: number; + serial: number; + mask: Uint8Array; +}; +/** Tracks only local presentation state; never modifies authoritative cells or runs matching. */ +export declare class LinkPresentation { + enabled: boolean; + generation: number; + private presented?; + private sent?; + private forceSnapshot; + private decorations?; + private pending?; + private lastSerial; + configure(enabled: boolean, generation: number): boolean; + private clear; + prepare(cells: Cells, metadata: FrameMetadata): void; + present(cells: Cells, metadata: FrameMetadata): { + linkGeneration?: number; + linkSnapshot?: LinkDetectionSnapshot; + }; + snapshot(): LinkDetectionSnapshot | undefined; + accept(revision: number, generation: number, serial: number, ranges: readonly SelectionRange[], busy: boolean, underlineStyle?: TerminalLinkUnderlineStyle): boolean; + submission(): { + mask?: Uint8Array; + acknowledgement?: Decorations; + }; + acknowledge(submitted: Decorations | undefined, busy: boolean): { + revision: number; + generation: number; + serial: number; + } | undefined; +} +export {}; +//# sourceMappingURL=link-presentation.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.d.ts.map new file mode 100644 index 00000000000..42ffa596a16 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"link-presentation.d.ts","sourceRoot":"","sources":["../src/link-presentation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAC7E,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEnE,KAAK,KAAK,GAAG,SAAS,CAAC,YAAY,GAAG,SAAS,CAAC,EAAE,CAAC;AACnD,KAAK,WAAW,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,CAAC;AA0B9F,iGAAiG;AACjG,qBAAa,gBAAgB;IAC3B,OAAO,UAAS;IAChB,UAAU,SAAK;IACf,OAAO,CAAC,SAAS,CAAC,CAA4C;IAC9D,OAAO,CAAC,IAAI,CAAC,CAA4C;IACzD,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,WAAW,CAAC,CAAc;IAClC,OAAO,CAAC,OAAO,CAAC,CAAc;IAC9B,OAAO,CAAC,UAAU,CAAM;IAExB,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO;IAcxD,OAAO,CAAC,KAAK;IAKb,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,aAAa,GAAG,IAAI;IAOpD,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,aAAa,GAAG;QAAE,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,qBAAqB,CAAA;KAAE;IAOjH,QAAQ,IAAI,qBAAqB,GAAG,SAAS;IAe7C,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,cAAc,EAAE,EAAE,IAAI,EAAE,OAAO,EAC3G,cAAc,GAAE,0BAAoC,GAAG,OAAO;IAwBhE,UAAU,IAAI;QAAE,IAAI,CAAC,EAAE,UAAU,CAAC;QAAC,eAAe,CAAC,EAAE,WAAW,CAAA;KAAE;IAIlE,WAAW,CAAC,SAAS,EAAE,WAAW,GAAG,SAAS,EAAE,IAAI,EAAE,OAAO,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS;CAMrI"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.js new file mode 100644 index 00000000000..62115e3382f --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.js @@ -0,0 +1,125 @@ +const TEXT_ATTRIBUTES = 64 | 1024; // Hidden and soft-wrap; other SGR attributes are presentation-only. +function sameContent(cells, metadata, previousCells, previous) { + if (cells === previousCells && metadata === previous) + return true; + if (metadata.columns !== previous.columns || metadata.rows !== previous.rows) + return false; + const history = metadata.history, oldHistory = previous.history; + if (!!history !== !!oldHistory || history?.generation !== oldHistory?.generation || + history?.buffer !== oldHistory?.buffer || history?.rowIds.length !== oldHistory?.rowIds.length || + history?.rowIds.some((id, index) => id !== oldHistory?.rowIds[index])) + return false; + if (metadata.hyperlinks.length !== previous.hyperlinks.length || + metadata.hyperlinks.some((range, index) => { + const old = previous.hyperlinks[index]; + return range.row !== old.row || range.startColumn !== old.startColumn || + range.endColumn !== old.endColumn || range.uri !== old.uri; + })) + return false; + if (cells.length !== previousCells.length) + return false; + for (let index = 0; index < cells.length; index++) { + const cell = cells[index], old = previousCells[index]; + if (cell === old) + continue; + if (!cell || !old || cell.text !== old.text || cell.width !== old.width || + (cell.attributes & TEXT_ATTRIBUTES) !== (old.attributes & TEXT_ATTRIBUTES)) + return false; + } + return true; +} +/** Tracks only local presentation state; never modifies authoritative cells or runs matching. */ +export class LinkPresentation { + enabled = false; + generation = 0; + presented; + sent; + forceSnapshot = false; + decorations; + pending; + lastSerial = -1; + configure(enabled, generation) { + if (typeof enabled !== "boolean" || !Number.isSafeInteger(generation) || generation < 0) { + throw new Error("Invalid link detection configuration"); + } + if (generation < this.generation || (enabled === this.enabled && generation === this.generation)) + return false; + this.enabled = enabled; + this.generation = generation; + this.sent = undefined; + this.forceSnapshot = enabled; + this.clear(); + this.lastSerial = -1; + return true; + } + clear() { + this.decorations = undefined; + this.pending = undefined; + } + prepare(cells, metadata) { + if (!this.enabled) + return; + if (!this.presented || !sameContent(cells, metadata, this.presented.cells, this.presented.metadata)) + this.clear(); + // A pending acknowledgement belongs to its submitted revision, even if text is unchanged. + if (this.pending?.revision !== metadata.revision) + this.pending = undefined; + } + present(cells, metadata) { + this.presented = { cells, metadata }; + if (!this.enabled) + return {}; + const snapshot = this.snapshot(); + return { linkGeneration: this.generation, ...(snapshot ? { linkSnapshot: snapshot } : {}) }; + } + snapshot() { + const current = this.presented; + if (!this.enabled || !current) + return undefined; + if (!this.forceSnapshot && this.sent && + sameContent(current.cells, current.metadata, this.sent.cells, this.sent.metadata)) { + this.sent = current; + return undefined; + } + this.forceSnapshot = false; + this.sent = current; + const { cells, metadata } = current; + return { revision: metadata.revision, columns: metadata.columns, rows: metadata.rows, + cells, hyperlinks: metadata.hyperlinks }; + } + accept(revision, generation, serial, ranges, busy, underlineStyle = "solid") { + if (![revision, generation, serial].every(value => Number.isSafeInteger(value) && value >= 0) || + !Array.isArray(ranges) || !["solid", "dashed"].includes(underlineStyle)) { + throw new Error("Invalid link decorations"); + } + const metadata = this.presented?.metadata; + if (!this.enabled || busy || !metadata || revision !== metadata.revision || + generation !== this.generation || serial <= this.lastSerial) + return false; + if (ranges.length > metadata.columns * metadata.rows || ranges.some(range => !range || + !Number.isInteger(range.row) || range.row < 0 || range.row >= metadata.rows || + !Number.isInteger(range.startColumn) || !Number.isInteger(range.endColumn) || + range.startColumn < 0 || range.endColumn <= range.startColumn || range.endColumn > metadata.columns)) { + throw new Error("Invalid link decoration ranges"); + } + const mask = new Uint8Array(metadata.columns * metadata.rows); + // Values share the renderer's SGR underline styles, without modifying any cell. + const style = underlineStyle === "dashed" ? 5 : 1; + for (const range of ranges) + mask.fill(style, range.row * metadata.columns + range.startColumn, range.row * metadata.columns + range.endColumn); + this.lastSerial = serial; + this.decorations = this.pending = { revision, generation, serial, mask }; + return true; + } + submission() { + return { mask: this.decorations?.mask, acknowledgement: this.pending }; + } + acknowledge(submitted, busy) { + if (!submitted || submitted !== this.pending || busy || !this.enabled || + submitted.generation !== this.generation || submitted.revision !== this.presented?.metadata.revision) + return undefined; + this.pending = undefined; + return { revision: submitted.revision, generation: submitted.generation, serial: submitted.serial }; + } +} +//# sourceMappingURL=link-presentation.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.js.map new file mode 100644 index 00000000000..d686dd72232 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-presentation.js.map @@ -0,0 +1 @@ +{"version":3,"file":"link-presentation.js","sourceRoot":"","sources":["../src/link-presentation.ts"],"names":[],"mappings":"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","sourcesContent":["import type { LinkDetectionSnapshot } from \"./link-detection.js\";\nimport type { SelectionRange, TerminalLinkUnderlineStyle } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell } from \"./wire-types.js\";\n\ntype Cells = readonly (TerminalCell | undefined)[];\ntype Decorations = { revision: number; generation: number; serial: number; mask: Uint8Array };\nconst TEXT_ATTRIBUTES = 64 | 1024; // Hidden and soft-wrap; other SGR attributes are presentation-only.\n\nfunction sameContent(cells: Cells, metadata: FrameMetadata, previousCells: Cells, previous: FrameMetadata): boolean {\n if (cells === previousCells && metadata === previous) return true;\n if (metadata.columns !== previous.columns || metadata.rows !== previous.rows) return false;\n const history = metadata.history, oldHistory = previous.history;\n if (!!history !== !!oldHistory || history?.generation !== oldHistory?.generation ||\n history?.buffer !== oldHistory?.buffer || history?.rowIds.length !== oldHistory?.rowIds.length ||\n history?.rowIds.some((id, index) => id !== oldHistory?.rowIds[index])) return false;\n if (metadata.hyperlinks.length !== previous.hyperlinks.length ||\n metadata.hyperlinks.some((range, index) => {\n const old = previous.hyperlinks[index];\n return range.row !== old.row || range.startColumn !== old.startColumn ||\n range.endColumn !== old.endColumn || range.uri !== old.uri;\n })) return false;\n if (cells.length !== previousCells.length) return false;\n for (let index = 0; index < cells.length; index++) {\n const cell = cells[index], old = previousCells[index];\n if (cell === old) continue;\n if (!cell || !old || cell.text !== old.text || cell.width !== old.width ||\n (cell.attributes & TEXT_ATTRIBUTES) !== (old.attributes & TEXT_ATTRIBUTES)) return false;\n }\n return true;\n}\n\n/** Tracks only local presentation state; never modifies authoritative cells or runs matching. */\nexport class LinkPresentation {\n enabled = false;\n generation = 0;\n private presented?: { cells: Cells; metadata: FrameMetadata };\n private sent?: { cells: Cells; metadata: FrameMetadata };\n private forceSnapshot = false;\n private decorations?: Decorations;\n private pending?: Decorations;\n private lastSerial = -1;\n\n configure(enabled: boolean, generation: number): boolean {\n if (typeof enabled !== \"boolean\" || !Number.isSafeInteger(generation) || generation < 0) {\n throw new Error(\"Invalid link detection configuration\");\n }\n if (generation < this.generation || (enabled === this.enabled && generation === this.generation)) return false;\n this.enabled = enabled;\n this.generation = generation;\n this.sent = undefined;\n this.forceSnapshot = enabled;\n this.clear();\n this.lastSerial = -1;\n return true;\n }\n\n private clear(): void {\n this.decorations = undefined;\n this.pending = undefined;\n }\n\n prepare(cells: Cells, metadata: FrameMetadata): void {\n if (!this.enabled) return;\n if (!this.presented || !sameContent(cells, metadata, this.presented.cells, this.presented.metadata)) this.clear();\n // A pending acknowledgement belongs to its submitted revision, even if text is unchanged.\n if (this.pending?.revision !== metadata.revision) this.pending = undefined;\n }\n\n present(cells: Cells, metadata: FrameMetadata): { linkGeneration?: number; linkSnapshot?: LinkDetectionSnapshot } {\n this.presented = { cells, metadata };\n if (!this.enabled) return {};\n const snapshot = this.snapshot();\n return { linkGeneration: this.generation, ...(snapshot ? { linkSnapshot: snapshot } : {}) };\n }\n\n snapshot(): LinkDetectionSnapshot | undefined {\n const current = this.presented;\n if (!this.enabled || !current) return undefined;\n if (!this.forceSnapshot && this.sent &&\n sameContent(current.cells, current.metadata, this.sent.cells, this.sent.metadata)) {\n this.sent = current;\n return undefined;\n }\n this.forceSnapshot = false;\n this.sent = current;\n const { cells, metadata } = current;\n return { revision: metadata.revision, columns: metadata.columns, rows: metadata.rows,\n cells, hyperlinks: metadata.hyperlinks };\n }\n\n accept(revision: number, generation: number, serial: number, ranges: readonly SelectionRange[], busy: boolean,\n underlineStyle: TerminalLinkUnderlineStyle = \"solid\"): boolean {\n if (![revision, generation, serial].every(value => Number.isSafeInteger(value) && value >= 0) ||\n !Array.isArray(ranges) || ![\"solid\", \"dashed\"].includes(underlineStyle)) {\n throw new Error(\"Invalid link decorations\");\n }\n const metadata = this.presented?.metadata;\n if (!this.enabled || busy || !metadata || revision !== metadata.revision ||\n generation !== this.generation || serial <= this.lastSerial) return false;\n if (ranges.length > metadata.columns * metadata.rows || ranges.some(range => !range ||\n !Number.isInteger(range.row) || range.row < 0 || range.row >= metadata.rows ||\n !Number.isInteger(range.startColumn) || !Number.isInteger(range.endColumn) ||\n range.startColumn < 0 || range.endColumn <= range.startColumn || range.endColumn > metadata.columns)) {\n throw new Error(\"Invalid link decoration ranges\");\n }\n const mask = new Uint8Array(metadata.columns * metadata.rows);\n // Values share the renderer's SGR underline styles, without modifying any cell.\n const style = underlineStyle === \"dashed\" ? 5 : 1;\n for (const range of ranges) mask.fill(style, range.row * metadata.columns + range.startColumn,\n range.row * metadata.columns + range.endColumn);\n this.lastSerial = serial;\n this.decorations = this.pending = { revision, generation, serial, mask };\n return true;\n }\n\n submission(): { mask?: Uint8Array; acknowledgement?: Decorations } {\n return { mask: this.decorations?.mask, acknowledgement: this.pending };\n }\n\n acknowledge(submitted: Decorations | undefined, busy: boolean): { revision: number; generation: number; serial: number } | undefined {\n if (!submitted || submitted !== this.pending || busy || !this.enabled ||\n submitted.generation !== this.generation || submitted.revision !== this.presented?.metadata.revision) return undefined;\n this.pending = undefined;\n return { revision: submitted.revision, generation: submitted.generation, serial: submitted.serial };\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.d.ts new file mode 100644 index 00000000000..44aeb062629 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.d.ts @@ -0,0 +1,18 @@ +import type { SelectionRange } from "./types.js"; +import type { TerminalLinkTextChunk, TerminalLinkTextMode } from "./link-types.js"; +import type { LinkDetectionSnapshot } from "./link-detection.js"; +interface Span extends SelectionRange { + start: number; + end: number; +} +export interface MappedLinkChunk { + chunk: TerminalLinkTextChunk; + spans: Span[]; + boundaries: Set; + unsafe: Set; + key: string; +} +export declare function extractLinkText(snapshot: LinkDetectionSnapshot, mode: TerminalLinkTextMode): MappedLinkChunk[]; +export declare function mapLinkRange(mapped: MappedLinkChunk, index: number, length: number): SelectionRange[] | null; +export {}; +//# sourceMappingURL=link-text.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.d.ts.map new file mode 100644 index 00000000000..3e52515dea4 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"link-text.d.ts","sourceRoot":"","sources":["../src/link-text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,KAAK,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAGjE,UAAU,IAAK,SAAQ,cAAc;IAAG,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE;AACpE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,qBAAqB,CAAC;IAC7B,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACxB,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;CACb;AAKD,wBAAgB,eAAe,CAAC,QAAQ,EAAE,qBAAqB,EAAE,IAAI,EAAE,oBAAoB,GAAG,eAAe,EAAE,CA4C9G;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,cAAc,EAAE,GAAG,IAAI,CAmB5G"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.js new file mode 100644 index 00000000000..0010886dc3f --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.js @@ -0,0 +1,92 @@ +import { LINK_LIMITS } from "./link-options.js"; +const SOFT_WRAP = 1024; +const HIDDEN = 64; +const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +export function extractLinkText(snapshot, mode) { + const { columns, rows, cells } = snapshot; + if (!Number.isSafeInteger(columns) || !Number.isSafeInteger(rows) || columns < 1 || rows < 1 || + columns * rows > LINK_LIMITS.cells) + throw new RangeError("Link grid exceeds cell limit"); + const result = []; + let text = "", spans = [], unsafe = new Set(), firstRow = 0, total = 0; + let start = "unknown"; + const wrapped = (row) => row >= 0 && !!((cells[(row + 1) * columns - 1]?.attributes ?? 0) & SOFT_WRAP); + const finish = (end) => { + const chunk = Object.freeze({ text, mode, start, end }); + const boundaries = new Set([text.length]); + for (const segment of segmenter.segment(text)) + boundaries.add(segment.index); + result.push({ chunk, spans, unsafe, boundaries, + key: JSON.stringify([mode, firstRow, start, end, text]) }); + text = ""; + spans = []; + unsafe = new Set(); + }; + for (let row = 0; row < rows; row++) { + if (text.length === 0) { + firstRow = row; + start = row === 0 ? "unknown" : wrapped(row - 1) ? "clipped" : "complete"; + } + for (let col = 0; col < columns; col++) { + const cell = cells[row * columns + col]; + const width = cell?.width ?? 1; + const valid = width >= 1 && width <= 2 && col + width <= columns && + !(cell && ((cell.attributes & HIDDEN) || cell.text.codePointAt(0) === 0x10eeee)) && + (width !== 2 || cells[row * columns + col + 1]?.width === 0); + const value = valid ? cell?.text || " " : "\0"; + if (total + value.length > LINK_LIMITS.totalText || text.length + value.length > LINK_LIMITS.chunk) { + throw new RangeError("Link text exceeds chunk or viewport limit"); + } + const offset = text.length; + text += value; + total += value.length; + if (valid) + spans.push({ start: offset, end: text.length, row, startColumn: col, endColumn: col + width }); + else { + unsafe.add(offset); + unsafe.add(text.length); + } + if (valid && width === 2) + col++; + } + const soft = wrapped(row); + if (!soft) + unsafe.add(text.length); // HWT1 cannot distinguish a full hard row from a right crop. + if (mode === "physicalRow" || (mode === "logicalLine" && !soft) || row === rows - 1) { + finish(soft ? "clipped" : "unknown"); + } + else if (!soft) { + if (++total > LINK_LIMITS.totalText || text.length + 1 > LINK_LIMITS.chunk) + throw new RangeError("Link text exceeds limit"); + text += "\n"; + } + } + return result; +} +export function mapLinkRange(mapped, index, length) { + const end = index + length, { chunk } = mapped; + if (length <= 0 || index < 0 || end > chunk.text.length || !mapped.boundaries.has(index) || + !mapped.boundaries.has(end) || (index === 0 && chunk.start !== "complete") || + (end === chunk.text.length && chunk.end !== "complete") || + chunk.text.slice(index, end).includes("\0")) + return null; + for (const boundary of mapped.unsafe) { + if (boundary === index || boundary === end || + (boundary > index && boundary < end && chunk.text[boundary - 1] !== " ")) + return null; + } + const ranges = []; + for (const span of mapped.spans) { + if (span.end <= index || span.start >= end) + continue; + if (span.start < index || span.end > end) + return null; + const last = ranges.at(-1); + if (last && last.row === span.row && last.endColumn === span.startColumn) + last.endColumn = span.endColumn; + else + ranges.push({ row: span.row, startColumn: span.startColumn, endColumn: span.endColumn }); + } + return ranges.length ? ranges : null; +} +//# sourceMappingURL=link-text.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.js.map new file mode 100644 index 00000000000..58df93776c0 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-text.js.map @@ -0,0 +1 @@ +{"version":3,"file":"link-text.js","sourceRoot":"","sources":["../src/link-text.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAUhD,MAAM,SAAS,GAAG,IAAI,CAAC;AACvB,MAAM,MAAM,GAAG,EAAE,CAAC;AAClB,MAAM,SAAS,GAAG,IAAI,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,CAAC,CAAC;AAE7E,MAAM,UAAU,eAAe,CAAC,QAA+B,EAAE,IAA0B;IACzF,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,QAAQ,CAAC;IAC1C,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,OAAO,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC;QACxF,OAAO,GAAG,IAAI,GAAG,WAAW,CAAC,KAAK;QAAE,MAAM,IAAI,UAAU,CAAC,8BAA8B,CAAC,CAAC;IAC7F,MAAM,MAAM,GAAsB,EAAE,CAAC;IACrC,IAAI,IAAI,GAAG,EAAE,EAAE,KAAK,GAAW,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,EAAU,EAAE,QAAQ,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC;IACvF,IAAI,KAAK,GAAmC,SAAS,CAAC;IACtD,MAAM,OAAO,GAAG,CAAC,GAAW,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,OAAO,GAAG,CAAC,CAAC,EAAE,UAAU,IAAI,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IAC/G,MAAM,MAAM,GAAG,CAAC,GAAiC,EAAE,EAAE;QACnD,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC;QACxD,MAAM,UAAU,GAAG,IAAI,GAAG,CAAS,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;QAClD,KAAK,MAAM,OAAO,IAAI,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC;YAAE,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAC7E,MAAM,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU;YAC5C,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;QAC7D,IAAI,GAAG,EAAE,CAAC;QAAC,KAAK,GAAG,EAAE,CAAC;QAAC,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;IAC5C,CAAC,CAAC;IACF,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC;QACpC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAAC,QAAQ,GAAG,GAAG,CAAC;YAAC,KAAK,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC;QAAC,CAAC;QACrH,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC;YACvC,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,GAAG,OAAO,GAAG,GAAG,CAAC,CAAC;YACxC,MAAM,KAAK,GAAG,IAAI,EAAE,KAAK,IAAI,CAAC,CAAC;YAC/B,MAAM,KAAK,GAAG,KAAK,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,GAAG,GAAG,KAAK,IAAI,OAAO;gBAC9D,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC;gBAChF,CAAC,KAAK,KAAK,CAAC,IAAI,KAAK,CAAC,GAAG,GAAG,OAAO,GAAG,GAAG,GAAG,CAAC,CAAC,EAAE,KAAK,KAAK,CAAC,CAAC,CAAC;YAC/D,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;YAC/C,IAAI,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,WAAW,CAAC,SAAS,IAAI,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,GAAG,WAAW,CAAC,KAAK,EAAE,CAAC;gBACnG,MAAM,IAAI,UAAU,CAAC,2CAA2C,CAAC,CAAC;YACpE,CAAC;YACD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;YAC3B,IAAI,IAAI,KAAK,CAAC;YAAC,KAAK,IAAI,KAAK,CAAC,MAAM,CAAC;YACrC,IAAI,KAAK;gBAAE,KAAK,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG,GAAG,KAAK,EAAE,CAAC,CAAC;iBACrG,CAAC;gBAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;gBAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAAC,CAAC;YACrD,IAAI,KAAK,IAAI,KAAK,KAAK,CAAC;gBAAE,GAAG,EAAE,CAAC;QAClC,CAAC;QACD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QAC1B,IAAI,CAAC,IAAI;YAAE,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,6DAA6D;QACjG,IAAI,IAAI,KAAK,aAAa,IAAI,CAAC,IAAI,KAAK,aAAa,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,IAAI,GAAG,CAAC,EAAE,CAAC;YACpF,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QACvC,CAAC;aAAM,IAAI,CAAC,IAAI,EAAE,CAAC;YACjB,IAAI,EAAE,KAAK,GAAG,WAAW,CAAC,SAAS,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,GAAG,WAAW,CAAC,KAAK;gBAAE,MAAM,IAAI,UAAU,CAAC,yBAAyB,CAAC,CAAC;YAC5H,IAAI,IAAI,IAAI,CAAC;QACf,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,MAAuB,EAAE,KAAa,EAAE,MAAc;IACjF,MAAM,GAAG,GAAG,KAAK,GAAG,MAAM,EAAE,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC;IAC/C,IAAI,MAAM,IAAI,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,GAAG,GAAG,KAAK,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC;QACpF,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC,IAAI,KAAK,CAAC,KAAK,KAAK,UAAU,CAAC;QAC1E,CAAC,GAAG,KAAK,KAAK,CAAC,IAAI,CAAC,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,UAAU,CAAC;QACvD,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC7D,KAAK,MAAM,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;QACrC,IAAI,QAAQ,KAAK,KAAK,IAAI,QAAQ,KAAK,GAAG;YACtC,CAAC,QAAQ,GAAG,KAAK,IAAI,QAAQ,GAAG,GAAG,IAAI,KAAK,CAAC,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC;IAC5F,CAAC;IACD,MAAM,MAAM,GAAqB,EAAE,CAAC;IACpC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QAChC,IAAI,IAAI,CAAC,GAAG,IAAI,KAAK,IAAI,IAAI,CAAC,KAAK,IAAI,GAAG;YAAE,SAAS;QACrD,IAAI,IAAI,CAAC,KAAK,GAAG,KAAK,IAAI,IAAI,CAAC,GAAG,GAAG,GAAG;YAAE,OAAO,IAAI,CAAC;QACtD,MAAM,IAAI,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3B,IAAI,IAAI,IAAI,IAAI,CAAC,GAAG,KAAK,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,SAAS,KAAK,IAAI,CAAC,WAAW;YAAE,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;;YACrG,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;IAChG,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AACvC,CAAC","sourcesContent":["import type { SelectionRange } from \"./types.js\";\nimport type { TerminalLinkTextChunk, TerminalLinkTextMode } from \"./link-types.js\";\nimport type { LinkDetectionSnapshot } from \"./link-detection.js\";\nimport { LINK_LIMITS } from \"./link-options.js\";\n\ninterface Span extends SelectionRange { start: number; end: number }\nexport interface MappedLinkChunk {\n chunk: TerminalLinkTextChunk;\n spans: Span[];\n boundaries: Set;\n unsafe: Set;\n key: string;\n}\nconst SOFT_WRAP = 1024;\nconst HIDDEN = 64;\nconst segmenter = new Intl.Segmenter(undefined, { granularity: \"grapheme\" });\n\nexport function extractLinkText(snapshot: LinkDetectionSnapshot, mode: TerminalLinkTextMode): MappedLinkChunk[] {\n const { columns, rows, cells } = snapshot;\n if (!Number.isSafeInteger(columns) || !Number.isSafeInteger(rows) || columns < 1 || rows < 1 ||\n columns * rows > LINK_LIMITS.cells) throw new RangeError(\"Link grid exceeds cell limit\");\n const result: MappedLinkChunk[] = [];\n let text = \"\", spans: Span[] = [], unsafe = new Set(), firstRow = 0, total = 0;\n let start: TerminalLinkTextChunk[\"start\"] = \"unknown\";\n const wrapped = (row: number) => row >= 0 && !!((cells[(row + 1) * columns - 1]?.attributes ?? 0) & SOFT_WRAP);\n const finish = (end: TerminalLinkTextChunk[\"end\"]) => {\n const chunk = Object.freeze({ text, mode, start, end });\n const boundaries = new Set([text.length]);\n for (const segment of segmenter.segment(text)) boundaries.add(segment.index);\n result.push({ chunk, spans, unsafe, boundaries,\n key: JSON.stringify([mode, firstRow, start, end, text]) });\n text = \"\"; spans = []; unsafe = new Set();\n };\n for (let row = 0; row < rows; row++) {\n if (text.length === 0) { firstRow = row; start = row === 0 ? \"unknown\" : wrapped(row - 1) ? \"clipped\" : \"complete\"; }\n for (let col = 0; col < columns; col++) {\n const cell = cells[row * columns + col];\n const width = cell?.width ?? 1;\n const valid = width >= 1 && width <= 2 && col + width <= columns &&\n !(cell && ((cell.attributes & HIDDEN) || cell.text.codePointAt(0) === 0x10eeee)) &&\n (width !== 2 || cells[row * columns + col + 1]?.width === 0);\n const value = valid ? cell?.text || \" \" : \"\\0\";\n if (total + value.length > LINK_LIMITS.totalText || text.length + value.length > LINK_LIMITS.chunk) {\n throw new RangeError(\"Link text exceeds chunk or viewport limit\");\n }\n const offset = text.length;\n text += value; total += value.length;\n if (valid) spans.push({ start: offset, end: text.length, row, startColumn: col, endColumn: col + width });\n else { unsafe.add(offset); unsafe.add(text.length); }\n if (valid && width === 2) col++;\n }\n const soft = wrapped(row);\n if (!soft) unsafe.add(text.length); // HWT1 cannot distinguish a full hard row from a right crop.\n if (mode === \"physicalRow\" || (mode === \"logicalLine\" && !soft) || row === rows - 1) {\n finish(soft ? \"clipped\" : \"unknown\");\n } else if (!soft) {\n if (++total > LINK_LIMITS.totalText || text.length + 1 > LINK_LIMITS.chunk) throw new RangeError(\"Link text exceeds limit\");\n text += \"\\n\";\n }\n }\n return result;\n}\n\nexport function mapLinkRange(mapped: MappedLinkChunk, index: number, length: number): SelectionRange[] | null {\n const end = index + length, { chunk } = mapped;\n if (length <= 0 || index < 0 || end > chunk.text.length || !mapped.boundaries.has(index) ||\n !mapped.boundaries.has(end) || (index === 0 && chunk.start !== \"complete\") ||\n (end === chunk.text.length && chunk.end !== \"complete\") ||\n chunk.text.slice(index, end).includes(\"\\0\")) return null;\n for (const boundary of mapped.unsafe) {\n if (boundary === index || boundary === end ||\n (boundary > index && boundary < end && chunk.text[boundary - 1] !== \" \")) return null;\n }\n const ranges: SelectionRange[] = [];\n for (const span of mapped.spans) {\n if (span.end <= index || span.start >= end) continue;\n if (span.start < index || span.end > end) return null;\n const last = ranges.at(-1);\n if (last && last.row === span.row && last.endColumn === span.startColumn) last.endColumn = span.endColumn;\n else ranges.push({ row: span.row, startColumn: span.startColumn, endColumn: span.endColumn });\n }\n return ranges.length ? ranges : null;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.d.ts new file mode 100644 index 00000000000..aa43ca12237 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.d.ts @@ -0,0 +1,72 @@ +import type { InputActionHandler, SelectionRange } from "./types.js"; +export type TerminalLinkTextMode = "physicalRow" | "logicalLine" | "viewport"; +export type TerminalLinkKind = "uri" | "path" | "custom"; +export type TerminalLinkAction = string | InputActionHandler; +export type TerminalLinkUnderlineStyle = "solid" | "dashed"; +export interface TerminalLinkOptions { + osc8?: false | { + action: TerminalLinkAction; + }; + detection?: false | { + rules: readonly TerminalLinkRule[]; + activation?: "modifierClick" | "click"; + /** When inferred underlines are visible. Defaults to always. */ + decoration?: "always" | "hover" | "none"; + /** Inferred underline appearance. Defaults to solid; authored SGR styling takes precedence. */ + underlineStyle?: TerminalLinkUnderlineStyle; + }; +} +export interface TerminalLinkRuleOptions { + id: string; + enabled?: boolean; + text?: TerminalLinkTextMode; + action: TerminalLinkAction; + /** Synchronous, fast host callback. Unlike regex execution, this cannot be preempted. */ + resolve?: (match: TerminalLinkMatch) => TerminalLinkResolution | null; +} +export type TerminalLinkRule = TerminalLinkRuleOptions & ({ + builtin: "url" | "uri" | "absolutePath" | "homePath"; + pattern?: never; + kind?: never; +} | { + pattern: RegExp; + kind: TerminalLinkKind; + builtin?: never; +}); +export interface TerminalLinkTextChunk { + readonly text: string; + readonly mode: TerminalLinkTextMode; + readonly start: "complete" | "clipped" | "unknown"; + readonly end: "complete" | "clipped" | "unknown"; +} +export interface TerminalLinkMatch { + readonly text: string; + /** UTF-16 offset within chunk.text. */ + readonly index: number; + readonly captures: readonly (string | undefined)[]; + readonly groups: Readonly>; + readonly chunk: TerminalLinkTextChunk; +} +export interface TerminalLinkResolution { + readonly target: string; + readonly action?: TerminalLinkAction; + readonly data?: unknown; +} +export interface TerminalLinkActivation { + readonly source: "detected" | "osc8"; + readonly ruleId: string | null; + readonly kind: TerminalLinkKind; + readonly text: string; + readonly target: string; + readonly ranges: readonly Readonly[]; + readonly revision: number; + /** Consumer-owned payload; core snapshots are frozen, but this object is not. */ + readonly data?: unknown; +} +export interface TerminalLinkDetectionError { + readonly code: "timeout" | "limit" | "resolver" | "worker"; + readonly ruleId: string | null; + readonly revision: number; + readonly message: string; +} +//# sourceMappingURL=link-types.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.d.ts.map new file mode 100644 index 00000000000..ba00fadc63f --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"link-types.d.ts","sourceRoot":"","sources":["../src/link-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.js new file mode 100644 index 00000000000..22eabb5650f --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=link-types.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.js.map new file mode 100644 index 00000000000..5a35288979d --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"link-types.js","sourceRoot":"","sources":["../src/link-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputActionHandler, SelectionRange } from \"./types.js\";\n\nexport type TerminalLinkTextMode = \"physicalRow\" | \"logicalLine\" | \"viewport\";\nexport type TerminalLinkKind = \"uri\" | \"path\" | \"custom\";\nexport type TerminalLinkAction = string | InputActionHandler;\nexport type TerminalLinkUnderlineStyle = \"solid\" | \"dashed\";\nexport interface TerminalLinkOptions {\n osc8?: false | { action: TerminalLinkAction };\n detection?: false | {\n rules: readonly TerminalLinkRule[];\n activation?: \"modifierClick\" | \"click\";\n /** When inferred underlines are visible. Defaults to always. */\n decoration?: \"always\" | \"hover\" | \"none\";\n /** Inferred underline appearance. Defaults to solid; authored SGR styling takes precedence. */\n underlineStyle?: TerminalLinkUnderlineStyle;\n };\n}\nexport interface TerminalLinkRuleOptions {\n id: string;\n enabled?: boolean;\n text?: TerminalLinkTextMode;\n action: TerminalLinkAction;\n /** Synchronous, fast host callback. Unlike regex execution, this cannot be preempted. */\n resolve?: (match: TerminalLinkMatch) => TerminalLinkResolution | null;\n}\nexport type TerminalLinkRule = TerminalLinkRuleOptions & (\n | { builtin: \"url\" | \"uri\" | \"absolutePath\" | \"homePath\"; pattern?: never; kind?: never }\n | { pattern: RegExp; kind: TerminalLinkKind; builtin?: never }\n);\nexport interface TerminalLinkTextChunk {\n readonly text: string;\n readonly mode: TerminalLinkTextMode;\n readonly start: \"complete\" | \"clipped\" | \"unknown\";\n readonly end: \"complete\" | \"clipped\" | \"unknown\";\n}\nexport interface TerminalLinkMatch {\n readonly text: string;\n /** UTF-16 offset within chunk.text. */\n readonly index: number;\n readonly captures: readonly (string | undefined)[];\n readonly groups: Readonly>;\n readonly chunk: TerminalLinkTextChunk;\n}\nexport interface TerminalLinkResolution {\n readonly target: string;\n readonly action?: TerminalLinkAction;\n readonly data?: unknown;\n}\nexport interface TerminalLinkActivation {\n readonly source: \"detected\" | \"osc8\";\n readonly ruleId: string | null;\n readonly kind: TerminalLinkKind;\n readonly text: string;\n readonly target: string;\n readonly ranges: readonly Readonly[];\n readonly revision: number;\n /** Consumer-owned payload; core snapshots are frozen, but this object is not. */\n readonly data?: unknown;\n}\nexport interface TerminalLinkDetectionError {\n readonly code: \"timeout\" | \"limit\" | \"resolver\" | \"worker\";\n readonly ruleId: string | null;\n readonly revision: number;\n readonly message: string;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.d.ts new file mode 100644 index 00000000000..78df2f9b9f3 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.d.ts @@ -0,0 +1,31 @@ +import type { TerminalLinkRule } from "./link-types.js"; +export interface LinkScanRequest { + id: number; + rule: { + source: string; + flags: string; + } | { + builtin: NonNullable; + }; + chunks: { + key: string; + text: string; + }[]; +} +export interface LinkScanMatch { + index: number; + text: string; + captures: (string | undefined)[]; + groups: Record; +} +export declare function linkMatchTextSize(match: LinkScanMatch): number; +export interface LinkScanResponse { + id: number; + results?: { + key: string; + matches: LinkScanMatch[]; + }[]; + error?: "limit" | "worker"; + message?: string; +} +//# sourceMappingURL=link-worker-protocol.d.ts.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.d.ts.map new file mode 100644 index 00000000000..094015fd028 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"link-worker-protocol.d.ts","sourceRoot":"","sources":["../src/link-worker-protocol.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAExD,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,OAAO,EAAE,WAAW,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAA;KAAE,CAAC;IAChG,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACzC;AACD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;IAC9D,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CAC5C;AACD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAM9D;AACD,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,aAAa,EAAE,CAAA;KAAE,EAAE,CAAC;IACtD,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.js new file mode 100644 index 00000000000..8148dfae9ba --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.js @@ -0,0 +1,10 @@ +export function linkMatchTextSize(match) { + // Include entry overhead so empty capture groups cannot evade the payload budget. + let size = match.text.length + 16; + for (const capture of match.captures) + size += 8 + (capture?.length ?? 0); + for (const [name, group] of Object.entries(match.groups)) + size += 8 + name.length + (group?.length ?? 0); + return size; +} +//# sourceMappingURL=link-worker-protocol.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.js.map new file mode 100644 index 00000000000..995990aa32c --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/link-worker-protocol.js.map @@ -0,0 +1 @@ +{"version":3,"file":"link-worker-protocol.js","sourceRoot":"","sources":["../src/link-worker-protocol.ts"],"names":[],"mappings":"AAWA,MAAM,UAAU,iBAAiB,CAAC,KAAoB;IACpD,kFAAkF;IAClF,IAAI,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;IAClC,KAAK,MAAM,OAAO,IAAI,KAAK,CAAC,QAAQ;QAAE,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,IAAI,CAAC,CAAC,CAAC;IACzE,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC;QAAE,IAAI,IAAI,CAAC,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC,CAAC,CAAC;IACzG,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["import type { TerminalLinkRule } from \"./link-types.js\";\n\nexport interface LinkScanRequest {\n id: number;\n rule: { source: string; flags: string } | { builtin: NonNullable };\n chunks: { key: string; text: string }[];\n}\nexport interface LinkScanMatch {\n index: number; text: string; captures: (string | undefined)[];\n groups: Record;\n}\nexport function linkMatchTextSize(match: LinkScanMatch): number {\n // Include entry overhead so empty capture groups cannot evade the payload budget.\n let size = match.text.length + 16;\n for (const capture of match.captures) size += 8 + (capture?.length ?? 0);\n for (const [name, group] of Object.entries(match.groups)) size += 8 + name.length + (group?.length ?? 0);\n return size;\n}\nexport interface LinkScanResponse {\n id: number;\n results?: { key: string; matches: LinkScanMatch[] }[];\n error?: \"limit\" | \"worker\";\n message?: string;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts index f61592f62cc..3a79f33da2d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts @@ -1,6 +1,11 @@ import type { GestureState } from "./selection-input.js"; import type { InputDecision, MouseTrackingMode, SelectionMode, TerminalInput, TerminalPoint } from "./types.js"; import type { MouseCommand } from "./wire-types.js"; +export interface PointerHyperlink { + id: string; + target: string; + activation?: "modifierClick" | "click"; +} interface MouseInspection { state?: () => Omit; begin?: (point: TerminalPoint, selection: { @@ -14,8 +19,9 @@ interface MouseInspection { execute?: (decision: Extract, input: TerminalInput) => void; - hyperlink?: (point: TerminalPoint) => string | null; - openHyperlink?: (uri: string) => void; + hyperlink?: (point: TerminalPoint) => PointerHyperlink | null; + hoverHyperlink?: (link: PointerHyperlink | null) => void; + openHyperlink?: (link: PointerHyperlink, input: TerminalInput) => void; } export interface MouseCapture { update(columns: number, rows: number, tracking: MouseTrackingMode): void; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map index 092001a1221..a69fce86c8f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"mouse-input.d.ts","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAmB,iBAAiB,EAAiB,aAAa,EAC3F,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,KAAK,EAA6B,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAM/E,UAAU,eAAe;IACvB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAC7C,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC5F,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IACxC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3D,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,aAAa,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAChG,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,MAAM,GAAG,IAAI,CAAC;IACpD,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AACD,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACzE,OAAO,IAAI,IAAI,CAAC;IAChB,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,EAC3F,KAAK,EAAE,MAAM,IAAI,EAAE,UAAU,GAAE,eAAoB,GAAG,YAAY,CA2WnE"} \ No newline at end of file +{"version":3,"file":"mouse-input.d.ts","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAmB,iBAAiB,EAAiB,aAAa,EAC3F,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,KAAK,EAA6B,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAK/E,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC;CACxC;AAED,UAAU,eAAe;IACvB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAC7C,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC5F,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IACxC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3D,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,aAAa,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAChG,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,gBAAgB,GAAG,IAAI,CAAC;IAC9D,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI,CAAC;IACzD,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,gBAAgB,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CACxE;AACD,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACzE,OAAO,IAAI,IAAI,CAAC;IAChB,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,EAC3F,KAAK,EAAE,MAAM,IAAI,EAAE,UAAU,GAAE,eAAoB,GAAG,YAAY,CA+WnE"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js index 3be3d15c607..f56e5ded7b2 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js @@ -27,6 +27,7 @@ export function captureMouse(canvas, send, focus, inspection = {}) { let hyperlinkClick; let hoverEvent; let hoverModifiers; + let hoveredLinkId; const originalTitle = canvas.title; const originalCursor = canvas.style.cursor; const state = () => ({ tracking, ...inspection.state?.() }); @@ -38,12 +39,16 @@ export function captureMouse(canvas, send, focus, inspection = {}) { return; hoverModifiers = modifiers; const position = hoverEvent && point(hoverEvent); - const uri = position ? inspection.hyperlink(position) : null; - canvas.title = uri ? `${uri}\nCtrl/Cmd+click to open link` : originalTitle; - canvas.style.cursor = uri && modifiers && (modifiers.ctrlKey || modifiers.metaKey) && + const link = position ? inspection.hyperlink(position) : null; + canvas.title = link ? `${link.target}\n${link.activation === "click" ? "Click" : "Ctrl/Cmd+click"} to activate link` : originalTitle; + canvas.style.cursor = link && modifiers && (link.activation === "click" || modifiers.ctrlKey || modifiers.metaKey) && !modifiers.altKey && !modifiers.shiftKey ? "pointer" : originalCursor; - if (hyperlinkClick && hyperlinkClick.uri !== uri) + if (hyperlinkClick && hyperlinkClick.link.id !== link?.id) hyperlinkClick.dragged = true; + if (hoveredLinkId !== link?.id) { + hoveredLinkId = link?.id; + inspection.hoverHyperlink?.(link); + } } function updateAutoScroll() { clearTimeout(autoScroll); @@ -146,11 +151,10 @@ export function captureMouse(canvas, send, focus, inspection = {}) { point: Object.freeze({ ...position }), ...inputModifiers(event) }; const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue }; let route = decision.action !== undefined ? InputRoute.Consume : decision.route; - if (route === InputRoute.Continue && event.button === 0 && (event.ctrlKey || event.metaKey) && - !event.altKey && !event.shiftKey) { - const uri = inspection.hyperlink?.(position); - if (uri) { - hyperlinkClick = { uri, x: event.clientX, y: event.clientY, dragged: false }; + if (route === InputRoute.Continue && event.button === 0 && !event.altKey && !event.shiftKey) { + const link = inspection.hyperlink?.(position); + if (link && (link.activation === "click" || event.ctrlKey || event.metaKey)) { + hyperlinkClick = { link, input, x: event.clientX, y: event.clientY, dragged: false }; route = InputRoute.Consume; } } @@ -266,13 +270,13 @@ export function captureMouse(canvas, send, focus, inspection = {}) { const position = point(event); const activate = event.button === 0 && event.buttons === 0 && !link.dragged && Math.hypot(event.clientX - link.x, event.clientY - link.y) < 4 && - position && inspection.hyperlink?.(position) === link.uri; + position && inspection.hyperlink?.(position)?.id === link.link.id; if (event.buttons === 0) cancel(false, false); else link.dragged = true; if (activate) - inspection.openHyperlink?.(link.uri); + inspection.openHyperlink?.(link.link, link.input); return; } const position = point(event, true) ?? lastPoint; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map index 325951e19f9..43f556d5227 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/mouse-input.js.map @@ -1 +1 @@ -{"version":3,"file":"mouse-input.js","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"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","sourcesContent":["import { cellPoint, WheelAccumulator, SelectionGesture } from \"./selection-input.js\";\nimport { InputRoute, inputModifiers } from \"./input-policy.js\";\nimport type { GestureState } from \"./selection-input.js\";\nimport type { InputDecision, InputRouteValue, MouseTrackingMode, PointerButton, SelectionMode,\n TerminalInput, TerminalPoint } from \"./types.js\";\nimport type { CellPosition, MouseButton, MouseCommand } from \"./wire-types.js\";\n\nconst buttons: readonly (readonly [PointerButton, number])[] = [[\"left\", 1], [\"middle\", 4], [\"right\", 2]];\nconst pointerButtons: readonly PointerButton[] = [\"left\", \"middle\", \"right\"];\ninterface SelectionClick { point: TerminalPoint; mode: SelectionMode; extend: boolean; dragged: boolean }\ninterface HyperlinkClick { uri: string; x: number; y: number; dragged: boolean }\ninterface MouseInspection {\n state?: () => Omit;\n begin?: (point: TerminalPoint, selection: { mode: SelectionMode; extend: boolean }) => void;\n extend?: (point: TerminalPoint) => void;\n scroll?: (delta: number, endpoint?: TerminalPoint) => void;\n end?: (cancelled: boolean) => void;\n resolve?: (input: TerminalInput) => InputDecision;\n execute?: (decision: Extract, input: TerminalInput) => void;\n hyperlink?: (point: TerminalPoint) => string | null;\n openHyperlink?: (uri: string) => void;\n}\nexport interface MouseCapture {\n update(columns: number, rows: number, tracking: MouseTrackingMode): void;\n refresh(): void;\n cancel(): void;\n dispose(): void;\n}\n\n/** Capture input intent only; the server chooses and encodes the mouse protocol. */\nexport function captureMouse(canvas: HTMLCanvasElement, send: (command: MouseCommand) => void,\n focus: () => void, inspection: MouseInspection = {}): MouseCapture {\n const listeners = new AbortController();\n const options = { signal: listeners.signal };\n let columns = 1, rows = 1;\n let tracking: MouseTrackingMode = 0;\n let pointerId: number | null = null;\n const pressed = new Set();\n let lastPoint: CellPosition | undefined;\n let pendingMove: MouseCommand | undefined;\n let lastMove: string | undefined;\n let scheduled: number | undefined;\n const wheel = new WheelAccumulator();\n const gesture = new SelectionGesture();\n let wheelOwner: \"local\" | \"app\" | undefined;\n let pointerEvent: PointerEvent | undefined;\n let autoScroll: ReturnType | undefined;\n let click = { count: 0, time: 0, x: -1, y: -1 };\n let selectionClick: SelectionClick | undefined;\n let completedClick: SelectionClick | undefined;\n let routedGesture: InputRouteValue | undefined;\n let contextMenuRoute: InputRouteValue | undefined;\n let hyperlinkClick: HyperlinkClick | undefined;\n let hoverEvent: PointerEvent | undefined;\n let hoverModifiers: Pick | undefined;\n const originalTitle = canvas.title;\n const originalCursor = canvas.style.cursor;\n const state = () => ({ tracking, ...inspection.state?.() });\n\n function point(event: MouseEvent, clamp = false): CellPosition | null {\n return cellPoint(event, canvas.getBoundingClientRect(), columns, rows, clamp);\n }\n\n function refreshHover(modifiers = hoverModifiers) {\n if (!inspection.hyperlink) return;\n hoverModifiers = modifiers;\n const position = hoverEvent && point(hoverEvent);\n const uri = position ? inspection.hyperlink(position) : null;\n canvas.title = uri ? `${uri}\\nCtrl/Cmd+click to open link` : originalTitle;\n canvas.style.cursor = uri && modifiers && (modifiers.ctrlKey || modifiers.metaKey) &&\n !modifiers.altKey && !modifiers.shiftKey ? \"pointer\" : originalCursor;\n if (hyperlinkClick && hyperlinkClick.uri !== uri) hyperlinkClick.dragged = true;\n }\n\n function updateAutoScroll() {\n clearTimeout(autoScroll);\n autoScroll = undefined;\n if (gesture.owner !== \"local\" || !pointerEvent) return;\n const bounds = canvas.getBoundingClientRect();\n const distance = pointerEvent.clientY < bounds.top ? pointerEvent.clientY - bounds.top\n : pointerEvent.clientY >= bounds.bottom ? pointerEvent.clientY - bounds.bottom + 1 : 0;\n if (!distance) return;\n autoScroll = setTimeout(() => {\n autoScroll = undefined;\n if (!pointerEvent) return;\n const position = point(pointerEvent, true);\n if (gesture.owner === \"local\" && position) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n const lines = Math.sign(distance) * Math.min(8, Math.max(1, Math.ceil(Math.abs(distance) / (bounds.height / rows))));\n inspection.scroll?.(lines, gesture.scrollPoint(position));\n updateAutoScroll();\n }\n }, 60);\n }\n\n function flushMove() {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n if (pendingMove) send(pendingMove);\n pendingMove = undefined;\n }\n\n function changeButtons(event: PointerEvent, position: CellPosition): void {\n for (const [button, mask] of buttons) {\n const down = (event.buttons & mask) !== 0;\n if (down === pressed.has(button)) continue;\n flushMove();\n if (down) pressed.add(button);\n else pressed.delete(button);\n if (down || tracking !== 9)\n send({ type: \"mouse\", action: down ? \"down\" : \"up\", button, ...position });\n lastMove = undefined;\n }\n }\n\n function cancel(report = true, cancelled = true) {\n inspection.end?.(cancelled);\n clearTimeout(autoScroll);\n autoScroll = undefined;\n pointerEvent = undefined;\n selectionClick = undefined;\n completedClick = undefined;\n hyperlinkClick = undefined;\n gesture.end();\n routedGesture = undefined;\n if (report) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (report && tracking !== 9 && lastPoint) {\n for (const button of pressed)\n send({ type: \"mouse\", action: \"up\", button, ...lastPoint });\n }\n pressed.clear();\n const captured = pointerId;\n pointerId = null;\n if (captured !== null && canvas.hasPointerCapture(captured))\n canvas.releasePointerCapture(captured);\n lastMove = undefined;\n wheel.reset();\n wheelOwner = undefined;\n }\n\n canvas.addEventListener(\"pointerdown\", event => {\n if (event.defaultPrevented && pointerId === null) return;\n if (event.pointerType !== \"mouse\" || ![0, 1, 2].includes(event.button)) return;\n const position = point(event);\n if (!position) return;\n if (pointerId !== null) {\n if (hyperlinkClick) hyperlinkClick.dragged = true;\n if (routedGesture !== InputRoute.Browser) event.preventDefault();\n if ((gesture.owner === \"app\" || routedGesture === InputRoute.Application) && pointerId === event.pointerId)\n changeButtons(event, position);\n return;\n }\n focus();\n const input: TerminalInput = { type: \"pointer\", button: pointerButtons[event.button],\n point: Object.freeze({ ...position }), ...inputModifiers(event) };\n const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue };\n let route = decision.action !== undefined ? InputRoute.Consume : decision.route;\n if (route === InputRoute.Continue && event.button === 0 && (event.ctrlKey || event.metaKey) &&\n !event.altKey && !event.shiftKey) {\n const uri = inspection.hyperlink?.(position);\n if (uri) {\n hyperlinkClick = { uri, x: event.clientX, y: event.clientY, dragged: false };\n route = InputRoute.Consume;\n }\n }\n contextMenuRoute = event.button === 2 ? route : undefined;\n if (hyperlinkClick) contextMenuRoute = InputRoute.Consume;\n hoverEvent = event;\n refreshHover(event);\n if (route !== InputRoute.Continue) {\n completedClick = selectionClick = undefined;\n click.count = 0;\n routedGesture = route;\n pointerId = event.pointerId;\n lastPoint = position;\n canvas.setPointerCapture(pointerId);\n if (route !== InputRoute.Browser) event.preventDefault();\n if (route === InputRoute.Application) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (route === InputRoute.Application) changeButtons(event, position);\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Keep focus on the hidden keyboard input instead of the canvas.\n event.preventDefault();\n if (event.metaKey) return;\n const now = performance.now();\n click.count = now - click.time < 500 && position.x === click.x && position.y === click.y\n ? click.count % 3 + 1 : 1;\n click = { count: click.count, time: now, x: position.x, y: position.y };\n const start = gesture.begin({ button: event.button, shiftKey: event.shiftKey, altKey: event.altKey,\n detail: event.detail || click.count }, position, state());\n if (!start) return;\n pointerId = event.pointerId;\n pointerEvent = event;\n lastPoint = position;\n completedClick = undefined;\n canvas.setPointerCapture(pointerId);\n if (start.owner === \"local\") {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n lastMove = undefined;\n selectionClick = { point: position, mode: start.mode, extend: start.extend, dragged: false };\n inspection.begin?.(position, start);\n }\n else changeButtons(event, position);\n }, options);\n\n canvas.addEventListener(\"pointermove\", event => {\n if (event.pointerType !== \"mouse\") return;\n if (pointerId === null && event.buttons) return;\n if (pointerId !== null && pointerId !== event.pointerId) return;\n hoverEvent = event;\n refreshHover(event);\n if (hyperlinkClick && Math.hypot(event.clientX - hyperlinkClick.x, event.clientY - hyperlinkClick.y) >= 4)\n hyperlinkClick.dragged = true;\n const position = point(event, pointerId !== null);\n if (!position) return;\n lastPoint = position;\n if (routedGesture && routedGesture !== InputRoute.Application) return;\n if (gesture.owner === \"local\") {\n pointerEvent = event;\n const previous = gesture.endpoint;\n const endpoint = gesture.move(position);\n if (endpoint && previous && (endpoint.x !== previous.x || endpoint.y !== previous.y)) {\n click.count = 0;\n if (selectionClick) selectionClick.dragged = true;\n inspection.extend?.(endpoint);\n }\n updateAutoScroll();\n return;\n }\n if (!tracking || (!routedGesture && gesture.owner === null && (state().historical || state().readOnly || event.shiftKey))) return;\n if (pointerId === event.pointerId) changeButtons(event, position);\n if (event.metaKey || (tracking !== 1003 && !(tracking === 1002 && pressed.size))) return;\n const button = buttons.find(([name]) => pressed.has(name))?.[0] ?? \"none\";\n const move: MouseCommand = { type: \"mouse\", action: \"move\", button, ...position };\n const key = JSON.stringify(move);\n if (key === lastMove) return;\n lastMove = key;\n pendingMove = move;\n if (scheduled === undefined) scheduled = requestAnimationFrame(flushMove);\n }, options);\n\n canvas.addEventListener(\"pointerup\", event => {\n if (pointerId !== event.pointerId) return;\n if (hyperlinkClick) {\n event.preventDefault();\n const link = hyperlinkClick;\n const position = point(event);\n const activate = event.button === 0 && event.buttons === 0 && !link.dragged &&\n Math.hypot(event.clientX - link.x, event.clientY - link.y) < 4 &&\n position && inspection.hyperlink?.(position) === link.uri;\n if (event.buttons === 0) cancel(false, false);\n else link.dragged = true;\n if (activate) inspection.openHyperlink?.(link.uri);\n return;\n }\n const position = point(event, true) ?? lastPoint;\n if (routedGesture) {\n if (routedGesture === InputRoute.Application && position) changeButtons(event, position);\n if (event.buttons === 0) cancel(routedGesture === InputRoute.Application);\n return;\n }\n if (gesture.owner === \"local\") {\n if (position && gesture.endpoint && (position.x !== gesture.endpoint.x || position.y !== gesture.endpoint.y)) {\n if (selectionClick) selectionClick.dragged = true;\n const endpoint = gesture.move(position);\n if (endpoint) inspection.extend?.(endpoint);\n }\n const completed = selectionClick;\n cancel(false, false);\n completedClick = completed;\n return;\n }\n if (position) changeButtons(event, position);\n if (!pressed.size) cancel();\n }, options);\n canvas.addEventListener(\"click\", event => {\n const completed = completedClick;\n completedClick = undefined;\n if (!completed || completed.dragged || !Number.isInteger(event.detail) || event.detail < 1) return;\n click.count = event.detail;\n // Some browsers expose native multiclick counts only on click, not pointerdown.\n const mode = event.detail >= 3 ? \"line\" : event.detail === 2 ? \"word\" : \"character\";\n if (!completed.extend && completed.mode !== \"rectangle\" && mode !== completed.mode)\n inspection.begin?.(completed.point, { mode, extend: false });\n }, options);\n canvas.addEventListener(\"pointercancel\", () => cancel(), options);\n canvas.addEventListener(\"pointerleave\", () => {\n hoverEvent = undefined;\n refreshHover();\n }, options);\n canvas.addEventListener(\"lostpointercapture\", () => {\n if (pointerId !== null) cancel();\n }, options);\n window.addEventListener(\"blur\", () => {\n cancel();\n hoverEvent = undefined;\n refreshHover();\n }, options);\n window.addEventListener(\"keydown\", event => refreshHover(event), { ...options, capture: true });\n window.addEventListener(\"keyup\", event => refreshHover(event), { ...options, capture: true });\n canvas.addEventListener(\"contextmenu\", event => {\n if (contextMenuRoute !== undefined && contextMenuRoute !== InputRoute.Continue) {\n const route = contextMenuRoute;\n contextMenuRoute = undefined;\n if (route !== InputRoute.Browser) event.preventDefault();\n return;\n }\n if (tracking || gesture.owner === \"local\") event.preventDefault();\n }, options);\n canvas.addEventListener(\"wheel\", event => {\n if (hyperlinkClick) hyperlinkClick.dragged = true;\n const input: TerminalInput = { type: \"wheel\", deltaX: event.deltaX, deltaY: event.deltaY,\n deltaMode: event.deltaMode, point: point(event), ...inputModifiers(event) };\n const decision: InputDecision = pointerId === null\n ? inspection.resolve?.(input) ?? { route: InputRoute.Continue }\n : { route: routedGesture ?? InputRoute.Continue };\n if (decision.route === InputRoute.Browser) return;\n if (decision.action !== undefined || decision.route === InputRoute.Consume) {\n event.preventDefault();\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Ctrl+wheel (including trackpad pinch) remains browser zoom.\n if (decision.route !== InputRoute.Application && (event.ctrlKey || event.metaKey)) return;\n const owner = decision.route === InputRoute.Application ? \"app\" : gesture.wheelOwner(event, state());\n if (owner === \"app\" && tracking === 9) return;\n const position = point(gesture.owner === \"local\" && pointerEvent ? pointerEvent : event, gesture.owner === \"local\");\n if (!position) return;\n event.preventDefault();\n flushMove();\n const bounds = canvas.getBoundingClientRect();\n if (wheelOwner !== owner) wheel.reset();\n wheelOwner = owner;\n const { x: horizontal, y: vertical } = wheel.take(event, bounds, rows);\n if (owner === \"local\") {\n if (vertical) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n inspection.scroll?.(vertical, gesture.scrollPoint(position));\n }\n return;\n }\n const wheelDirections: [number, MouseButton, MouseButton][] = [\n [vertical, \"wheelUp\", \"wheelDown\"],\n [horizontal, \"wheelLeft\", \"wheelRight\"]\n ];\n for (const [steps, negative, positive] of wheelDirections) {\n if (steps) send({\n type: \"mouse\", action: \"wheel\", button: steps < 0 ? negative : positive,\n count: Math.min(32, Math.abs(steps)), ...position\n });\n }\n }, { ...options, passive: false });\n\n return {\n update(nextColumns, nextRows, nextTracking) {\n if (columns !== nextColumns || rows !== nextRows || tracking !== nextTracking) {\n if (lastPoint) {\n lastPoint = { ...lastPoint,\n x: Math.min(lastPoint.x, nextColumns - 1),\n y: Math.min(lastPoint.y, nextRows - 1)\n };\n }\n // Application mode changes do not transfer ownership mid-gesture.\n if (columns !== nextColumns || rows !== nextRows) cancel(nextTracking !== 0);\n columns = nextColumns;\n rows = nextRows;\n tracking = nextTracking;\n }\n refreshHover();\n },\n refresh() { refreshHover(); },\n cancel() { cancel(); },\n dispose() {\n cancel(false);\n listeners.abort();\n canvas.title = originalTitle;\n canvas.style.cursor = originalCursor;\n }\n };\n}\n"]} \ No newline at end of file +{"version":3,"file":"mouse-input.js","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"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","sourcesContent":["import { cellPoint, WheelAccumulator, SelectionGesture } from \"./selection-input.js\";\nimport { InputRoute, inputModifiers } from \"./input-policy.js\";\nimport type { GestureState } from \"./selection-input.js\";\nimport type { InputDecision, InputRouteValue, MouseTrackingMode, PointerButton, SelectionMode,\n TerminalInput, TerminalPoint } from \"./types.js\";\nimport type { CellPosition, MouseButton, MouseCommand } from \"./wire-types.js\";\n\nconst buttons: readonly (readonly [PointerButton, number])[] = [[\"left\", 1], [\"middle\", 4], [\"right\", 2]];\nconst pointerButtons: readonly PointerButton[] = [\"left\", \"middle\", \"right\"];\ninterface SelectionClick { point: TerminalPoint; mode: SelectionMode; extend: boolean; dragged: boolean }\nexport interface PointerHyperlink {\n id: string;\n target: string;\n activation?: \"modifierClick\" | \"click\";\n}\ninterface HyperlinkClick { link: PointerHyperlink; input: TerminalInput; x: number; y: number; dragged: boolean }\ninterface MouseInspection {\n state?: () => Omit;\n begin?: (point: TerminalPoint, selection: { mode: SelectionMode; extend: boolean }) => void;\n extend?: (point: TerminalPoint) => void;\n scroll?: (delta: number, endpoint?: TerminalPoint) => void;\n end?: (cancelled: boolean) => void;\n resolve?: (input: TerminalInput) => InputDecision;\n execute?: (decision: Extract, input: TerminalInput) => void;\n hyperlink?: (point: TerminalPoint) => PointerHyperlink | null;\n hoverHyperlink?: (link: PointerHyperlink | null) => void;\n openHyperlink?: (link: PointerHyperlink, input: TerminalInput) => void;\n}\nexport interface MouseCapture {\n update(columns: number, rows: number, tracking: MouseTrackingMode): void;\n refresh(): void;\n cancel(): void;\n dispose(): void;\n}\n\n/** Capture input intent only; the server chooses and encodes the mouse protocol. */\nexport function captureMouse(canvas: HTMLCanvasElement, send: (command: MouseCommand) => void,\n focus: () => void, inspection: MouseInspection = {}): MouseCapture {\n const listeners = new AbortController();\n const options = { signal: listeners.signal };\n let columns = 1, rows = 1;\n let tracking: MouseTrackingMode = 0;\n let pointerId: number | null = null;\n const pressed = new Set();\n let lastPoint: CellPosition | undefined;\n let pendingMove: MouseCommand | undefined;\n let lastMove: string | undefined;\n let scheduled: number | undefined;\n const wheel = new WheelAccumulator();\n const gesture = new SelectionGesture();\n let wheelOwner: \"local\" | \"app\" | undefined;\n let pointerEvent: PointerEvent | undefined;\n let autoScroll: ReturnType | undefined;\n let click = { count: 0, time: 0, x: -1, y: -1 };\n let selectionClick: SelectionClick | undefined;\n let completedClick: SelectionClick | undefined;\n let routedGesture: InputRouteValue | undefined;\n let contextMenuRoute: InputRouteValue | undefined;\n let hyperlinkClick: HyperlinkClick | undefined;\n let hoverEvent: PointerEvent | undefined;\n let hoverModifiers: Pick | undefined;\n let hoveredLinkId: string | undefined;\n const originalTitle = canvas.title;\n const originalCursor = canvas.style.cursor;\n const state = () => ({ tracking, ...inspection.state?.() });\n\n function point(event: MouseEvent, clamp = false): CellPosition | null {\n return cellPoint(event, canvas.getBoundingClientRect(), columns, rows, clamp);\n }\n\n function refreshHover(modifiers = hoverModifiers) {\n if (!inspection.hyperlink) return;\n hoverModifiers = modifiers;\n const position = hoverEvent && point(hoverEvent);\n const link = position ? inspection.hyperlink(position) : null;\n canvas.title = link ? `${link.target}\\n${link.activation === \"click\" ? \"Click\" : \"Ctrl/Cmd+click\"} to activate link` : originalTitle;\n canvas.style.cursor = link && modifiers && (link.activation === \"click\" || modifiers.ctrlKey || modifiers.metaKey) &&\n !modifiers.altKey && !modifiers.shiftKey ? \"pointer\" : originalCursor;\n if (hyperlinkClick && hyperlinkClick.link.id !== link?.id) hyperlinkClick.dragged = true;\n if (hoveredLinkId !== link?.id) {\n hoveredLinkId = link?.id;\n inspection.hoverHyperlink?.(link);\n }\n }\n\n function updateAutoScroll() {\n clearTimeout(autoScroll);\n autoScroll = undefined;\n if (gesture.owner !== \"local\" || !pointerEvent) return;\n const bounds = canvas.getBoundingClientRect();\n const distance = pointerEvent.clientY < bounds.top ? pointerEvent.clientY - bounds.top\n : pointerEvent.clientY >= bounds.bottom ? pointerEvent.clientY - bounds.bottom + 1 : 0;\n if (!distance) return;\n autoScroll = setTimeout(() => {\n autoScroll = undefined;\n if (!pointerEvent) return;\n const position = point(pointerEvent, true);\n if (gesture.owner === \"local\" && position) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n const lines = Math.sign(distance) * Math.min(8, Math.max(1, Math.ceil(Math.abs(distance) / (bounds.height / rows))));\n inspection.scroll?.(lines, gesture.scrollPoint(position));\n updateAutoScroll();\n }\n }, 60);\n }\n\n function flushMove() {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n if (pendingMove) send(pendingMove);\n pendingMove = undefined;\n }\n\n function changeButtons(event: PointerEvent, position: CellPosition): void {\n for (const [button, mask] of buttons) {\n const down = (event.buttons & mask) !== 0;\n if (down === pressed.has(button)) continue;\n flushMove();\n if (down) pressed.add(button);\n else pressed.delete(button);\n if (down || tracking !== 9)\n send({ type: \"mouse\", action: down ? \"down\" : \"up\", button, ...position });\n lastMove = undefined;\n }\n }\n\n function cancel(report = true, cancelled = true) {\n inspection.end?.(cancelled);\n clearTimeout(autoScroll);\n autoScroll = undefined;\n pointerEvent = undefined;\n selectionClick = undefined;\n completedClick = undefined;\n hyperlinkClick = undefined;\n gesture.end();\n routedGesture = undefined;\n if (report) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (report && tracking !== 9 && lastPoint) {\n for (const button of pressed)\n send({ type: \"mouse\", action: \"up\", button, ...lastPoint });\n }\n pressed.clear();\n const captured = pointerId;\n pointerId = null;\n if (captured !== null && canvas.hasPointerCapture(captured))\n canvas.releasePointerCapture(captured);\n lastMove = undefined;\n wheel.reset();\n wheelOwner = undefined;\n }\n\n canvas.addEventListener(\"pointerdown\", event => {\n if (event.defaultPrevented && pointerId === null) return;\n if (event.pointerType !== \"mouse\" || ![0, 1, 2].includes(event.button)) return;\n const position = point(event);\n if (!position) return;\n if (pointerId !== null) {\n if (hyperlinkClick) hyperlinkClick.dragged = true;\n if (routedGesture !== InputRoute.Browser) event.preventDefault();\n if ((gesture.owner === \"app\" || routedGesture === InputRoute.Application) && pointerId === event.pointerId)\n changeButtons(event, position);\n return;\n }\n focus();\n const input: TerminalInput = { type: \"pointer\", button: pointerButtons[event.button],\n point: Object.freeze({ ...position }), ...inputModifiers(event) };\n const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue };\n let route = decision.action !== undefined ? InputRoute.Consume : decision.route;\n if (route === InputRoute.Continue && event.button === 0 && !event.altKey && !event.shiftKey) {\n const link = inspection.hyperlink?.(position);\n if (link && (link.activation === \"click\" || event.ctrlKey || event.metaKey)) {\n hyperlinkClick = { link, input, x: event.clientX, y: event.clientY, dragged: false };\n route = InputRoute.Consume;\n }\n }\n contextMenuRoute = event.button === 2 ? route : undefined;\n if (hyperlinkClick) contextMenuRoute = InputRoute.Consume;\n hoverEvent = event;\n refreshHover(event);\n if (route !== InputRoute.Continue) {\n completedClick = selectionClick = undefined;\n click.count = 0;\n routedGesture = route;\n pointerId = event.pointerId;\n lastPoint = position;\n canvas.setPointerCapture(pointerId);\n if (route !== InputRoute.Browser) event.preventDefault();\n if (route === InputRoute.Application) flushMove();\n else {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n }\n if (route === InputRoute.Application) changeButtons(event, position);\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Keep focus on the hidden keyboard input instead of the canvas.\n event.preventDefault();\n if (event.metaKey) return;\n const now = performance.now();\n click.count = now - click.time < 500 && position.x === click.x && position.y === click.y\n ? click.count % 3 + 1 : 1;\n click = { count: click.count, time: now, x: position.x, y: position.y };\n const start = gesture.begin({ button: event.button, shiftKey: event.shiftKey, altKey: event.altKey,\n detail: event.detail || click.count }, position, state());\n if (!start) return;\n pointerId = event.pointerId;\n pointerEvent = event;\n lastPoint = position;\n completedClick = undefined;\n canvas.setPointerCapture(pointerId);\n if (start.owner === \"local\") {\n if (scheduled !== undefined) cancelAnimationFrame(scheduled);\n scheduled = undefined;\n pendingMove = undefined;\n lastMove = undefined;\n selectionClick = { point: position, mode: start.mode, extend: start.extend, dragged: false };\n inspection.begin?.(position, start);\n }\n else changeButtons(event, position);\n }, options);\n\n canvas.addEventListener(\"pointermove\", event => {\n if (event.pointerType !== \"mouse\") return;\n if (pointerId === null && event.buttons) return;\n if (pointerId !== null && pointerId !== event.pointerId) return;\n hoverEvent = event;\n refreshHover(event);\n if (hyperlinkClick && Math.hypot(event.clientX - hyperlinkClick.x, event.clientY - hyperlinkClick.y) >= 4)\n hyperlinkClick.dragged = true;\n const position = point(event, pointerId !== null);\n if (!position) return;\n lastPoint = position;\n if (routedGesture && routedGesture !== InputRoute.Application) return;\n if (gesture.owner === \"local\") {\n pointerEvent = event;\n const previous = gesture.endpoint;\n const endpoint = gesture.move(position);\n if (endpoint && previous && (endpoint.x !== previous.x || endpoint.y !== previous.y)) {\n click.count = 0;\n if (selectionClick) selectionClick.dragged = true;\n inspection.extend?.(endpoint);\n }\n updateAutoScroll();\n return;\n }\n if (!tracking || (!routedGesture && gesture.owner === null && (state().historical || state().readOnly || event.shiftKey))) return;\n if (pointerId === event.pointerId) changeButtons(event, position);\n if (event.metaKey || (tracking !== 1003 && !(tracking === 1002 && pressed.size))) return;\n const button = buttons.find(([name]) => pressed.has(name))?.[0] ?? \"none\";\n const move: MouseCommand = { type: \"mouse\", action: \"move\", button, ...position };\n const key = JSON.stringify(move);\n if (key === lastMove) return;\n lastMove = key;\n pendingMove = move;\n if (scheduled === undefined) scheduled = requestAnimationFrame(flushMove);\n }, options);\n\n canvas.addEventListener(\"pointerup\", event => {\n if (pointerId !== event.pointerId) return;\n if (hyperlinkClick) {\n event.preventDefault();\n const link = hyperlinkClick;\n const position = point(event);\n const activate = event.button === 0 && event.buttons === 0 && !link.dragged &&\n Math.hypot(event.clientX - link.x, event.clientY - link.y) < 4 &&\n position && inspection.hyperlink?.(position)?.id === link.link.id;\n if (event.buttons === 0) cancel(false, false);\n else link.dragged = true;\n if (activate) inspection.openHyperlink?.(link.link, link.input);\n return;\n }\n const position = point(event, true) ?? lastPoint;\n if (routedGesture) {\n if (routedGesture === InputRoute.Application && position) changeButtons(event, position);\n if (event.buttons === 0) cancel(routedGesture === InputRoute.Application);\n return;\n }\n if (gesture.owner === \"local\") {\n if (position && gesture.endpoint && (position.x !== gesture.endpoint.x || position.y !== gesture.endpoint.y)) {\n if (selectionClick) selectionClick.dragged = true;\n const endpoint = gesture.move(position);\n if (endpoint) inspection.extend?.(endpoint);\n }\n const completed = selectionClick;\n cancel(false, false);\n completedClick = completed;\n return;\n }\n if (position) changeButtons(event, position);\n if (!pressed.size) cancel();\n }, options);\n canvas.addEventListener(\"click\", event => {\n const completed = completedClick;\n completedClick = undefined;\n if (!completed || completed.dragged || !Number.isInteger(event.detail) || event.detail < 1) return;\n click.count = event.detail;\n // Some browsers expose native multiclick counts only on click, not pointerdown.\n const mode = event.detail >= 3 ? \"line\" : event.detail === 2 ? \"word\" : \"character\";\n if (!completed.extend && completed.mode !== \"rectangle\" && mode !== completed.mode)\n inspection.begin?.(completed.point, { mode, extend: false });\n }, options);\n canvas.addEventListener(\"pointercancel\", () => cancel(), options);\n canvas.addEventListener(\"pointerleave\", () => {\n hoverEvent = undefined;\n refreshHover();\n }, options);\n canvas.addEventListener(\"lostpointercapture\", () => {\n if (pointerId !== null) cancel();\n }, options);\n window.addEventListener(\"blur\", () => {\n cancel();\n hoverEvent = undefined;\n refreshHover();\n }, options);\n window.addEventListener(\"keydown\", event => refreshHover(event), { ...options, capture: true });\n window.addEventListener(\"keyup\", event => refreshHover(event), { ...options, capture: true });\n canvas.addEventListener(\"contextmenu\", event => {\n if (contextMenuRoute !== undefined && contextMenuRoute !== InputRoute.Continue) {\n const route = contextMenuRoute;\n contextMenuRoute = undefined;\n if (route !== InputRoute.Browser) event.preventDefault();\n return;\n }\n if (tracking || gesture.owner === \"local\") event.preventDefault();\n }, options);\n canvas.addEventListener(\"wheel\", event => {\n if (hyperlinkClick) hyperlinkClick.dragged = true;\n const input: TerminalInput = { type: \"wheel\", deltaX: event.deltaX, deltaY: event.deltaY,\n deltaMode: event.deltaMode, point: point(event), ...inputModifiers(event) };\n const decision: InputDecision = pointerId === null\n ? inspection.resolve?.(input) ?? { route: InputRoute.Continue }\n : { route: routedGesture ?? InputRoute.Continue };\n if (decision.route === InputRoute.Browser) return;\n if (decision.action !== undefined || decision.route === InputRoute.Consume) {\n event.preventDefault();\n if (decision.action !== undefined) inspection.execute?.(decision, input);\n return;\n }\n // Ctrl+wheel (including trackpad pinch) remains browser zoom.\n if (decision.route !== InputRoute.Application && (event.ctrlKey || event.metaKey)) return;\n const owner = decision.route === InputRoute.Application ? \"app\" : gesture.wheelOwner(event, state());\n if (owner === \"app\" && tracking === 9) return;\n const position = point(gesture.owner === \"local\" && pointerEvent ? pointerEvent : event, gesture.owner === \"local\");\n if (!position) return;\n event.preventDefault();\n flushMove();\n const bounds = canvas.getBoundingClientRect();\n if (wheelOwner !== owner) wheel.reset();\n wheelOwner = owner;\n const { x: horizontal, y: vertical } = wheel.take(event, bounds, rows);\n if (owner === \"local\") {\n if (vertical) {\n if (selectionClick) selectionClick.dragged = true;\n click.count = 0;\n inspection.scroll?.(vertical, gesture.scrollPoint(position));\n }\n return;\n }\n const wheelDirections: [number, MouseButton, MouseButton][] = [\n [vertical, \"wheelUp\", \"wheelDown\"],\n [horizontal, \"wheelLeft\", \"wheelRight\"]\n ];\n for (const [steps, negative, positive] of wheelDirections) {\n if (steps) send({\n type: \"mouse\", action: \"wheel\", button: steps < 0 ? negative : positive,\n count: Math.min(32, Math.abs(steps)), ...position\n });\n }\n }, { ...options, passive: false });\n\n return {\n update(nextColumns, nextRows, nextTracking) {\n if (columns !== nextColumns || rows !== nextRows || tracking !== nextTracking) {\n if (lastPoint) {\n lastPoint = { ...lastPoint,\n x: Math.min(lastPoint.x, nextColumns - 1),\n y: Math.min(lastPoint.y, nextRows - 1)\n };\n }\n // Application mode changes do not transfer ownership mid-gesture.\n if (columns !== nextColumns || rows !== nextRows) cancel(nextTracking !== 0);\n columns = nextColumns;\n rows = nextRows;\n tracking = nextTracking;\n }\n refreshHover();\n },\n refresh() { refreshHover(); },\n cancel() { cancel(); },\n dispose() {\n cancel(false);\n listeners.abort();\n canvas.title = originalTitle;\n canvas.style.cursor = originalCursor;\n }\n };\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts index 6b36ef276c1..fb91da7fae3 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts @@ -71,7 +71,8 @@ export declare class TerminalRenderer { solid(x: number, y: number, width: number, height: number, color: Vector4): void; placement(placement: ImagePlacement): void; decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void; - render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean): { + private underline; + render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean, linkDecorations?: Uint8Array): { cpuMs: number; quads: number; drawCalls: number; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map index e829f798942..65fbe6f3491 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js index e5b2b9569a3..7c1131e96bc 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js @@ -344,7 +344,9 @@ export class TerminalRenderer { if (cell.attributes & 256) this.solid(x, y + 1, width, 1, foreground); const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0); - const color = rgba(cell.underlineColor); + this.underline(style, x, y, width, rgba(cell.underlineColor)); + } + underline(style, x, y, width, color) { if (style === 1) this.solid(x, y + 18, width, 1, color); else if (style === 2) { @@ -363,7 +365,7 @@ export class TerminalRenderer { this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color); } } - render(cells, metadata, blinkOn) { + render(cells, metadata, blinkOn, linkDecorations) { const start = performance.now(); this.quadCount = 0; this.batches = []; @@ -399,6 +401,9 @@ export class TerminalRenderer { } // Reverse and dim are already reflected in server-projected colors. this.decorations(cell, x, y, width, foreground); + if (linkDecorations?.[i] && !cell.underlineStyle && !(cell.attributes & 8) && !isKgpPlaceholder(cell)) { + this.underline(linkDecorations[i], x, y, width, foreground); + } } for (const item of placements) if (item.z >= 0) diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map index 886bb77d7d5..124d22db1df 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/renderer.js.map @@ -1 +1 @@ -{"version":3,"file":"renderer.js","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"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","sourcesContent":["import { LIMITS } from \"./protocol.js\";\nimport { loadFont, measureFont, normalizeFont } from \"./terminal-font.js\";\nimport { createRenderBackend } from \"./backend-selection.js\";\nimport { QUAD_STRIDE } from \"./render-backend.js\";\nimport type { RenderBackend, RenderBatch, RenderColor, RenderTexture } from \"./render-backend.js\";\nimport type { FontMetrics, LoadedFont, NormalizedFont } from \"./terminal-font.js\";\nimport type { TerminalFont, TerminalRendererPreference, TerminalSize } from \"./types.js\";\nimport type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from \"./wire-types.js\";\n\ntype Vector4 = RenderColor;\ntype TextureResource = RenderTexture;\ninterface Shelf { x: number; y: number; rowHeight: number }\ninterface GlyphPlacement { key: string; cell: TerminalCell; x: number; y: number; width: number; height: number }\ninterface Glyph { colored: boolean; u0: number; v0: number; u1: number; v1: number }\ntype Batch = RenderBatch;\n\nconst CELL_WIDTH = 10;\nconst CELL_HEIGHT = 20;\nconst MAX_QUADS = 1024 * 1024;\nconst MAX_GLYPHS = 16384;\nconst MAX_GLYPH_KEY_UNITS = 1024 * 1024;\nconst STRIDE = QUAD_STRIDE;\nconst WHITE: Vector4 = [1, 1, 1, 1];\n\nfunction rgba(packed: number): Vector4 {\n return [\n (packed & 255) / 255,\n ((packed >>> 8) & 255) / 255,\n ((packed >>> 16) & 255) / 255,\n ((packed >>> 24) & 255) / 255,\n ];\n}\n\nfunction glyphKey(cell: TerminalCell): string {\n return `${cell.attributes & 5}/${cell.width}/${cell.text}`;\n}\n\nfunction isKgpPlaceholder(cell: TerminalCell): boolean {\n // The base scalar and following diacritics encode an image reference, not a glyph.\n // Keep the authoritative text and colors intact even when no image is placed.\n return cell.text.codePointAt(0) === 0x10eeee;\n}\n\n/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */\nfunction packGlyphs(glyphs: ReadonlyMap, size: number, scale: number,\n initial: Shelf = { x: 0, y: 0, rowHeight: 0 }): { placements: GlyphPlacement[]; shelf: Shelf } | null {\n let { x, y, rowHeight } = initial;\n const placements = [];\n for (const [key, cell] of glyphs) {\n const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4;\n const height = Math.ceil(CELL_HEIGHT * scale) + 4;\n if (width > size || height > size) return null;\n if (x + width > size) { x = 0; y += rowHeight; rowHeight = 0; }\n if (y + height > size) return null;\n placements.push({ key, cell, x, y, width, height });\n x += width;\n rowHeight = Math.max(rowHeight, height);\n }\n return { placements, shelf: { x, y, rowHeight } };\n}\n\n/** Shared instanced-quad preparation; Canvas2D rasterizes reusable glyphs for either backend. */\nexport class TerminalRenderer {\n canvas: OffscreenCanvas;\n scale: number;\n backingScale: number;\n backend: RenderBackend;\n fallbackReason?: string;\n fontConfiguration: NormalizedFont;\n fontMetrics: Map;\n images: Map;\n glyphs: Map;\n imageUploadBytes: number;\n imagePayloadBytes: number;\n glyphUploadBytes: number;\n atlasRebuilds: number;\n textureBytes: number;\n instances: Float32Array;\n disposed: boolean;\n columns: number;\n rows: number;\n // Initialized by create() before the renderer can prepare or submit frames.\n font!: LoadedFont;\n rasterCanvas!: OffscreenCanvas;\n raster!: OffscreenCanvasRenderingContext2D;\n atlas!: TextureResource;\n glyphKeyUnits = 0;\n shelf: Shelf = { x: 0, y: 0, rowHeight: 0 };\n canvasLimited = false;\n width = 0;\n height = 0;\n quadCount = 0;\n batches: Batch[] = [];\n\n static async create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error) => void,\n font?: TerminalFont, preference: TerminalRendererPreference = \"auto\"): Promise {\n const normalizedFont = normalizeFont(font);\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n const { backend, fallbackReason } = await createRenderBackend(canvas, onFatal, preference);\n const renderer = new TerminalRenderer(canvas, scale, backend, normalizedFont);\n renderer.fallbackReason = fallbackReason;\n try {\n await renderer.initialize();\n return renderer;\n } catch (error) {\n renderer.dispose();\n throw error;\n }\n }\n\n constructor(canvas: OffscreenCanvas, scale: number, backend: RenderBackend, font: NormalizedFont) {\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n this.canvas = canvas;\n this.scale = scale;\n this.backingScale = scale;\n this.backend = backend;\n this.fontConfiguration = font;\n this.fontMetrics = new Map();\n this.images = new Map();\n this.glyphs = new Map();\n this.imageUploadBytes = 0;\n this.imagePayloadBytes = 0;\n this.glyphUploadBytes = 0;\n this.atlasRebuilds = 0;\n this.textureBytes = 0;\n this.instances = new Float32Array(4096 * STRIDE);\n this.disposed = false;\n this.columns = 0;\n this.rows = 0;\n }\n\n async initialize() {\n this.font = await loadFont(this.fontConfiguration);\n this.rasterCanvas = new OffscreenCanvas(1, 1);\n const raster = this.rasterCanvas.getContext(\"2d\", { willReadFrequently: true });\n if (!raster) throw new Error(\"Worker glyph rasterization is unavailable\");\n this.raster = raster;\n this.resetAtlas(Math.min(2048, this.backend.maxTextureDimension2D));\n }\n\n createTexture(width: number, height: number, label: string): TextureResource {\n return this.backend.createTexture(width, height, label);\n }\n\n resetAtlas(size: number): void {\n this.atlas?.destroy();\n this.atlas = this.createTexture(size, size, \"Glyph atlas\");\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.shelf = { x: 0, y: 0, rowHeight: 0 };\n }\n\n resize(columns: number, rows: number, viewport?: TerminalSize): void {\n const width = columns * CELL_WIDTH;\n const height = rows * CELL_HEIGHT;\n const limit = this.backend.maxCanvasDimension2D;\n const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity);\n this.canvasLimited = width * requested > limit || height * requested > limit;\n this.backingScale = Math.min(requested, limit / width, limit / height);\n const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale)));\n const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale)));\n if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight) return;\n this.columns = columns;\n this.rows = rows;\n this.width = width;\n this.height = height;\n this.canvas.width = backingWidth;\n this.canvas.height = backingHeight;\n this.backend.resize(width, height);\n }\n\n /** Call only between submissions. Missing/over-budget resources terminate the session. */\n async updateImages(incoming: readonly FrameImage[], retainedKeys: readonly string[]): Promise {\n const retained = new Set(retainedKeys);\n const replacements = new Map(incoming.map(image => [image.key, image]));\n let projectedBytes = 0;\n for (const key of retained) {\n const image = replacements.get(key) || this.images.get(key);\n if (!image) throw new Error(`Missing retained image resource: ${key}`);\n projectedBytes += image.width * image.height * 4;\n }\n if (projectedBytes > LIMITS.textureBytes) throw new Error(\"Retained images exceed the 256 MiB texture budget\");\n for (const [key, image] of this.images) {\n if (!retained.has(key) || replacements.has(key)) {\n image.destroy();\n this.textureBytes -= image.width * image.height * 4;\n this.images.delete(key);\n }\n }\n for (const image of incoming) {\n const resource = this.createTexture(image.width, image.height, `Image ${image.key}`);\n try {\n if (image.format === \"rgba\") {\n resource.writePixels(image.bytes, image.width, image.height);\n } else {\n // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions.\n const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength);\n if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a ||\n png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 ||\n png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) {\n throw new Error(`PNG header dimensions do not match resource ${image.key}`);\n }\n const bitmap = await createImageBitmap(new Blob([image.bytes], { type: \"image/png\" }), {\n premultiplyAlpha: \"none\",\n colorSpaceConversion: \"none\",\n });\n try {\n if (bitmap.width !== image.width || bitmap.height !== image.height) throw new Error(\"Decoded PNG dimension mismatch\");\n resource.writeBitmap(bitmap);\n } finally {\n bitmap.close();\n }\n }\n this.images.set(image.key, resource);\n this.textureBytes += image.width * image.height * 4;\n this.imageUploadBytes += image.width * image.height * 4;\n this.imagePayloadBytes += image.byteLength;\n } catch (error) {\n resource.destroy();\n throw error;\n }\n }\n }\n\n prepareGlyphs(cells: readonly (TerminalCell | undefined)[]): void {\n const visible = new Map();\n for (const cell of cells) {\n if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64) || isKgpPlaceholder(cell)) continue;\n visible.set(glyphKey(cell), cell);\n }\n const keyUnits = (glyphs: ReadonlyMap) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0);\n if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) {\n throw new Error(\"Visible glyph metadata exceeds the bounded glyph cache\");\n }\n const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key)));\n if (!missing.size) return;\n const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS &&\n this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS;\n let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null;\n if (!plan) {\n let size = this.atlas.width;\n const maxSize = Math.min(4096, this.backend.maxTextureDimension2D);\n while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) {\n size = Math.min(size * 2, maxSize);\n }\n if (!plan) throw new Error(\"Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale\");\n this.resetAtlas(size);\n this.atlasRebuilds++;\n }\n for (const placement of plan.placements) this.uploadGlyph(placement);\n this.shelf = plan.shelf;\n }\n\n uploadGlyph({ key, cell, x, y, width, height }: GlyphPlacement): void {\n const scale = this.scale;\n const raster = this.raster;\n const style = cell.attributes & 5;\n let metrics = this.fontMetrics.get(style);\n if (!metrics) {\n metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT);\n this.fontMetrics.set(style, metrics);\n }\n this.rasterCanvas.width = width;\n this.rasterCanvas.height = height;\n raster.font = metrics.font;\n raster.textBaseline = \"alphabetic\";\n raster.textAlign = \"left\";\n raster.fillStyle = \"white\";\n // One transform per font style, not per glyph: borders remain font outlines,\n // and graphemes are clipped to their server-owned span without individual stretching.\n raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline);\n raster.fillText(cell.text, 0, 0);\n const pixels = raster.getImageData(0, 0, width, height);\n let colored = false;\n for (let i = 0; i < pixels.data.length; i += 4) {\n if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) {\n colored = true;\n break;\n }\n }\n this.atlas.writePixels(pixels.data, width, height, x, y);\n this.glyphUploadBytes += width * height * 4;\n this.glyphs.set(key, {\n colored,\n u0: (x + 2) / this.atlas.width,\n v0: (y + 2) / this.atlas.height,\n u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width,\n v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height,\n });\n this.glyphKeyUnits += key.length;\n }\n\n /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */\n quad(resource: TextureResource, x: number, y: number, width: number, height: number,\n color: Vector4, mode = 0, uv: Vector4 = [0, 0, 1, 1],\n clip: Vector4 = [0, 0, this.width, this.height]): void {\n if (width <= 0 || height <= 0 || color[3] <= 0) return;\n const left = Math.max(0, x, clip[0]);\n const top = Math.max(0, y, clip[1]);\n const right = Math.min(this.width, x + width, clip[0] + clip[2]);\n const bottom = Math.min(this.height, y + height, clip[1] + clip[3]);\n if (right <= left || bottom <= top) return;\n if (this.quadCount >= MAX_QUADS) throw new Error(\"Frame exceeds bounded quad budget\");\n const offset = this.quadCount * STRIDE;\n if (offset + STRIDE > this.instances.length) {\n const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE));\n grown.set(this.instances);\n this.instances = grown;\n }\n const du = uv[2] - uv[0];\n const dv = uv[3] - uv[1];\n this.instances.set([\n left, top, right - left, bottom - top,\n uv[0] + (left - x) / width * du,\n uv[1] + (top - y) / height * dv,\n uv[0] + (right - x) / width * du,\n uv[1] + (bottom - y) / height * dv,\n ...color, mode, 0, 0, 0,\n ], offset);\n const last = this.batches[this.batches.length - 1];\n if (last?.resource === resource) last.count++;\n else this.batches.push({ resource, start: this.quadCount, count: 1 });\n this.quadCount++;\n }\n\n solid(x: number, y: number, width: number, height: number, color: Vector4): void {\n this.quad(this.atlas, x, y, width, height, color);\n }\n\n placement(placement: ImagePlacement): void {\n const image = this.images.get(placement.key);\n if (!image) throw new Error(`Placement texture is missing: ${placement.key}`);\n const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement;\n if (!sw || !sh || !placement.width || !placement.height) return;\n // Clip out-of-texture source regions in destination space instead of stretching edge texels.\n const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width;\n const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height;\n const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width;\n const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height;\n const left = Math.max(sourceLeft, placement.clipX);\n const top = Math.max(sourceTop, placement.clipY);\n const right = Math.min(sourceRight, placement.clipX + placement.clipWidth);\n const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight);\n this.quad(\n image, placement.x, placement.y, placement.width, placement.height, WHITE, 2,\n [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height],\n [left, top, Math.max(0, right - left), Math.max(0, bottom - top)],\n );\n }\n\n decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void {\n if (cell.attributes & 128) this.solid(x, y + 10, width, 1, foreground);\n if (cell.attributes & 256) this.solid(x, y + 1, width, 1, foreground);\n const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0);\n const color = rgba(cell.underlineColor);\n if (style === 1) this.solid(x, y + 18, width, 1, color);\n else if (style === 2) {\n this.solid(x, y + 16, width, 1, color);\n this.solid(x, y + 18, width, 1, color);\n } else if (style === 3) {\n for (let dx = 0; dx < width; dx++) {\n this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color);\n }\n } else if (style === 4 || style === 5) {\n const step = style === 4 ? 2 : 5;\n const segment = style === 4 ? 1 : 3;\n for (let dx = 0; dx < width; dx += step) this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color);\n }\n }\n\n render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean) {\n const start = performance.now();\n this.quadCount = 0;\n this.batches = [];\n const placements = metadata.placements.map((placement, order) => ({\n placement,\n order,\n z: placement.kind === \"sixel\" ? -1 : placement.z,\n })).sort((a, b) => a.z - b.z || a.order - b.order);\n for (const item of placements) if (item.z < -1073741824) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell) continue;\n this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background));\n }\n for (const item of placements) if (item.z >= -1073741824 && item.z < 0) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn)) continue;\n const x = (i % this.columns) * CELL_WIDTH;\n const y = Math.floor(i / this.columns) * CELL_HEIGHT;\n const width = Math.min(cell.width * CELL_WIDTH, this.width - x);\n const foreground = rgba(cell.foreground);\n const glyph = isKgpPlaceholder(cell) ? undefined : this.glyphs.get(glyphKey(cell));\n if (glyph) {\n const tint: Vector4 = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground;\n this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT,\n tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]);\n }\n // Reverse and dim are already reflected in server-projected colors.\n this.decorations(cell, x, y, width, foreground);\n }\n for (const item of placements) if (item.z >= 0) this.placement(item.placement);\n const cursor = metadata.cursor;\n const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1;\n if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) {\n const cell = cells[cursor.y * this.columns + cursor.x];\n const color = rgba(cell?.foreground ?? 0xffffffff);\n const x = cursor.x * CELL_WIDTH;\n const y = cursor.y * CELL_HEIGHT;\n if (cursor.shape === 3 || cursor.shape === 4) this.solid(x, y + 18, CELL_WIDTH, 2, color);\n else if (cursor.shape === 5 || cursor.shape === 6) this.solid(x, y, 2, CELL_HEIGHT, color);\n else {\n color[3] *= 0.55;\n this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color);\n }\n }\n const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000);\n this.backend.submit(this.instances, this.quadCount, this.batches, base);\n return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length };\n }\n\n metrics() {\n return {\n renderer: this.backend.kind,\n rendererFallbackReason: this.fallbackReason,\n fontFamily: this.font.family,\n rasterScale: this.scale,\n backingScale: this.backingScale,\n backingWidth: this.canvas.width,\n backingHeight: this.canvas.height,\n imageCount: this.images.size,\n textureBytes: this.textureBytes,\n atlasGlyphs: this.glyphs.size,\n atlasBytes: this.atlas.width * this.atlas.height * 4,\n atlasRebuilds: this.atlasRebuilds,\n imageUploadBytes: this.imageUploadBytes,\n imagePayloadBytes: this.imagePayloadBytes,\n glyphUploadBytes: this.glyphUploadBytes,\n instanceBufferBytes: this.backend.instanceBufferBytes,\n };\n }\n\n async idle() {\n await this.backend.idle();\n }\n\n dispose() {\n if (this.disposed) return;\n this.disposed = true;\n for (const image of this.images.values()) image.destroy();\n this.images.clear();\n this.textureBytes = 0;\n this.atlas?.destroy();\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.batches = [];\n this.quadCount = 0;\n this.instances = new Float32Array(0);\n this.font?.dispose();\n this.fontMetrics.clear();\n this.backend.dispose();\n }\n}\n"]} \ No newline at end of file +{"version":3,"file":"renderer.js","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAalD,MAAM,UAAU,GAAG,EAAE,CAAC;AACtB,MAAM,WAAW,GAAG,EAAE,CAAC;AACvB,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,CAAC;AAC9B,MAAM,UAAU,GAAG,KAAK,CAAC;AACzB,MAAM,mBAAmB,GAAG,IAAI,GAAG,IAAI,CAAC;AACxC,MAAM,MAAM,GAAG,WAAW,CAAC;AAC3B,MAAM,KAAK,GAAY,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AAEpC,SAAS,IAAI,CAAC,MAAc;IAC1B,OAAO;QACL,CAAC,MAAM,GAAG,GAAG,CAAC,GAAG,GAAG;QACpB,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG;QAC5B,CAAC,CAAC,MAAM,KAAK,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG;QAC7B,CAAC,CAAC,MAAM,KAAK,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG;KAC9B,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,IAAkB;IAClC,OAAO,GAAG,IAAI,CAAC,UAAU,GAAG,CAAC,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;AAC7D,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAkB;IAC1C,mFAAmF;IACnF,8EAA8E;IAC9E,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC;AAC/C,CAAC;AAED,4FAA4F;AAC5F,SAAS,UAAU,CAAC,MAAyC,EAAE,IAAY,EAAE,KAAa,EACxF,UAAiB,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE;IAC7C,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IAClC,MAAM,UAAU,GAAG,EAAE,CAAC;IACtB,KAAK,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,MAAM,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,UAAU,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QAClD,IAAI,KAAK,GAAG,IAAI,IAAI,MAAM,GAAG,IAAI;YAAE,OAAO,IAAI,CAAC;QAC/C,IAAI,CAAC,GAAG,KAAK,GAAG,IAAI,EAAE,CAAC;YAAC,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC,IAAI,SAAS,CAAC;YAAC,SAAS,GAAG,CAAC,CAAC;QAAC,CAAC;QAC/D,IAAI,CAAC,GAAG,MAAM,GAAG,IAAI;YAAE,OAAO,IAAI,CAAC;QACnC,UAAU,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QACpD,CAAC,IAAI,KAAK,CAAC;QACX,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IAC1C,CAAC;IACD,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,EAAE,CAAC;AACpD,CAAC;AAED,iGAAiG;AACjG,MAAM,OAAO,gBAAgB;IAC3B,MAAM,CAAkB;IACxB,KAAK,CAAS;IACd,YAAY,CAAS;IACrB,OAAO,CAAgB;IACvB,cAAc,CAAU;IACxB,iBAAiB,CAAiB;IAClC,WAAW,CAA2B;IACtC,MAAM,CAA+B;IACrC,MAAM,CAAqB;IAC3B,gBAAgB,CAAS;IACzB,iBAAiB,CAAS;IAC1B,gBAAgB,CAAS;IACzB,aAAa,CAAS;IACtB,YAAY,CAAS;IACrB,SAAS,CAA4B;IACrC,QAAQ,CAAU;IAClB,OAAO,CAAS;IAChB,IAAI,CAAS;IACb,4EAA4E;IAC5E,IAAI,CAAc;IAClB,YAAY,CAAmB;IAC/B,MAAM,CAAqC;IAC3C,KAAK,CAAmB;IACxB,aAAa,GAAG,CAAC,CAAC;IAClB,KAAK,GAAU,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;IAC5C,aAAa,GAAG,KAAK,CAAC;IACtB,KAAK,GAAG,CAAC,CAAC;IACV,MAAM,GAAG,CAAC,CAAC;IACX,SAAS,GAAG,CAAC,CAAC;IACd,OAAO,GAAY,EAAE,CAAC;IAEtB,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAuB,EAAE,KAAa,EAAE,OAA+B,EACzF,IAAmB,EAAE,aAAyC,MAAM;QACpE,MAAM,cAAc,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;QAC3C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,GAAG,IAAI,KAAK,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;QAClG,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,GAAG,MAAM,mBAAmB,CAAC,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC;QAC3F,MAAM,QAAQ,GAAG,IAAI,gBAAgB,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;QAC9E,QAAQ,CAAC,cAAc,GAAG,cAAc,CAAC;QACzC,IAAI,CAAC;YACH,MAAM,QAAQ,CAAC,UAAU,EAAE,CAAC;YAC5B,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,QAAQ,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,YAAY,MAAuB,EAAE,KAAa,EAAE,OAAsB,EAAE,IAAoB;QAC9F,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,GAAG,IAAI,KAAK,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;QAClG,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,YAAY,GAAG,KAAK,CAAC;QAC1B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAC;QAC9B,IAAI,CAAC,WAAW,GAAG,IAAI,GAAG,EAAE,CAAC;QAC7B,IAAI,CAAC,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACxB,IAAI,CAAC,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACxB,IAAI,CAAC,gBAAgB,GAAG,CAAC,CAAC;QAC1B,IAAI,CAAC,iBAAiB,GAAG,CAAC,CAAC;QAC3B,IAAI,CAAC,gBAAgB,GAAG,CAAC,CAAC;QAC1B,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;QACvB,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;QACtB,IAAI,CAAC,SAAS,GAAG,IAAI,YAAY,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC;QACjD,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;QACtB,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC;IAChB,CAAC;IAED,KAAK,CAAC,UAAU;QACd,IAAI,CAAC,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACnD,IAAI,CAAC,YAAY,GAAG,IAAI,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,kBAAkB,EAAE,IAAI,EAAE,CAAC,CAAC;QAChF,IAAI,CAAC,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,2CAA2C,CAAC,CAAC;QAC1E,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,qBAAqB,CAAC,CAAC,CAAC;IACtE,CAAC;IAED,aAAa,CAAC,KAAa,EAAE,MAAc,EAAE,KAAa;QACxD,OAAO,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IAC1D,CAAC;IAED,UAAU,CAAC,IAAY;QACrB,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC;QACtB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,EAAE,IAAI,EAAE,aAAa,CAAC,CAAC;QAC3D,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACpB,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;QACvB,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;IAC5C,CAAC;IAED,MAAM,CAAC,OAAe,EAAE,IAAY,EAAE,QAAuB;QAC3D,MAAM,KAAK,GAAG,OAAO,GAAG,UAAU,CAAC;QACnC,MAAM,MAAM,GAAG,IAAI,GAAG,WAAW,CAAC;QAClC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,oBAAoB,CAAC;QAChD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;QACrI,IAAI,CAAC,aAAa,GAAG,KAAK,GAAG,SAAS,GAAG,KAAK,IAAI,MAAM,GAAG,SAAS,GAAG,KAAK,CAAC;QAC7E,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,GAAG,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,CAAC;QACvE,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QACxF,MAAM,aAAa,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAC1F,IAAI,IAAI,CAAC,OAAO,KAAK,OAAO,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,MAAM,CAAC,KAAK,KAAK,YAAY,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,aAAa;YAAE,OAAO;QACzI,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,MAAM,CAAC,KAAK,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,aAAa,CAAC;QACnC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACrC,CAAC;IAED,0FAA0F;IAC1F,KAAK,CAAC,YAAY,CAAC,QAA+B,EAAE,YAA+B;QACjF,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,YAAY,CAAC,CAAC;QACvC,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;QACxE,IAAI,cAAc,GAAG,CAAC,CAAC;QACvB,KAAK,MAAM,GAAG,IAAI,QAAQ,EAAE,CAAC;YAC3B,MAAM,KAAK,GAAG,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC5D,IAAI,CAAC,KAAK;gBAAE,MAAM,IAAI,KAAK,CAAC,oCAAoC,GAAG,EAAE,CAAC,CAAC;YACvE,cAAc,IAAI,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;QACnD,CAAC;QACD,IAAI,cAAc,GAAG,MAAM,CAAC,YAAY;YAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;QAC/G,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACvC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;gBAChD,KAAK,CAAC,OAAO,EAAE,CAAC;gBAChB,IAAI,CAAC,YAAY,IAAI,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;gBACpD,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;QACD,KAAK,MAAM,KAAK,IAAI,QAAQ,EAAE,CAAC;YAC7B,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,SAAS,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;YACrF,IAAI,CAAC;gBACH,IAAI,KAAK,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;oBAC5B,QAAQ,CAAC,WAAW,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;gBAC/D,CAAC;qBAAM,CAAC;oBACN,sFAAsF;oBACtF,MAAM,GAAG,GAAG,IAAI,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;oBAC7F,IAAI,GAAG,CAAC,UAAU,GAAG,EAAE,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,UAAU,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,UAAU;wBACzF,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,IAAI,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,UAAU;wBAC3D,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,KAAK,CAAC,KAAK,IAAI,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,KAAK,CAAC,MAAM,EAAE,CAAC;wBAC5E,MAAM,IAAI,KAAK,CAAC,+CAA+C,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;oBAC9E,CAAC;oBACD,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,EAAE;wBACrF,gBAAgB,EAAE,MAAM;wBACxB,oBAAoB,EAAE,MAAM;qBAC7B,CAAC,CAAC;oBACH,IAAI,CAAC;wBACH,IAAI,MAAM,CAAC,KAAK,KAAK,KAAK,CAAC,KAAK,IAAI,MAAM,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM;4BAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;wBACtH,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;oBAC/B,CAAC;4BAAS,CAAC;wBACT,MAAM,CAAC,KAAK,EAAE,CAAC;oBACjB,CAAC;gBACH,CAAC;gBACD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;gBACrC,IAAI,CAAC,YAAY,IAAI,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;gBACpD,IAAI,CAAC,gBAAgB,IAAI,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;gBACxD,IAAI,CAAC,iBAAiB,IAAI,KAAK,CAAC,UAAU,CAAC;YAC7C,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,QAAQ,CAAC,OAAO,EAAE,CAAC;gBACnB,MAAM,KAAK,CAAC;YACd,CAAC;QACH,CAAC;IACH,CAAC;IAED,aAAa,CAAC,KAA4C;QACxD,MAAM,OAAO,GAAG,IAAI,GAAG,EAAwB,CAAC;QAChD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC;gBAAE,SAAS;YAC5G,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC;QACpC,CAAC;QACD,MAAM,QAAQ,GAAG,CAAC,MAAyC,EAAE,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,KAAK,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACjI,IAAI,OAAO,CAAC,IAAI,GAAG,UAAU,IAAI,QAAQ,CAAC,OAAO,CAAC,GAAG,mBAAmB,EAAE,CAAC;YACzE,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;QAC5E,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAC/E,IAAI,CAAC,OAAO,CAAC,IAAI;YAAE,OAAO;QAC1B,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,UAAU;YAChE,IAAI,CAAC,aAAa,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,mBAAmB,CAAC;QAChE,IAAI,IAAI,GAAG,YAAY,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC/F,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,IAAI,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC;YAC5B,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,qBAAqB,CAAC,CAAC;YACnE,OAAO,CAAC,CAAC,IAAI,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,IAAI,GAAG,OAAO,EAAE,CAAC;gBACzE,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,EAAE,OAAO,CAAC,CAAC;YACrC,CAAC;YACD,IAAI,CAAC,IAAI;gBAAE,MAAM,IAAI,KAAK,CAAC,sFAAsF,CAAC,CAAC;YACnH,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;YACtB,IAAI,CAAC,aAAa,EAAE,CAAC;QACvB,CAAC;QACD,KAAK,MAAM,SAAS,IAAI,IAAI,CAAC,UAAU;YAAE,IAAI,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC;QACrE,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;IAC1B,CAAC;IAED,WAAW,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAkB;QAC5D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACzB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;QAClC,IAAI,OAAO,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,GAAG,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,WAAW,CAAC,CAAC;YAC1F,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QACvC,CAAC;QACD,IAAI,CAAC,YAAY,CAAC,KAAK,GAAG,KAAK,CAAC;QAChC,IAAI,CAAC,YAAY,CAAC,MAAM,GAAG,MAAM,CAAC;QAClC,MAAM,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAC3B,MAAM,CAAC,YAAY,GAAG,YAAY,CAAC;QACnC,MAAM,CAAC,SAAS,GAAG,MAAM,CAAC;QAC1B,MAAM,CAAC,SAAS,GAAG,OAAO,CAAC;QAC3B,6EAA6E;QAC7E,sFAAsF;QACtF,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;QACnF,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QACjC,MAAM,MAAM,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;QACxD,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;gBAC/G,OAAO,GAAG,IAAI,CAAC;gBACf,MAAM;YACR,CAAC;QACH,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QACzD,IAAI,CAAC,gBAAgB,IAAI,KAAK,GAAG,MAAM,GAAG,CAAC,CAAC;QAC5C,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE;YACnB,OAAO;YACP,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK;YAC9B,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM;YAC/B,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,KAAK,GAAG,UAAU,GAAG,KAAK,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK;YAChE,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM;SACtD,CAAC,CAAC;QACH,IAAI,CAAC,aAAa,IAAI,GAAG,CAAC,MAAM,CAAC;IACnC,CAAC;IAED,sFAAsF;IACtF,IAAI,CAAC,QAAyB,EAAE,CAAS,EAAE,CAAS,EAAE,KAAa,EAAE,MAAc,EACjF,KAAc,EAAE,IAAI,GAAG,CAAC,EAAE,KAAc,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EACpD,OAAgB,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC;QAC/C,IAAI,KAAK,IAAI,CAAC,IAAI,MAAM,IAAI,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;YAAE,OAAO;QACvD,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACrC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACpC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACjE,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,GAAG,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACpE,IAAI,KAAK,IAAI,IAAI,IAAI,MAAM,IAAI,GAAG;YAAE,OAAO;QAC3C,IAAI,IAAI,CAAC,SAAS,IAAI,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;QACtF,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC;QACvC,IAAI,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;YAC5C,MAAM,KAAK,GAAG,IAAI,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC,CAAC,CAAC;YACxF,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAC1B,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;QACzB,CAAC;QACD,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC;QACzB,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC;QACzB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC;YACjB,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,IAAI,EAAE,MAAM,GAAG,GAAG;YACrC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,KAAK,GAAG,EAAE;YAC/B,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,MAAM,GAAG,EAAE;YAC/B,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,KAAK,GAAG,EAAE;YAChC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,MAAM,GAAG,EAAE;YAClC,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;SACxB,EAAE,MAAM,CAAC,CAAC;QACX,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACnD,IAAI,IAAI,EAAE,QAAQ,KAAK,QAAQ;YAAE,IAAI,CAAC,KAAK,EAAE,CAAC;;YACzC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC;QACtE,IAAI,CAAC,SAAS,EAAE,CAAC;IACnB,CAAC;IAED,KAAK,CAAC,CAAS,EAAE,CAAS,EAAE,KAAa,EAAE,MAAc,EAAE,KAAc;QACvE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IACpD,CAAC;IAED,SAAS,CAAC,SAAyB;QACjC,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAC7C,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,SAAS,CAAC,GAAG,EAAE,CAAC,CAAC;QAC9E,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,YAAY,EAAE,EAAE,EAAE,GAAG,SAAS,CAAC;QAClF,IAAI,CAAC,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,CAAC,SAAS,CAAC,MAAM;YAAE,OAAO;QAChE,6FAA6F;QAC7F,MAAM,UAAU,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,KAAK,CAAC;QACzE,MAAM,SAAS,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,MAAM,CAAC;QACzE,MAAM,WAAW,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,KAAK,CAAC;QACxF,MAAM,YAAY,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,MAAM,GAAG,EAAE,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,MAAM,CAAC;QAC3F,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;QACnD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;QACjD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,SAAS,CAAC,KAAK,GAAG,SAAS,CAAC,SAAS,CAAC,CAAC;QAC3E,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,SAAS,CAAC,KAAK,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;QAC9E,IAAI,CAAC,IAAI,CACP,KAAK,EAAE,SAAS,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,EAAE,SAAS,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,EAC5E,CAAC,EAAE,GAAG,KAAK,CAAC,KAAK,EAAE,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,EACxF,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,CAAC,CAClE,CAAC;IACJ,CAAC;IAED,WAAW,CAAC,IAAkB,EAAE,CAAS,EAAE,CAAS,EAAE,KAAa,EAAE,UAAmB;QACtF,IAAI,IAAI,CAAC,UAAU,GAAG,GAAG;YAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,CAAC;QACvE,IAAI,IAAI,CAAC,UAAU,GAAG,GAAG;YAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,CAAC;QACtE,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACnE,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC;IAChE,CAAC;IAEO,SAAS,CAAC,KAAa,EAAE,CAAS,EAAE,CAAS,EAAE,KAAa,EAAE,KAAc;QAClF,IAAI,KAAK,KAAK,CAAC;YAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;aACnD,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;YACrB,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;YACvC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;QACzC,CAAC;aAAM,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;YACvB,KAAK,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,KAAK,EAAE,EAAE,EAAE,EAAE,CAAC;gBAClC,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;YACzF,CAAC;QACH,CAAC;aAAM,IAAI,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACjC,MAAM,OAAO,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACpC,KAAK,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,KAAK,EAAE,EAAE,IAAI,IAAI;gBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,EAAE,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;QAC/G,CAAC;IACH,CAAC;IAED,MAAM,CAAC,KAA4C,EAAE,QAAuB,EAAE,OAAgB,EAC5F,eAA4B;QAC5B,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;QAChC,IAAI,CAAC,SAAS,GAAG,CAAC,CAAC;QACnB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;QAClB,MAAM,UAAU,GAAG,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;YAChE,SAAS;YACT,KAAK;YACL,CAAC,EAAE,SAAS,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;SACjD,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;QACnD,KAAK,MAAM,IAAI,IAAI,UAAU;YAAE,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,UAAU;gBAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACxF,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACtB,IAAI,CAAC,IAAI;gBAAE,SAAS;YACpB,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAC1I,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,UAAU;YAAE,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC;gBAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACvG,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACtB,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;gBAAE,SAAS;YACrG,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,UAAU,CAAC;YAC1C,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,WAAW,CAAC;YACrD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,GAAG,UAAU,EAAE,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;YAChE,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YACzC,MAAM,KAAK,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;YACnF,IAAI,KAAK,EAAE,CAAC;gBACV,MAAM,IAAI,GAAY,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC;gBACtG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,KAAK,GAAG,UAAU,EAAE,WAAW,EAC9D,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;YAC3E,CAAC;YACD,oEAAoE;YACpE,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC;YAChD,IAAI,eAAe,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,IAAI,CAAC,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,EAAE,CAAC;gBACtG,IAAI,CAAC,SAAS,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC;YAC9D,CAAC;QACH,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,UAAU;YAAE,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC;gBAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC/E,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;QAC/B,MAAM,WAAW,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,GAAG,CAAC,KAAK,CAAC,CAAC;QACjE,IAAI,MAAM,CAAC,OAAO,IAAI,CAAC,CAAC,WAAW,IAAI,OAAO,CAAC,IAAI,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,IAAI,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YACrI,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;YACvD,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,EAAE,UAAU,IAAI,UAAU,CAAC,CAAC;YACnD,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,UAAU,CAAC;YAChC,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,WAAW,CAAC;YACjC,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC;gBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;iBACrF,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC;gBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,CAAC;iBACtF,CAAC;gBACJ,KAAK,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;gBACjB,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,CAAC,CAAC;YACnD,CAAC;QACH,CAAC;QACD,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,iBAAiB,IAAI,KAAK,CAAC,CAAC,CAAC,EAAE,UAAU,IAAI,UAAU,CAAC,CAAC;QACpF,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACxE,OAAO,EAAE,KAAK,EAAE,WAAW,CAAC,GAAG,EAAE,GAAG,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;IACrG,CAAC;IAED,OAAO;QACL,OAAO;YACL,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI;YAC3B,sBAAsB,EAAE,IAAI,CAAC,cAAc;YAC3C,UAAU,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM;YAC5B,WAAW,EAAE,IAAI,CAAC,KAAK;YACvB,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK;YAC/B,aAAa,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;YACjC,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI;YAC5B,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,WAAW,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI;YAC7B,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC;YACpD,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;YACvC,iBAAiB,EAAE,IAAI,CAAC,iBAAiB;YACzC,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;YACvC,mBAAmB,EAAE,IAAI,CAAC,OAAO,CAAC,mBAAmB;SACtD,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,IAAI;QACR,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;IAC5B,CAAC;IAED,OAAO;QACL,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO;QAC1B,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE;YAAE,KAAK,CAAC,OAAO,EAAE,CAAC;QAC1D,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACpB,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;QACtB,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC;QACtB,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACpB,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;QACvB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;QAClB,IAAI,CAAC,SAAS,GAAG,CAAC,CAAC;QACnB,IAAI,CAAC,SAAS,GAAG,IAAI,YAAY,CAAC,CAAC,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC;QACrB,IAAI,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;QACzB,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;IACzB,CAAC;CACF","sourcesContent":["import { LIMITS } from \"./protocol.js\";\nimport { loadFont, measureFont, normalizeFont } from \"./terminal-font.js\";\nimport { createRenderBackend } from \"./backend-selection.js\";\nimport { QUAD_STRIDE } from \"./render-backend.js\";\nimport type { RenderBackend, RenderBatch, RenderColor, RenderTexture } from \"./render-backend.js\";\nimport type { FontMetrics, LoadedFont, NormalizedFont } from \"./terminal-font.js\";\nimport type { TerminalFont, TerminalRendererPreference, TerminalSize } from \"./types.js\";\nimport type { FrameImage, FrameMetadata, ImagePlacement, TerminalCell } from \"./wire-types.js\";\n\ntype Vector4 = RenderColor;\ntype TextureResource = RenderTexture;\ninterface Shelf { x: number; y: number; rowHeight: number }\ninterface GlyphPlacement { key: string; cell: TerminalCell; x: number; y: number; width: number; height: number }\ninterface Glyph { colored: boolean; u0: number; v0: number; u1: number; v1: number }\ntype Batch = RenderBatch;\n\nconst CELL_WIDTH = 10;\nconst CELL_HEIGHT = 20;\nconst MAX_QUADS = 1024 * 1024;\nconst MAX_GLYPHS = 16384;\nconst MAX_GLYPH_KEY_UNITS = 1024 * 1024;\nconst STRIDE = QUAD_STRIDE;\nconst WHITE: Vector4 = [1, 1, 1, 1];\n\nfunction rgba(packed: number): Vector4 {\n return [\n (packed & 255) / 255,\n ((packed >>> 8) & 255) / 255,\n ((packed >>> 16) & 255) / 255,\n ((packed >>> 24) & 255) / 255,\n ];\n}\n\nfunction glyphKey(cell: TerminalCell): string {\n return `${cell.attributes & 5}/${cell.width}/${cell.text}`;\n}\n\nfunction isKgpPlaceholder(cell: TerminalCell): boolean {\n // The base scalar and following diacritics encode an image reference, not a glyph.\n // Keep the authoritative text and colors intact even when no image is placed.\n return cell.text.codePointAt(0) === 0x10eeee;\n}\n\n/** Shelf packer. Plans are computed before mutating the atlas used by a submitted frame. */\nfunction packGlyphs(glyphs: ReadonlyMap, size: number, scale: number,\n initial: Shelf = { x: 0, y: 0, rowHeight: 0 }): { placements: GlyphPlacement[]; shelf: Shelf } | null {\n let { x, y, rowHeight } = initial;\n const placements = [];\n for (const [key, cell] of glyphs) {\n const width = Math.ceil(cell.width * CELL_WIDTH * scale) + 4;\n const height = Math.ceil(CELL_HEIGHT * scale) + 4;\n if (width > size || height > size) return null;\n if (x + width > size) { x = 0; y += rowHeight; rowHeight = 0; }\n if (y + height > size) return null;\n placements.push({ key, cell, x, y, width, height });\n x += width;\n rowHeight = Math.max(rowHeight, height);\n }\n return { placements, shelf: { x, y, rowHeight } };\n}\n\n/** Shared instanced-quad preparation; Canvas2D rasterizes reusable glyphs for either backend. */\nexport class TerminalRenderer {\n canvas: OffscreenCanvas;\n scale: number;\n backingScale: number;\n backend: RenderBackend;\n fallbackReason?: string;\n fontConfiguration: NormalizedFont;\n fontMetrics: Map;\n images: Map;\n glyphs: Map;\n imageUploadBytes: number;\n imagePayloadBytes: number;\n glyphUploadBytes: number;\n atlasRebuilds: number;\n textureBytes: number;\n instances: Float32Array;\n disposed: boolean;\n columns: number;\n rows: number;\n // Initialized by create() before the renderer can prepare or submit frames.\n font!: LoadedFont;\n rasterCanvas!: OffscreenCanvas;\n raster!: OffscreenCanvasRenderingContext2D;\n atlas!: TextureResource;\n glyphKeyUnits = 0;\n shelf: Shelf = { x: 0, y: 0, rowHeight: 0 };\n canvasLimited = false;\n width = 0;\n height = 0;\n quadCount = 0;\n batches: Batch[] = [];\n\n static async create(canvas: OffscreenCanvas, scale: number, onFatal: (error: Error) => void,\n font?: TerminalFont, preference: TerminalRendererPreference = \"auto\"): Promise {\n const normalizedFont = normalizeFont(font);\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n const { backend, fallbackReason } = await createRenderBackend(canvas, onFatal, preference);\n const renderer = new TerminalRenderer(canvas, scale, backend, normalizedFont);\n renderer.fallbackReason = fallbackReason;\n try {\n await renderer.initialize();\n return renderer;\n } catch (error) {\n renderer.dispose();\n throw error;\n }\n }\n\n constructor(canvas: OffscreenCanvas, scale: number, backend: RenderBackend, font: NormalizedFont) {\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new Error(\"Invalid backing scale\");\n this.canvas = canvas;\n this.scale = scale;\n this.backingScale = scale;\n this.backend = backend;\n this.fontConfiguration = font;\n this.fontMetrics = new Map();\n this.images = new Map();\n this.glyphs = new Map();\n this.imageUploadBytes = 0;\n this.imagePayloadBytes = 0;\n this.glyphUploadBytes = 0;\n this.atlasRebuilds = 0;\n this.textureBytes = 0;\n this.instances = new Float32Array(4096 * STRIDE);\n this.disposed = false;\n this.columns = 0;\n this.rows = 0;\n }\n\n async initialize() {\n this.font = await loadFont(this.fontConfiguration);\n this.rasterCanvas = new OffscreenCanvas(1, 1);\n const raster = this.rasterCanvas.getContext(\"2d\", { willReadFrequently: true });\n if (!raster) throw new Error(\"Worker glyph rasterization is unavailable\");\n this.raster = raster;\n this.resetAtlas(Math.min(2048, this.backend.maxTextureDimension2D));\n }\n\n createTexture(width: number, height: number, label: string): TextureResource {\n return this.backend.createTexture(width, height, label);\n }\n\n resetAtlas(size: number): void {\n this.atlas?.destroy();\n this.atlas = this.createTexture(size, size, \"Glyph atlas\");\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.shelf = { x: 0, y: 0, rowHeight: 0 };\n }\n\n resize(columns: number, rows: number, viewport?: TerminalSize): void {\n const width = columns * CELL_WIDTH;\n const height = rows * CELL_HEIGHT;\n const limit = this.backend.maxCanvasDimension2D;\n const requested = Math.min(this.scale, viewport ? viewport.width / width : Infinity, viewport ? viewport.height / height : Infinity);\n this.canvasLimited = width * requested > limit || height * requested > limit;\n this.backingScale = Math.min(requested, limit / width, limit / height);\n const backingWidth = Math.max(1, Math.min(limit, Math.ceil(width * this.backingScale)));\n const backingHeight = Math.max(1, Math.min(limit, Math.ceil(height * this.backingScale)));\n if (this.columns === columns && this.rows === rows && this.canvas.width === backingWidth && this.canvas.height === backingHeight) return;\n this.columns = columns;\n this.rows = rows;\n this.width = width;\n this.height = height;\n this.canvas.width = backingWidth;\n this.canvas.height = backingHeight;\n this.backend.resize(width, height);\n }\n\n /** Call only between submissions. Missing/over-budget resources terminate the session. */\n async updateImages(incoming: readonly FrameImage[], retainedKeys: readonly string[]): Promise {\n const retained = new Set(retainedKeys);\n const replacements = new Map(incoming.map(image => [image.key, image]));\n let projectedBytes = 0;\n for (const key of retained) {\n const image = replacements.get(key) || this.images.get(key);\n if (!image) throw new Error(`Missing retained image resource: ${key}`);\n projectedBytes += image.width * image.height * 4;\n }\n if (projectedBytes > LIMITS.textureBytes) throw new Error(\"Retained images exceed the 256 MiB texture budget\");\n for (const [key, image] of this.images) {\n if (!retained.has(key) || replacements.has(key)) {\n image.destroy();\n this.textureBytes -= image.width * image.height * 4;\n this.images.delete(key);\n }\n }\n for (const image of incoming) {\n const resource = this.createTexture(image.width, image.height, `Image ${image.key}`);\n try {\n if (image.format === \"rgba\") {\n resource.writePixels(image.bytes, image.width, image.height);\n } else {\n // Check IHDR before decoding so a tiny PNG cannot claim unbounded decoded dimensions.\n const png = new DataView(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength);\n if (png.byteLength < 33 || png.getUint32(0) !== 0x89504e47 || png.getUint32(4) !== 0x0d0a1a0a ||\n png.getUint32(8) !== 13 || png.getUint32(12) !== 0x49484452 ||\n png.getUint32(16) !== image.width || png.getUint32(20) !== image.height) {\n throw new Error(`PNG header dimensions do not match resource ${image.key}`);\n }\n const bitmap = await createImageBitmap(new Blob([image.bytes], { type: \"image/png\" }), {\n premultiplyAlpha: \"none\",\n colorSpaceConversion: \"none\",\n });\n try {\n if (bitmap.width !== image.width || bitmap.height !== image.height) throw new Error(\"Decoded PNG dimension mismatch\");\n resource.writeBitmap(bitmap);\n } finally {\n bitmap.close();\n }\n }\n this.images.set(image.key, resource);\n this.textureBytes += image.width * image.height * 4;\n this.imageUploadBytes += image.width * image.height * 4;\n this.imagePayloadBytes += image.byteLength;\n } catch (error) {\n resource.destroy();\n throw error;\n }\n }\n }\n\n prepareGlyphs(cells: readonly (TerminalCell | undefined)[]): void {\n const visible = new Map();\n for (const cell of cells) {\n if (!cell || !cell.width || !cell.text.trim() || (cell.attributes & 64) || isKgpPlaceholder(cell)) continue;\n visible.set(glyphKey(cell), cell);\n }\n const keyUnits = (glyphs: ReadonlyMap) => [...glyphs.keys()].reduce((total, key) => total + key.length, 0);\n if (visible.size > MAX_GLYPHS || keyUnits(visible) > MAX_GLYPH_KEY_UNITS) {\n throw new Error(\"Visible glyph metadata exceeds the bounded glyph cache\");\n }\n const missing = new Map([...visible].filter(([key]) => !this.glyphs.has(key)));\n if (!missing.size) return;\n const metadataFits = this.glyphs.size + missing.size <= MAX_GLYPHS &&\n this.glyphKeyUnits + keyUnits(missing) <= MAX_GLYPH_KEY_UNITS;\n let plan = metadataFits ? packGlyphs(missing, this.atlas.width, this.scale, this.shelf) : null;\n if (!plan) {\n let size = this.atlas.width;\n const maxSize = Math.min(4096, this.backend.maxTextureDimension2D);\n while (!(plan = packGlyphs(visible, size, this.scale)) && size < maxSize) {\n size = Math.min(size * 2, maxSize);\n }\n if (!plan) throw new Error(\"Visible glyphs exceed the bounded 4096² mask atlas; reduce the grid or backing scale\");\n this.resetAtlas(size);\n this.atlasRebuilds++;\n }\n for (const placement of plan.placements) this.uploadGlyph(placement);\n this.shelf = plan.shelf;\n }\n\n uploadGlyph({ key, cell, x, y, width, height }: GlyphPlacement): void {\n const scale = this.scale;\n const raster = this.raster;\n const style = cell.attributes & 5;\n let metrics = this.fontMetrics.get(style);\n if (!metrics) {\n metrics = measureFont(raster, this.font.cssFamily, style, scale, CELL_WIDTH, CELL_HEIGHT);\n this.fontMetrics.set(style, metrics);\n }\n this.rasterCanvas.width = width;\n this.rasterCanvas.height = height;\n raster.font = metrics.font;\n raster.textBaseline = \"alphabetic\";\n raster.textAlign = \"left\";\n raster.fillStyle = \"white\";\n // One transform per font style, not per glyph: borders remain font outlines,\n // and graphemes are clipped to their server-owned span without individual stretching.\n raster.setTransform(metrics.xScale, 0, 0, metrics.yScale, 2, 2 + metrics.baseline);\n raster.fillText(cell.text, 0, 0);\n const pixels = raster.getImageData(0, 0, width, height);\n let colored = false;\n for (let i = 0; i < pixels.data.length; i += 4) {\n if (pixels.data[i + 3] && (pixels.data[i] !== pixels.data[i + 1] || pixels.data[i + 1] !== pixels.data[i + 2])) {\n colored = true;\n break;\n }\n }\n this.atlas.writePixels(pixels.data, width, height, x, y);\n this.glyphUploadBytes += width * height * 4;\n this.glyphs.set(key, {\n colored,\n u0: (x + 2) / this.atlas.width,\n v0: (y + 2) / this.atlas.height,\n u1: (x + 2 + cell.width * CELL_WIDTH * scale) / this.atlas.width,\n v1: (y + 2 + CELL_HEIGHT * scale) / this.atlas.height,\n });\n this.glyphKeyUnits += key.length;\n }\n\n /** Clip geometry and UVs together. Only adjacent compatible textures may coalesce. */\n quad(resource: TextureResource, x: number, y: number, width: number, height: number,\n color: Vector4, mode = 0, uv: Vector4 = [0, 0, 1, 1],\n clip: Vector4 = [0, 0, this.width, this.height]): void {\n if (width <= 0 || height <= 0 || color[3] <= 0) return;\n const left = Math.max(0, x, clip[0]);\n const top = Math.max(0, y, clip[1]);\n const right = Math.min(this.width, x + width, clip[0] + clip[2]);\n const bottom = Math.min(this.height, y + height, clip[1] + clip[3]);\n if (right <= left || bottom <= top) return;\n if (this.quadCount >= MAX_QUADS) throw new Error(\"Frame exceeds bounded quad budget\");\n const offset = this.quadCount * STRIDE;\n if (offset + STRIDE > this.instances.length) {\n const grown = new Float32Array(Math.min(this.instances.length * 2, MAX_QUADS * STRIDE));\n grown.set(this.instances);\n this.instances = grown;\n }\n const du = uv[2] - uv[0];\n const dv = uv[3] - uv[1];\n this.instances.set([\n left, top, right - left, bottom - top,\n uv[0] + (left - x) / width * du,\n uv[1] + (top - y) / height * dv,\n uv[0] + (right - x) / width * du,\n uv[1] + (bottom - y) / height * dv,\n ...color, mode, 0, 0, 0,\n ], offset);\n const last = this.batches[this.batches.length - 1];\n if (last?.resource === resource) last.count++;\n else this.batches.push({ resource, start: this.quadCount, count: 1 });\n this.quadCount++;\n }\n\n solid(x: number, y: number, width: number, height: number, color: Vector4): void {\n this.quad(this.atlas, x, y, width, height, color);\n }\n\n placement(placement: ImagePlacement): void {\n const image = this.images.get(placement.key);\n if (!image) throw new Error(`Placement texture is missing: ${placement.key}`);\n const { sourceX: sx, sourceY: sy, sourceWidth: sw, sourceHeight: sh } = placement;\n if (!sw || !sh || !placement.width || !placement.height) return;\n // Clip out-of-texture source regions in destination space instead of stretching edge texels.\n const sourceLeft = placement.x + Math.max(0, -sx) / sw * placement.width;\n const sourceTop = placement.y + Math.max(0, -sy) / sh * placement.height;\n const sourceRight = placement.x + Math.min(sw, image.width - sx) / sw * placement.width;\n const sourceBottom = placement.y + Math.min(sh, image.height - sy) / sh * placement.height;\n const left = Math.max(sourceLeft, placement.clipX);\n const top = Math.max(sourceTop, placement.clipY);\n const right = Math.min(sourceRight, placement.clipX + placement.clipWidth);\n const bottom = Math.min(sourceBottom, placement.clipY + placement.clipHeight);\n this.quad(\n image, placement.x, placement.y, placement.width, placement.height, WHITE, 2,\n [sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height],\n [left, top, Math.max(0, right - left), Math.max(0, bottom - top)],\n );\n }\n\n decorations(cell: TerminalCell, x: number, y: number, width: number, foreground: Vector4): void {\n if (cell.attributes & 128) this.solid(x, y + 10, width, 1, foreground);\n if (cell.attributes & 256) this.solid(x, y + 1, width, 1, foreground);\n const style = cell.underlineStyle || (cell.attributes & 8 ? 1 : 0);\n this.underline(style, x, y, width, rgba(cell.underlineColor));\n }\n\n private underline(style: number, x: number, y: number, width: number, color: Vector4): void {\n if (style === 1) this.solid(x, y + 18, width, 1, color);\n else if (style === 2) {\n this.solid(x, y + 16, width, 1, color);\n this.solid(x, y + 18, width, 1, color);\n } else if (style === 3) {\n for (let dx = 0; dx < width; dx++) {\n this.solid(x + dx, y + 17 + Math.round(Math.sin((x + dx) * Math.PI / 4)), 1, 1, color);\n }\n } else if (style === 4 || style === 5) {\n const step = style === 4 ? 2 : 5;\n const segment = style === 4 ? 1 : 3;\n for (let dx = 0; dx < width; dx += step) this.solid(x + dx, y + 18, Math.min(segment, width - dx), 1, color);\n }\n }\n\n render(cells: readonly (TerminalCell | undefined)[], metadata: FrameMetadata, blinkOn: boolean,\n linkDecorations?: Uint8Array) {\n const start = performance.now();\n this.quadCount = 0;\n this.batches = [];\n const placements = metadata.placements.map((placement, order) => ({\n placement,\n order,\n z: placement.kind === \"sixel\" ? -1 : placement.z,\n })).sort((a, b) => a.z - b.z || a.order - b.order);\n for (const item of placements) if (item.z < -1073741824) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell) continue;\n this.solid((i % this.columns) * CELL_WIDTH, Math.floor(i / this.columns) * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT, rgba(cell.background));\n }\n for (const item of placements) if (item.z >= -1073741824 && item.z < 0) this.placement(item.placement);\n for (let i = 0; i < cells.length; i++) {\n const cell = cells[i];\n if (!cell || !cell.width || (cell.attributes & 64) || ((cell.attributes & 16) && !blinkOn)) continue;\n const x = (i % this.columns) * CELL_WIDTH;\n const y = Math.floor(i / this.columns) * CELL_HEIGHT;\n const width = Math.min(cell.width * CELL_WIDTH, this.width - x);\n const foreground = rgba(cell.foreground);\n const glyph = isKgpPlaceholder(cell) ? undefined : this.glyphs.get(glyphKey(cell));\n if (glyph) {\n const tint: Vector4 = glyph.colored ? (cell.attributes & 2 ? [0.5, 0.5, 0.5, 1] : WHITE) : foreground;\n this.quad(this.atlas, x, y, cell.width * CELL_WIDTH, CELL_HEIGHT,\n tint, glyph.colored ? 2 : 1, [glyph.u0, glyph.v0, glyph.u1, glyph.v1]);\n }\n // Reverse and dim are already reflected in server-projected colors.\n this.decorations(cell, x, y, width, foreground);\n if (linkDecorations?.[i] && !cell.underlineStyle && !(cell.attributes & 8) && !isKgpPlaceholder(cell)) {\n this.underline(linkDecorations[i], x, y, width, foreground);\n }\n }\n for (const item of placements) if (item.z >= 0) this.placement(item.placement);\n const cursor = metadata.cursor;\n const cursorBlink = cursor.shape === 0 || cursor.shape % 2 === 1;\n if (cursor.visible && (!cursorBlink || blinkOn) && cursor.x >= 0 && cursor.x < this.columns && cursor.y >= 0 && cursor.y < this.rows) {\n const cell = cells[cursor.y * this.columns + cursor.x];\n const color = rgba(cell?.foreground ?? 0xffffffff);\n const x = cursor.x * CELL_WIDTH;\n const y = cursor.y * CELL_HEIGHT;\n if (cursor.shape === 3 || cursor.shape === 4) this.solid(x, y + 18, CELL_WIDTH, 2, color);\n else if (cursor.shape === 5 || cursor.shape === 6) this.solid(x, y, 2, CELL_HEIGHT, color);\n else {\n color[3] *= 0.55;\n this.solid(x, y, CELL_WIDTH, CELL_HEIGHT, color);\n }\n }\n const base = rgba(metadata.defaultBackground ?? cells[0]?.background ?? 0xff000000);\n this.backend.submit(this.instances, this.quadCount, this.batches, base);\n return { cpuMs: performance.now() - start, quads: this.quadCount, drawCalls: this.batches.length };\n }\n\n metrics() {\n return {\n renderer: this.backend.kind,\n rendererFallbackReason: this.fallbackReason,\n fontFamily: this.font.family,\n rasterScale: this.scale,\n backingScale: this.backingScale,\n backingWidth: this.canvas.width,\n backingHeight: this.canvas.height,\n imageCount: this.images.size,\n textureBytes: this.textureBytes,\n atlasGlyphs: this.glyphs.size,\n atlasBytes: this.atlas.width * this.atlas.height * 4,\n atlasRebuilds: this.atlasRebuilds,\n imageUploadBytes: this.imageUploadBytes,\n imagePayloadBytes: this.imagePayloadBytes,\n glyphUploadBytes: this.glyphUploadBytes,\n instanceBufferBytes: this.backend.instanceBufferBytes,\n };\n }\n\n async idle() {\n await this.backend.idle();\n }\n\n dispose() {\n if (this.disposed) return;\n this.disposed = true;\n for (const image of this.images.values()) image.destroy();\n this.images.clear();\n this.textureBytes = 0;\n this.atlas?.destroy();\n this.glyphs.clear();\n this.glyphKeyUnits = 0;\n this.batches = [];\n this.quadCount = 0;\n this.instances = new Float32Array(0);\n this.font?.dispose();\n this.fontMetrics.clear();\n this.backend.dispose();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js index 06eff65d0e9..f9abc9fd8ff 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js @@ -1,5 +1,6 @@ import { decodeFrame, screenText } from "./protocol.js"; import { TerminalRenderer } from "./renderer.js"; +import { LinkPresentation } from "./link-presentation.js"; import { errorMessage } from "./validation.js"; let renderer; let socket; @@ -20,6 +21,7 @@ let renderPromise = Promise.resolve(); let metricsTimer; let blinkTimer; let viewport; +const links = new LinkPresentation(); const stats = { revision: 0, fullFrames: 0, frames: 0, presentations: 0, changedCells: 0, lastChangedCells: 0, discardedFrames: 0, @@ -85,9 +87,12 @@ async function drawFrame() { drawing = true; const frame = pendingFrame; try { + if (frame) + links.prepare(cells, metadata); + const linkSubmission = links.submission(); renderer.resize(metadata.columns, metadata.rows, viewport); const blink = Math.floor(performance.now() / 600) % 2 === 0; - const result = renderer.render(cells, metadata, blink); + const result = renderer.render(cells, metadata, blink, linkSubmission.mask); // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement. await renderer.idle(); if (failed || stopped) @@ -126,11 +131,19 @@ async function drawFrame() { history: metadata.history, revision: frame.revision, title: metadata.title, progress: metadata.progress, shellIntegration: metadata.shellIntegration, workingDirectory: metadata.workingDirectory, commandMark: metadata.commandMark, - text, hyperlinks: metadata.hyperlinks + text, hyperlinks: metadata.hyperlinks, ...links.present(cells, metadata) }); send({ type: "ack", revision: frame.revision }); emitStats(text); } + else { + const snapshot = links.snapshot(); + if (snapshot) + self.postMessage({ type: "linkSnapshot", generation: links.generation, snapshot }); + } + const acknowledgement = links.acknowledge(linkSubmission.acknowledgement, processing || frameInFlight); + if (acknowledgement) + self.postMessage({ type: "linkDecorations", ...acknowledgement }); } catch (error) { fail(error); @@ -290,5 +303,29 @@ self.addEventListener("message", event => { else if (message.type === "command" && !failed && !stopped) { send(message.command); } + else if (message.type === "linkDetection" && !failed && !stopped) { + try { + if (!links.configure(message.enabled, message.generation)) + return; + if (!processing && !drawing && !pendingFrame) { + const snapshot = links.snapshot(); + if (snapshot) + self.postMessage({ type: "linkSnapshot", generation: links.generation, snapshot }); + } + scheduleRender(); + } + catch (error) { + fail(error); + } + } + else if (message.type === "linkDecorations" && !failed && !stopped) { + try { + if (links.accept(message.revision, message.generation, message.serial, message.ranges, processing || frameInFlight || !!pendingFrame, message.underlineStyle)) + scheduleRender(); + } + catch (error) { + fail(error); + } + } }); //# sourceMappingURL=terminal-worker.js.map \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map index 4ce5f7a025d..ac5e6784008 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/terminal-worker.js.map @@ -1 +1 @@ -{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close();\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, title: metadata.title,\n progress: metadata.progress, shellIntegration: metadata.shellIntegration,\n workingDirectory: metadata.workingDirectory, commandMark: metadata.commandMark,\n text, hyperlinks: metadata.hyperlinks\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n }\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n // WebSocket errors are followed by close, which carries the browser's actual status.\n // Rejecting mount on error would terminate this worker before that status can be delivered.\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"closed\", details: {\n code: event.code, reason: event.reason, wasClean: event.wasClean\n } });\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n }\n});\n"]} \ No newline at end of file +{"version":3,"file":"terminal-worker.js","sourceRoot":"","sources":["../src/terminal-worker.ts"],"names":[],"mappings":"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","sourcesContent":["import { decodeFrame, screenText } from \"./protocol.js\";\nimport { TerminalRenderer } from \"./renderer.js\";\nimport { LinkPresentation } from \"./link-presentation.js\";\nimport type { TerminalSize, TerminalStatusLevel } from \"./types.js\";\nimport type { FrameMetadata, TerminalCell, TerminalCommand, WorkerInputMessage, WorkerOutputMessage, WorkerStats } from \"./wire-types.js\";\nimport { errorMessage } from \"./validation.js\";\n\n// This module is only executed as a dedicated worker. Keeping its global local to\n// this module avoids leaking worker/WebGPU ambient dependencies to public declarations.\ndeclare const self: {\n postMessage(message: WorkerOutputMessage): void;\n requestAnimationFrame(callback: FrameRequestCallback): number;\n close(): void;\n addEventListener(type: \"error\", callback: (event: ErrorEvent) => void): void;\n addEventListener(type: \"unhandledrejection\", callback: (event: PromiseRejectionEvent) => void): void;\n addEventListener(type: \"message\", callback: (event: MessageEvent) => void): void;\n};\n\nlet renderer: TerminalRenderer | undefined;\nlet socket: WebSocket | undefined;\nlet failed = false;\nlet stopped = false;\nlet processing = false;\nlet drawing = false;\nlet frameInFlight = false;\nlet scheduled = false;\nlet needsRender = false;\nlet hasBlink = false;\nlet lastBlink = true;\nlet metadata: FrameMetadata | undefined;\nlet cells: (TerminalCell | undefined)[] = [];\nlet localRevision = 0;\nlet pendingFrame: { revision: number; full: boolean; changedCells: number } | undefined;\nlet renderPromise = Promise.resolve();\nlet metricsTimer: ReturnType | undefined;\nlet blinkTimer: ReturnType | undefined;\nlet viewport: TerminalSize | undefined;\nconst links = new LinkPresentation();\nconst stats: WorkerStats = {\n revision: 0, fullFrames: 0, frames: 0, presentations: 0,\n changedCells: 0, lastChangedCells: 0, discardedFrames: 0,\n imageCount: 0, textureBytes: 0, atlasGlyphs: 0, atlasBytes: 0,\n bytesReceived: 0, imageUploadBytes: 0, imagePayloadBytes: 0,\n gpu: \"initializing\", connected: false, warnings: [],\n fps: 0, receivedKBps: 0, workloadMBps: 0,\n captureMs: 0, rendererCpuMs: 0, preparationCpuMs: 0,\n workloadBytes: 0, outputBatches: 0, serverElapsedMs: 0,\n};\nlet sample = { time: performance.now(), presentations: 0, bytes: 0, workload: 0 };\n\nfunction postStatus(message: string, level: TerminalStatusLevel = \"info\"): void {\n self.postMessage({ type: \"status\", message, level });\n}\n\nfunction send(message: TerminalCommand): void {\n if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));\n}\n\nfunction emitStats(text?: string): void {\n if (renderer && !renderer.disposed) Object.assign(stats, renderer.metrics());\n self.postMessage({ type: \"stats\", stats: { ...stats }, ...(text === undefined ? {} : { text }) });\n}\n\nfunction fail(error: unknown): void {\n if (failed || stopped) return;\n failed = true;\n const message = errorMessage(error);\n stats.gpu = \"error\";\n stats.connected = false;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close();\n emitStats();\n postStatus(message, \"error\");\n renderer?.dispose();\n}\n\nself.addEventListener(\"error\", event => {\n event.preventDefault();\n fail(event.error || new Error(event.message));\n});\nself.addEventListener(\"unhandledrejection\", event => {\n event.preventDefault();\n fail(event.reason);\n});\n\n/** At most one state frame, one decode, and one GPU submission are outstanding. */\nfunction scheduleRender() {\n needsRender = true;\n if (scheduled || drawing || processing || failed || stopped || !metadata) return;\n scheduled = true;\n self.requestAnimationFrame(() => {\n scheduled = false;\n if (processing || drawing || failed || stopped) return;\n renderPromise = drawFrame();\n });\n}\n\nasync function drawFrame() {\n if (!needsRender || !metadata || !renderer) return;\n needsRender = false;\n drawing = true;\n const frame = pendingFrame;\n try {\n if (frame) links.prepare(cells, metadata);\n const linkSubmission = links.submission();\n renderer.resize(metadata.columns, metadata.rows, viewport);\n const blink = Math.floor(performance.now() / 600) % 2 === 0;\n const result = renderer.render(cells, metadata, blink, linkSubmission.mask);\n // This is bounded completion/backpressure, not GPU readback or a GPU timing measurement.\n await renderer.idle();\n if (failed || stopped) return;\n lastBlink = blink;\n stats.presentations++;\n stats.rendererCpuMs = result.cpuMs;\n stats.quads = result.quads;\n stats.drawCalls = result.drawCalls;\n stats.warnings = renderer.canvasLimited\n ? [...metadata.warnings, `Canvas resolution capped by the GPU; terminal grid remains ${metadata.columns}x${metadata.rows}`]\n : metadata.warnings;\n if (frame) {\n pendingFrame = undefined;\n frameInFlight = false;\n stats.frames++;\n if (frame.full) stats.fullFrames++;\n stats.revision = frame.revision;\n stats.changedCells += frame.changedCells;\n stats.lastChangedCells = frame.changedCells;\n stats.captureMs = metadata.stats.captureMs;\n stats.workloadBytes = metadata.stats.workloadBytes;\n stats.outputBatches = metadata.stats.outputBatches;\n stats.serverElapsedMs = metadata.stats.elapsedMs;\n stats.columns = metadata.columns;\n stats.rows = metadata.rows;\n stats.mouseTracking = metadata.mouseTracking;\n stats.peer = metadata.peer;\n stats.history = metadata.history;\n const text = screenText(cells, metadata.columns, metadata.rows);\n self.postMessage({\n type: \"geometry\", columns: metadata.columns, rows: metadata.rows,\n cellWidth: metadata.cellWidth, cellHeight: metadata.cellHeight,\n mouseTracking: metadata.mouseTracking, peer: metadata.peer,\n history: metadata.history, revision: frame.revision, title: metadata.title,\n progress: metadata.progress, shellIntegration: metadata.shellIntegration,\n workingDirectory: metadata.workingDirectory, commandMark: metadata.commandMark,\n text, hyperlinks: metadata.hyperlinks, ...links.present(cells, metadata)\n });\n send({ type: \"ack\", revision: frame.revision });\n emitStats(text);\n } else {\n const snapshot = links.snapshot();\n if (snapshot) self.postMessage({ type: \"linkSnapshot\", generation: links.generation, snapshot });\n }\n const acknowledgement = links.acknowledge(linkSubmission.acknowledgement, processing || frameInFlight);\n if (acknowledgement) self.postMessage({ type: \"linkDecorations\", ...acknowledgement });\n } catch (error) {\n fail(error);\n } finally {\n drawing = false;\n if (needsRender && !processing) scheduleRender();\n }\n}\n\nasync function receiveFrame(buffer: ArrayBuffer): Promise {\n if (failed || stopped || !renderer) return;\n if (frameInFlight) throw new Error(\"Server sent a second state frame before acknowledgement\");\n frameInFlight = true;\n processing = true;\n stats.bytesReceived += buffer.byteLength || 0;\n try {\n const frame = decodeFrame(buffer);\n const next = frame.metadata;\n if (!next.full && (next.baseRevision !== localRevision || next.revision <= localRevision ||\n !metadata || next.columns !== metadata.columns || next.rows !== metadata.rows)) {\n stats.discardedFrames++;\n frameInFlight = false;\n // A discarded frame must release the server's one-in-flight gate before resync.\n send({ type: \"ack\", revision: next.revision });\n send({ type: \"resync\" });\n postStatus(`Revision mismatch (local ${localRevision}, base ${next.baseRevision}); requesting a full frame`);\n return;\n }\n await renderPromise;\n if (failed || stopped) return;\n // No blink presentation may reference textures while this resource transaction is in progress.\n await renderer.idle();\n await renderer.updateImages(frame.images, next.retainedImages);\n if (failed || stopped) return;\n const preparationStart = performance.now();\n const nextCells: (TerminalCell | undefined)[] = next.full\n ? new Array(next.columns * next.rows) : cells.slice();\n for (const cell of frame.cells) nextCells[cell.index] = cell;\n renderer.prepareGlyphs(nextCells);\n cells = nextCells;\n metadata = next;\n localRevision = next.revision;\n pendingFrame = { revision: next.revision, full: next.full, changedCells: frame.cells.length };\n hasBlink = cells.some(cell => cell && (cell.attributes & 16) && !(cell.attributes & 64)) ||\n (next.cursor.visible && (next.cursor.shape === 0 || next.cursor.shape % 2 === 1));\n stats.preparationCpuMs = performance.now() - preparationStart;\n needsRender = true;\n } finally {\n processing = false;\n if (needsRender) scheduleRender();\n }\n}\n\nasync function initialize(message: Extract): Promise {\n if (renderer || socket) throw new Error(\"Worker is already initialized\");\n if (typeof self.requestAnimationFrame !== \"function\") {\n throw new Error(\"This browser does not support requestAnimationFrame in a dedicated OffscreenCanvas worker\");\n }\n postStatus(\"Loading terminal font and initializing renderer...\");\n renderer = await TerminalRenderer.create(message.canvas, message.scale, fail, message.font, message.renderer);\n if (failed || stopped) {\n renderer?.dispose();\n return;\n }\n stats.gpu = \"ready\";\n stats.backingScale = message.scale;\n emitStats();\n const rendererName = renderer.backend.kind === \"webgpu\" ? \"WebGPU\" : \"WebGL2\";\n if (renderer.fallbackReason) postStatus(`Using WebGL2: ${renderer.fallbackReason}`);\n postStatus(`${rendererName} ready. Attaching terminal view...`);\n const url = new URL(message.url);\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) {\n throw new Error(\"The terminal WebSocket URL must use ws: or wss:\");\n }\n socket = new WebSocket(url);\n socket.binaryType = \"arraybuffer\";\n socket.addEventListener(\"open\", () => {\n if (failed || stopped) return;\n stats.connected = true;\n self.postMessage({ type: \"connected\" });\n postStatus(`Connected · ${rendererName} worker · server-authoritative cells and graphics`, \"ready\");\n emitStats();\n });\n socket.addEventListener(\"message\", event => {\n if (!(event.data instanceof ArrayBuffer)) {\n fail(new Error(`Expected binary HWT1 frame, received ${String(event.data).slice(0, 200)}`));\n return;\n }\n receiveFrame(event.data).catch(fail);\n });\n // WebSocket errors are followed by close, which carries the browser's actual status.\n // Rejecting mount on error would terminate this worker before that status can be delivered.\n socket.addEventListener(\"close\", event => {\n stats.connected = false;\n if (!failed && !stopped) {\n stats.gpu = \"stopped\";\n stats.fps = 0;\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n renderer?.dispose();\n self.postMessage({ type: \"closed\", details: {\n code: event.code, reason: event.reason, wasClean: event.wasClean\n } });\n postStatus(`View disconnected (${event.code}${event.reason ? `: ${event.reason}` : \"\"}). Attach another view to reconnect.`, \"error\");\n emitStats();\n }\n });\n metricsTimer = setInterval(() => {\n const now = performance.now();\n const seconds = (now - sample.time) / 1000;\n stats.fps = (stats.presentations - sample.presentations) / seconds;\n stats.receivedKBps = (stats.bytesReceived - sample.bytes) / seconds / 1000;\n stats.workloadMBps = Math.max(0, stats.workloadBytes - sample.workload) / seconds / 1000000;\n sample = { time: now, presentations: stats.presentations, bytes: stats.bytesReceived, workload: stats.workloadBytes };\n emitStats();\n }, 1000);\n blinkTimer = setInterval(() => {\n const blinkOn = Math.floor(performance.now() / 600) % 2 === 0;\n if (hasBlink && blinkOn !== lastBlink) scheduleRender();\n }, 100);\n}\n\nself.addEventListener(\"message\", event => {\n const message = event.data;\n if (message.type === \"init\") {\n initialize(message).catch(fail);\n } else if (message.type === \"stop\") {\n stopped = true;\n clearInterval(metricsTimer);\n clearInterval(blinkTimer);\n socket?.close(1000, \"View detached\");\n renderer?.dispose();\n self.close();\n } else if (message.type === \"viewport\" && !failed && !stopped) {\n if (!Number.isFinite(message.width) || !Number.isFinite(message.height) || message.width < 0 || message.height < 0) {\n fail(new Error(\"Invalid mounted viewport dimensions\"));\n return;\n }\n if (viewport?.width === message.width && viewport?.height === message.height) return;\n viewport = { width: message.width, height: message.height };\n scheduleRender();\n } else if (message.type === \"command\" && !failed && !stopped) {\n send(message.command);\n } else if (message.type === \"linkDetection\" && !failed && !stopped) {\n try {\n if (!links.configure(message.enabled, message.generation)) return;\n if (!processing && !drawing && !pendingFrame) {\n const snapshot = links.snapshot();\n if (snapshot) self.postMessage({ type: \"linkSnapshot\", generation: links.generation, snapshot });\n }\n scheduleRender();\n } catch (error) { fail(error); }\n } else if (message.type === \"linkDecorations\" && !failed && !stopped) {\n try {\n if (links.accept(message.revision, message.generation, message.serial, message.ranges,\n processing || frameInFlight || !!pendingFrame, message.underlineStyle)) scheduleRender();\n } catch (error) { fail(error); }\n }\n});\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts index 8b78376fd7e..f468573fda1 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts @@ -1,3 +1,5 @@ +import type { TerminalLinkOptions, TerminalLinkDetectionError } from "./link-types.js"; +export type * from "./link-types.js"; /** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */ export interface TerminalGrid { columns: number; @@ -317,6 +319,12 @@ export interface WebTerminalOptions extends InputPolicyOptions { url: string | URL; /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */ workerUrl?: string | URL; + /** Optional isolated regex worker entry, resolved against the page URL. */ + linkDetectionWorkerUrl?: string | URL; + /** Per-view link interaction. Detection is opt-in; omitted preserves legacy OSC 8 navigation. */ + links?: false | TerminalLinkOptions; + /** Detection failures are local to this feature and also reported through onStatus. */ + onLinkDetectionError?: (error: TerminalLinkDetectionError) => void; signal?: AbortSignal; scale?: number | "auto"; /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */ @@ -417,6 +425,8 @@ export interface WebTerminalHandle { * Hosts must separately enforce permissions on their per-view Hwt1PresentationAdapter. */ setReadOnly(readOnly: boolean): void; + /** Atomically replaces link options, cancelling active link gestures and stale detections. */ + setLinks(options: false | TerminalLinkOptions): void; focus(): void; requestPrimary(): void; resize(columns: number, rows: number): void; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map index 30efa8a73d9..9da8ebfb5aa 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map index 42c761baa44..cea0fe85acc 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\n/** Native WebSocket close details, not an assertion that the terminal workload completed. */\nexport interface TerminalCloseDetails {\n /** RFC 6455 status reported by the browser, including 1006 for abnormal loss without a close frame. */\n readonly code: number;\n /** Peer-provided close reason, or \"\". Treat as untrusted text. */\n readonly reason: string;\n /** Whether the browser observed a clean WebSocket closing handshake, not workload success. */\n readonly wasClean: boolean;\n}\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\n/** Application-reported OSC 9;4 indicator, independent of shell execution. */\nexport type TerminalProgressState = \"none\" | \"normal\" | \"error\" | \"indeterminate\" | \"warning\";\n/** Immutable current progress; a hidden or indeterminate indicator has no percentage. */\nexport interface TerminalProgress {\n readonly state: TerminalProgressState;\n readonly percentage: number | null;\n}\n/** Last reported OSC 133 phase. Unknown does not mean idle; commandLine is not execution. */\nexport type TerminalShellIntegrationPhase = \"unknown\" | \"prompt\" | \"commandLine\" | \"executing\" | \"finished\";\n/** Current shell phase and latest reported completion status, not command history. */\nexport interface TerminalShellIntegration {\n readonly phase: TerminalShellIntegrationPhase;\n /** Null means no reported status, not success. Preserved across the next prompt/command. */\n readonly lastExitCode: number | null;\n}\n/** Last reported OSC 7 working directory, or all-null before any is reported. */\nexport interface TerminalWorkingDirectory {\n /** Raw URI as reported by the shell (typically `file://`), or null. */\n readonly uri: string | null;\n /** Authority from the URI; \"\" for a local/unqualified authority. Null when uri is null. */\n readonly host: string | null;\n /** Decoded filesystem path from the URI. Null when uri is null. */\n readonly path: string | null;\n}\n/**\n * Latest OSC 133 marker, distinct from {@link TerminalShellIntegration}: it additionally carries\n * any raw trailing `key=value` parameters (e.g. a `cmdline_url` extension on marker C). This is\n * the single most-recent marker only — the server does not transport a mark history or event\n * log over this wire; consumers that want their own history should accumulate distinct values\n * from {@link WebTerminalOptions.onCommandMarkChange} themselves.\n */\nexport interface TerminalCommandMark {\n readonly phase: TerminalShellIntegrationPhase;\n readonly exitCode: number | null;\n /** Verbatim `key=value[;key=value...]` trailing the marker, or null when none was present. */\n readonly rawParameters: string | null;\n}\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n /** Initial per-view input policy. Change it later with setReadOnly; not a server authorization boundary. */\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n /**\n * Receives the native WebSocket close details once, including connection failures and closes\n * before the first frame. The view is disconnected before this callback; a pending mount\n * rejects after notification. No callback is synthesized for abort, disposal, initialization\n * failure, or mount timeout, and none runs after disposal. This client never reconnects\n * automatically. Interpret application close codes in the host; even 1000 is not proof of\n * workload completion. Callback exceptions reach the host and are not retried.\n */\n onClose?: (details: TerminalCloseDetails) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n /**\n * Receives the first authoritative presented title (including \"\") before mount resolves,\n * then distinct presented changes. The title getter is updated first. Titles are untrusted\n * text; render with textContent, not HTML. No notifications after disposal.\n */\n onTitleChange?: (title: string) => void;\n /**\n * Receives the first authoritative presented progress before mount resolves, then distinct\n * presented changes. Both activity getters update before either callback. Intermediate\n * states may coalesce; this is not a callback for every OSC sequence. None hides the indicator.\n * No notifications after disposal; connection loss does not manufacture a progress clear.\n */\n onProgressChange?: (progress: TerminalProgress) => void;\n /**\n * Receives the first authoritative presented shell state before mount resolves, then distinct\n * presented changes. This is not a lossless command-start/finish stream: entire commands may\n * occur between frames. Replays provide current state, never synthetic command executions.\n */\n onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void;\n /**\n * Receives the first authoritative presented working directory before mount resolves, then\n * distinct presented changes. All-null means none reported yet; a malformed or non-`file` OSC 7\n * report does not change presented state. No notifications after disposal.\n */\n onWorkingDirectoryChange?: (workingDirectory: TerminalWorkingDirectory) => void;\n /**\n * Receives the first authoritative presented command mark before mount resolves (null if none\n * yet reported), then distinct presented changes. Only the latest marker is transmitted, not a\n * history; entire commands may occur between frames. No notifications after disposal.\n */\n onCommandMarkChange?: (commandMark: TerminalCommandMark | null) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n /** Whether this view blocks application input, resize, and primary takeover. */\n readonly readOnly: boolean;\n /** Current presented workload title, or \"\" when unset/cleared. Retained on disconnect/dispose. */\n readonly title: string;\n /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */\n readonly progress: TerminalProgress;\n /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */\n readonly shellIntegration: TerminalShellIntegration;\n /** Current presented working directory, all-null initially. Retained on disconnect/dispose. */\n readonly workingDirectory: TerminalWorkingDirectory;\n /** Latest presented command mark, or null if none reported yet. Retained on disconnect/dispose. */\n readonly commandMark: TerminalCommandMark | null;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n /**\n * Changes this view's input policy without remounting or changing peer roles.\n * Output, history, selection and copying remain available. Cancels active gestures,\n * pending composition and clipboard paste; already dispatched commands cannot be recalled.\n * Hosts must separately enforce permissions on their per-view Hwt1PresentationAdapter.\n */\n setReadOnly(readOnly: boolean): void;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { TerminalLinkOptions, TerminalLinkDetectionError } from \"./link-types.js\";\nexport type * from \"./link-types.js\";\n\n/** Logical terminal dimensions, confirmed by the producer rather than reflowed locally. */\nexport interface TerminalGrid { columns: number; rows: number }\nexport interface TerminalSize { width: number; height: number }\nexport interface TerminalPoint { x: number; y: number }\nexport type MouseTrackingMode = 0 | 9 | 1000 | 1002 | 1003;\nexport interface TerminalGeometry extends TerminalGrid {\n cellWidth: number;\n cellHeight: number;\n mouseTracking: MouseTrackingMode;\n}\nexport interface TerminalPeer {\n id: string | null;\n primaryId: string | null;\n isPrimary: boolean;\n}\n/** Font size is an integer from 8 to 32; fixed grids allow 20–300 columns and 10–100 rows. */\nexport type TerminalSizing =\n | { mode: \"auto\"; fontSize?: number }\n | { mode: \"fixed\"; fontSize?: number; columns: number; rows: number };\nexport type TerminalSizingState =\n | { mode: \"auto\"; fontSize: number }\n | { mode: \"fixed\"; fontSize: number; columns: number; rows: number };\nexport interface TerminalFontFace { url: string; weight?: string; style?: string }\n/** Without faces, a non-generic family must be installed locally in the worker's environment. */\nexport interface TerminalFont { family: string; faces?: readonly TerminalFontFace[] }\nexport type TerminalBuffer = \"main\" | \"alternate\";\nexport type SelectionMode = \"character\" | \"word\" | \"line\" | \"rectangle\";\nexport interface SelectionRange { row: number; startColumn: number; endColumn: number }\nexport type TerminalViewport = (\n | { available: true; generation: string; buffer: TerminalBuffer; totalRows: number;\n liveTop: number; top: number; requestId: number; rowIds: readonly string[]; revision: number }\n | { available: false; generation?: undefined; buffer?: undefined; totalRows?: undefined;\n liveTop?: undefined; top?: undefined; requestId?: undefined; rowIds?: readonly string[]; revision?: undefined }\n) & { following: boolean; pending: boolean; followTail: boolean; offset: number };\nexport type TerminalSelection = (\n | { status: \"valid\"; text: string; requestId: number; revision: number }\n | { status: \"none\" | \"invalidated\"; text: null; requestId: number; revision: number }\n | { status: \"pending\"; text: null; requestId: number; revision?: undefined }\n | { status: \"unavailable\"; text: null; requestId?: undefined; revision?: undefined }\n) & {\n mode: SelectionMode;\n ranges: readonly Readonly[];\n canExtend?: boolean;\n message: string;\n active: boolean;\n pending: boolean;\n copying: boolean;\n copyError: string;\n};\nexport type TerminalStatusLevel = \"info\" | \"ready\" | \"error\";\n/** Native WebSocket close details, not an assertion that the terminal workload completed. */\nexport interface TerminalCloseDetails {\n /** RFC 6455 status reported by the browser, including 1006 for abnormal loss without a close frame. */\n readonly code: number;\n /** Peer-provided close reason, or \"\". Treat as untrusted text. */\n readonly reason: string;\n /** Whether the browser observed a clean WebSocket closing handshake, not workload success. */\n readonly wasClean: boolean;\n}\nexport type TerminalRendererKind = \"webgpu\" | \"webgl2\";\n/** Auto prefers WebGPU and falls back to WebGL2 for capability/device acquisition failures. */\nexport type TerminalRendererPreference = \"auto\" | TerminalRendererKind;\n/** Metrics are initially empty; individual fields appear as initialization and presentation proceed. */\nexport interface TerminalStats {\n /** Active backend; absent until renderer initialization completes. */\n renderer?: TerminalRendererKind;\n /** Why auto selected WebGL2 instead of WebGPU; absent for explicit selection or WebGPU. */\n rendererFallbackReason?: string;\n revision?: number; fullFrames?: number; frames?: number; presentations?: number;\n changedCells?: number; lastChangedCells?: number; discardedFrames?: number;\n imageCount?: number; textureBytes?: number; atlasGlyphs?: number; atlasBytes?: number;\n bytesReceived?: number; imageUploadBytes?: number; imagePayloadBytes?: number;\n gpu?: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected?: boolean; warnings?: readonly string[];\n fps?: number; receivedKBps?: number; workloadMBps?: number;\n captureMs?: number; rendererCpuMs?: number; preparationCpuMs?: number;\n workloadBytes?: number; outputBatches?: number; serverElapsedMs?: number;\n quads?: number; drawCalls?: number; columns?: number; rows?: number; mouseTracking?: MouseTrackingMode;\n peer?: TerminalPeer; fontFamily?: string; rasterScale?: number; backingScale?: number;\n backingWidth?: number; backingHeight?: number; atlasRebuilds?: number;\n glyphUploadBytes?: number; instanceBufferBytes?: number;\n}\nexport interface InputModifiers { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }\nexport type PointerButton = \"left\" | \"middle\" | \"right\";\n/** Input intents contain no browser event. Returning a route controls browser cancellation. */\nexport type TerminalInput =\n | ({ type: \"key\"; key: string; code: string; repeat: boolean } & InputModifiers)\n | ({ type: \"pointer\"; button: PointerButton; point: Readonly } & InputModifiers)\n | ({ type: \"wheel\"; deltaX: number; deltaY: number; deltaMode: number;\n point: Readonly | null } & InputModifiers)\n | { type: \"paste\" | \"text\"; text: string };\nexport type InputRouteValue = \"continue\" | \"consume\" | \"application\" | \"browser\";\nexport type TerminalActionName = \"copySelection\" | \"pasteClipboard\" | \"copyOrPaste\"\n | \"clearSelection\" | \"scrollToLive\" | \"scrollLines\";\nexport interface CopySelectionOptions { clear?: boolean }\nexport interface TerminalInputContext {\n readonly terminal: WebTerminalHandle;\n readonly selection: TerminalSelection;\n readonly viewport: TerminalViewport;\n readonly buffer: TerminalBuffer | null;\n readonly mouseCaptured: boolean;\n readonly historical: boolean;\n readonly readOnly: boolean;\n readonly connected: boolean;\n readonly peer: TerminalPeer;\n}\n/** Custom actions validate their own arguments and may complete asynchronously. */\nexport type InputActionHandler =\n (context: TerminalInputContext, args: unknown, input: Readonly | undefined) => unknown;\nexport type InputDecision =\n | { route: InputRouteValue; action?: never; args?: never }\n | { action: string | InputActionHandler; args?: unknown; route?: never };\nexport type InputInterceptor =\n (input: Readonly, context: TerminalInputContext) => InputRouteValue | InputDecision | undefined;\nexport type InputBinding = {\n id: string;\n match: (input: Readonly, context: TerminalInputContext) => boolean;\n when?: (context: TerminalInputContext, input: Readonly) => boolean;\n remove?: false;\n} & InputDecision;\nexport type InputBindingOverride = InputBinding | { id: string; remove: true };\nexport interface InputPolicyOptions {\n inputBindings?: readonly InputBindingOverride[];\n onInput?: InputInterceptor;\n actions?: Readonly>;\n}\n/** Built-ins have typed arguments/results; custom names and callbacks own their argument validation. */\nexport interface RunTerminalAction {\n (action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n (action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n (action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n (action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n (action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n}\nexport interface SelectionRectangle { left: number; top: number; width: number; height: number }\nexport interface SelectionUIState {\n readonly selection: Readonly;\n readonly viewport: Readonly;\n readonly geometry: Readonly;\n readonly canvasSize: Readonly;\n readonly connected: boolean;\n readonly readOnly: boolean;\n}\n/** A frozen snapshot; preventDefault() synchronously to replace the built-in Copy button. */\nexport interface SelectionUIDetail extends SelectionUIState {\n readonly overlay: HTMLDivElement;\n readonly signal: AbortSignal;\n readonly runAction: RunTerminalAction;\n readonly rects: readonly Readonly[];\n}\nexport type SelectionUIEvent = CustomEvent;\n/** Application-reported OSC 9;4 indicator, independent of shell execution. */\nexport type TerminalProgressState = \"none\" | \"normal\" | \"error\" | \"indeterminate\" | \"warning\";\n/** Immutable current progress; a hidden or indeterminate indicator has no percentage. */\nexport interface TerminalProgress {\n readonly state: TerminalProgressState;\n readonly percentage: number | null;\n}\n/** Last reported OSC 133 phase. Unknown does not mean idle; commandLine is not execution. */\nexport type TerminalShellIntegrationPhase = \"unknown\" | \"prompt\" | \"commandLine\" | \"executing\" | \"finished\";\n/** Current shell phase and latest reported completion status, not command history. */\nexport interface TerminalShellIntegration {\n readonly phase: TerminalShellIntegrationPhase;\n /** Null means no reported status, not success. Preserved across the next prompt/command. */\n readonly lastExitCode: number | null;\n}\n/** Last reported OSC 7 working directory, or all-null before any is reported. */\nexport interface TerminalWorkingDirectory {\n /** Raw URI as reported by the shell (typically `file://`), or null. */\n readonly uri: string | null;\n /** Authority from the URI; \"\" for a local/unqualified authority. Null when uri is null. */\n readonly host: string | null;\n /** Decoded filesystem path from the URI. Null when uri is null. */\n readonly path: string | null;\n}\n/**\n * Latest OSC 133 marker, distinct from {@link TerminalShellIntegration}: it additionally carries\n * any raw trailing `key=value` parameters (e.g. a `cmdline_url` extension on marker C). This is\n * the single most-recent marker only — the server does not transport a mark history or event\n * log over this wire; consumers that want their own history should accumulate distinct values\n * from {@link WebTerminalOptions.onCommandMarkChange} themselves.\n */\nexport interface TerminalCommandMark {\n readonly phase: TerminalShellIntegrationPhase;\n readonly exitCode: number | null;\n /** Verbatim `key=value[;key=value...]` trailing the marker, or null when none was present. */\n readonly rawParameters: string | null;\n}\nexport interface WebTerminalOptions extends InputPolicyOptions {\n url: string | URL;\n /** Optional module-worker entry, resolved against the page URL. Defaults to the bundled worker. */\n workerUrl?: string | URL;\n /** Optional isolated regex worker entry, resolved against the page URL. */\n linkDetectionWorkerUrl?: string | URL;\n /** Per-view link interaction. Detection is opt-in; omitted preserves legacy OSC 8 navigation. */\n links?: false | TerminalLinkOptions;\n /** Detection failures are local to this feature and also reported through onStatus. */\n onLinkDetectionError?: (error: TerminalLinkDetectionError) => void;\n signal?: AbortSignal;\n scale?: number | \"auto\";\n /** Mount-time backend selection. Defaults to auto; explicit modes never fall back. */\n renderer?: TerminalRendererPreference;\n font?: TerminalFont;\n sizing?: TerminalSizing;\n label?: string;\n /** Initial per-view input policy. Change it later with setReadOnly; not a server authorization boundary. */\n readOnly?: boolean;\n onStatus?: (message: string, level: TerminalStatusLevel) => void;\n /**\n * Receives the native WebSocket close details once, including connection failures and closes\n * before the first frame. The view is disconnected before this callback; a pending mount\n * rejects after notification. No callback is synthesized for abort, disposal, initialization\n * failure, or mount timeout, and none runs after disposal. This client never reconnects\n * automatically. Interpret application close codes in the host; even 1000 is not proof of\n * workload completion. Callback exceptions reach the host and are not retried.\n */\n onClose?: (details: TerminalCloseDetails) => void;\n onGeometry?: (geometry: TerminalGeometry) => void;\n onSizingChange?: (sizing: TerminalSizingState) => void;\n onRoleChange?: (peer: TerminalPeer) => void;\n /**\n * Receives the first authoritative presented title (including \"\") before mount resolves,\n * then distinct presented changes. The title getter is updated first. Titles are untrusted\n * text; render with textContent, not HTML. No notifications after disposal.\n */\n onTitleChange?: (title: string) => void;\n /**\n * Receives the first authoritative presented progress before mount resolves, then distinct\n * presented changes. Both activity getters update before either callback. Intermediate\n * states may coalesce; this is not a callback for every OSC sequence. None hides the indicator.\n * No notifications after disposal; connection loss does not manufacture a progress clear.\n */\n onProgressChange?: (progress: TerminalProgress) => void;\n /**\n * Receives the first authoritative presented shell state before mount resolves, then distinct\n * presented changes. This is not a lossless command-start/finish stream: entire commands may\n * occur between frames. Replays provide current state, never synthetic command executions.\n */\n onShellIntegrationChange?: (shellIntegration: TerminalShellIntegration) => void;\n /**\n * Receives the first authoritative presented working directory before mount resolves, then\n * distinct presented changes. All-null means none reported yet; a malformed or non-`file` OSC 7\n * report does not change presented state. No notifications after disposal.\n */\n onWorkingDirectoryChange?: (workingDirectory: TerminalWorkingDirectory) => void;\n /**\n * Receives the first authoritative presented command mark before mount resolves (null if none\n * yet reported), then distinct presented changes. Only the latest marker is transmitted, not a\n * history; entire commands may occur between frames. No notifications after disposal.\n */\n onCommandMarkChange?: (commandMark: TerminalCommandMark | null) => void;\n onStats?: (stats: TerminalStats, text: string | undefined) => void;\n onViewportChange?: (viewport: TerminalViewport) => void;\n onSelectionChange?: (selection: TerminalSelection) => void;\n onInputError?: (error: Error) => void;\n /** Must return undefined synchronously; async handlers cannot claim default UI ownership. */\n onSelectionUI?: (event: SelectionUIEvent) => undefined;\n}\n/** Owns only the appended element and browser connection, not the server terminal. */\nexport interface WebTerminalHandle {\n readonly element: HTMLDivElement;\n readonly geometry: TerminalGeometry;\n readonly peer: TerminalPeer;\n readonly connected: boolean;\n /** Whether this view blocks application input, resize, and primary takeover. */\n readonly readOnly: boolean;\n /** Current presented workload title, or \"\" when unset/cleared. Retained on disconnect/dispose. */\n readonly title: string;\n /** Current presented progress, initially none. Retained on disconnect/dispose; check connected. */\n readonly progress: TerminalProgress;\n /** Current presented shell state, initially unknown. Retained on disconnect/dispose. */\n readonly shellIntegration: TerminalShellIntegration;\n /** Current presented working directory, all-null initially. Retained on disconnect/dispose. */\n readonly workingDirectory: TerminalWorkingDirectory;\n /** Latest presented command mark, or null if none reported yet. Retained on disconnect/dispose. */\n readonly commandMark: TerminalCommandMark | null;\n readonly stats: TerminalStats;\n readonly screenText: string;\n readonly sizing: TerminalSizingState;\n readonly inputBindings: InputBinding[];\n readonly inputContext: TerminalInputContext;\n readonly viewport: TerminalViewport;\n readonly selection: TerminalSelection;\n runAction: RunTerminalAction;\n scrollLines(delta: number): void;\n scrollToLive(): void;\n clearSelection(): void;\n refreshSelectionUI(): void;\n copySelection(options?: CopySelectionOptions): Promise;\n paste(text: string): void;\n pasteClipboard(): Promise;\n /**\n * Changes this view's input policy without remounting or changing peer roles.\n * Output, history, selection and copying remain available. Cancels active gestures,\n * pending composition and clipboard paste; already dispatched commands cannot be recalled.\n * Hosts must separately enforce permissions on their per-view Hwt1PresentationAdapter.\n */\n setReadOnly(readOnly: boolean): void;\n /** Atomically replaces link options, cancelling active link gestures and stale detections. */\n setLinks(options: false | TerminalLinkOptions): void;\n focus(): void;\n requestPrimary(): void;\n resize(columns: number, rows: number): void;\n setSizing(sizing: TerminalSizing): void;\n resync(): void;\n dispose(): void;\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts index f8f184fb48e..bffbc1b6ffb 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts @@ -1,4 +1,4 @@ -import type { CopySelectionOptions, InputActionHandler, InputBinding, TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport, TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark, WebTerminalHandle, WebTerminalOptions } from "./types.js"; +import type { CopySelectionOptions, InputActionHandler, InputBinding, TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport, TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark, TerminalLinkOptions, WebTerminalHandle, WebTerminalOptions } from "./types.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; /** * First-party HWT1 client. Owns only the element it appends, not the caller's @@ -46,6 +46,7 @@ export declare class WebTerminal implements WebTerminalHandle { pasteClipboard(): Promise; /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */ setReadOnly(readOnly: boolean): void; + setLinks(options: false | TerminalLinkOptions): void; focus(): void; /** Request HMP1 primary explicitly; peer notifications confirm the result. */ requestPrimary(): void; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map index cace0ea2b6d..efd0b1e0b5f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"web-terminal.d.ts","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js index f826cbb5772..e8c5f3f69ce 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js @@ -8,6 +8,8 @@ import { InputPolicy, InputRoute, TerminalAction, inputModifiers } from "./input import { assertCommandSize } from "./protocol.js"; import { SelectionUI } from "./selection-ui.js"; import { Hyperlinks } from "./hyperlinks.js"; +import { LinkDetection } from "./link-detection.js"; +import { normalizeLinks } from "./link-options.js"; import { errorMessage, isRecord } from "./validation.js"; export { InputRoute, TerminalAction, defaultInputBindings } from "./input-policy.js"; function requiredElement(root, selector, type) { @@ -71,6 +73,18 @@ export class WebTerminal { #selectionUIError = ""; #canvasSize = { width: 0, height: 0 }; #hyperlinks = new Hyperlinks(); + #links; + #linkDetector; + #linkGeneration = 1; + #linkRevision = 0; + #linkSerial = 0; + #linkSnapshot; + #osc8Rows = new Map(); + #detectedLinks = []; + #presentedLinks = []; + #detectedRows = new Map(); + #hoveredLinkId; + #pendingLinks; /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */ static async mount(container, options) { if (!(container instanceof HTMLElement)) @@ -105,11 +119,39 @@ export class WebTerminal { if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) && (typeof options.workerUrl !== "string" || !options.workerUrl.trim())) throw new TypeError("workerUrl must be a nonempty URL string or URL"); + if (options.linkDetectionWorkerUrl !== undefined && !(options.linkDetectionWorkerUrl instanceof URL) && + (typeof options.linkDetectionWorkerUrl !== "string" || !options.linkDetectionWorkerUrl.trim())) + throw new TypeError("linkDetectionWorkerUrl must be a nonempty URL string or URL"); + if (options.onLinkDetectionError !== undefined && typeof options.onLinkDetectionError !== "function") + throw new TypeError("onLinkDetectionError must be a function"); if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== "function" || options.onSelectionUI.constructor.name === "AsyncFunction")) throw new TypeError("onSelectionUI must be a synchronous event handler"); this.#policy = new InputPolicy(options); this.#actions = new Map(Object.entries(options.actions ?? {})); + this.#links = normalizeLinks(options.links, new Set(this.#actions.keys())); + this.#linkDetector = new LinkDetection({ + workerUrl: options.linkDetectionWorkerUrl === undefined ? undefined : new URL(options.linkDetectionWorkerUrl, location.href), + actions: new Set(this.#actions.keys()), + onChange: (revision, links) => { + if (this.#disposed || !this.#connected || revision !== this.#linkRevision) + return; + this.#detectedLinks = links; + this.#replacePresentedLinks([]); + this.#requestLinkDecorations(); + }, + onError: error => { + if (this.#disposed) + return; + try { + this.#options.onStatus?.(`Link detection: ${error.message}`, "error"); + } + finally { + this.#options.onLinkDetectionError?.(error); + } + }, + }); + this.#linkDetector.configure(this.#links ? this.#links.detection : false); this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged()); this.element = document.createElement("div"); this.element.className = "hex1b-terminal"; @@ -236,15 +278,13 @@ export class WebTerminal { end: cancelled => this.#history.endGesture(cancelled), resolve: input => this.#resolveInput(input), execute: (decision, input) => this.#executeInputAction(decision, input), - hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null, - openHyperlink: uri => { - try { - window.open(uri, "_blank", "noopener,noreferrer"); - } - catch (error) { - this.#actionFailed(error); - } - } + hyperlink: point => this.#linkAt(point), + hoverHyperlink: link => { + this.#hoveredLinkId = link?.id; + if (this.#links && this.#links.detection && this.#links.detection.decoration === "hover") + this.#requestLinkDecorations(); + }, + openHyperlink: (link, input) => this.#activateLink(link, input), }); this.#bindKeyboard(); requiredElement(this.#inspection, ".return-live", HTMLButtonElement).addEventListener("click", () => { @@ -287,6 +327,7 @@ export class WebTerminal { const canvas = this.#canvas.transferControlToOffscreen(); this.#post({ type: "init", canvas, url: url.href, scale, font, renderer: this.#renderer }, [canvas]); + this.#postLinkConfiguration(); } #message(message) { if (this.#disposed) @@ -317,6 +358,15 @@ export class WebTerminal { this.#options.onStatus?.(message.message, message.level); } else if (message.type === "geometry") { + this.#linkRevision = message.revision; + this.#osc8Rows.clear(); + for (const range of message.hyperlinks) { + const row = this.#osc8Rows.get(range.row) ?? []; + row.push(range); + this.#osc8Rows.set(range.row, row); + } + this.#replacePresentedLinks([]); + this.#pendingLinks = undefined; const first = !this.#hasGeometry; const geometryChanged = first || ["columns", "rows", "cellWidth", "cellHeight", "mouseTracking"] .some(field => this.#geometry[field] !== message[field]); @@ -346,6 +396,15 @@ export class WebTerminal { this.#screenText = message.text; this.#history.accept(message.history, message.revision); } + if (message.linkGeneration === this.#linkGeneration) { + if (message.linkSnapshot) + this.#acceptLinkSnapshot(message.linkSnapshot); + else { + if (this.#linkSnapshot) + this.#linkSnapshot = { ...this.#linkSnapshot, revision: message.revision }; + this.#linkDetector.advance(message.revision); + } + } this.#mouse?.update(message.columns, message.rows, message.mouseTracking); if (geometryChanged) this.#options.onGeometry?.(this.geometry); @@ -384,6 +443,20 @@ export class WebTerminal { this.#options.onCommandMarkChange?.(this.commandMark); } } + else if (message.type === "linkSnapshot") { + if (message.generation === this.#linkGeneration && message.snapshot.revision === this.#linkRevision) + this.#acceptLinkSnapshot(message.snapshot); + } + else if (message.type === "linkDecorations") { + const pending = this.#pendingLinks; + if (this.#connected && pending && pending.serial === message.serial && + pending.generation === message.generation && message.generation === this.#linkGeneration && + pending.revision === message.revision && message.revision === this.#linkRevision) { + this.#pendingLinks = undefined; + this.#replacePresentedLinks(pending.links); + this.#mouse?.refresh(); + } + } else if (message.type === "history") { this.#screenText = message.text; this.#history.accept(message.history, message.revision); @@ -671,6 +744,122 @@ export class WebTerminal { this.focus(); this.#selectionUI?.refresh(); } + setLinks(options) { + if (this.#disposed) + throw new Error("Terminal view is disposed"); + if (options === undefined) + throw new TypeError("Link options must be an object or false"); + const next = normalizeLinks(options, new Set(this.#actions.keys())); + this.#links = next; + this.#linkGeneration++; + this.#detectedLinks = []; + this.#replacePresentedLinks([]); + this.#pendingLinks = undefined; + this.#linkSnapshot = undefined; + this.#hoveredLinkId = undefined; + this.#linkDetector.configure(next ? next.detection : false); + this.#mouse?.cancel(); + this.#mouse?.refresh(); + this.#postLinkConfiguration(); + } + #postLinkConfiguration() { + const enabled = !!(this.#links && (this.#links.osc8 || + (this.#links.detection && this.#links.detection.rules.some(rule => rule.enabled !== false)))); + this.#post({ type: "linkDetection", enabled, generation: this.#linkGeneration }); + } + #acceptLinkSnapshot(snapshot) { + this.#linkSnapshot = snapshot; + this.#detectedLinks = []; + this.#replacePresentedLinks([]); + this.#pendingLinks = undefined; + this.#linkDetector.update(snapshot); + this.#mouse?.refresh(); + } + #detectedPointerId(link) { + return `detected/${this.#linkGeneration}/${this.#linkRevision}/${link.id}`; + } + #replacePresentedLinks(links) { + this.#presentedLinks = links; + this.#detectedRows.clear(); + for (const link of links) { + for (const range of link.activation.ranges) { + const row = this.#detectedRows.get(range.row) ?? []; + row.push({ startColumn: range.startColumn, endColumn: range.endColumn, link }); + this.#detectedRows.set(range.row, row); + } + } + } + #requestLinkDecorations() { + if (!this.#worker || !this.#connected || !this.#linkRevision) + return; + const detection = this.#links && this.#links.detection; + if (!detection) + return; + const visible = detection.decoration === "none" ? [] : detection.decoration === "hover" + ? this.#detectedLinks.filter(link => this.#detectedPointerId(link) === this.#hoveredLinkId) + : this.#detectedLinks; + this.#pendingLinks = { revision: this.#linkRevision, generation: this.#linkGeneration, + serial: ++this.#linkSerial, links: this.#detectedLinks }; + this.#post({ type: "linkDecorations", revision: this.#linkRevision, generation: this.#linkGeneration, + serial: this.#linkSerial, ranges: visible.flatMap(link => link.activation.ranges), + underlineStyle: detection.underlineStyle ?? "solid" }); + } + #linkAt(point) { + if (!this.#connected || this.viewport.pending || !this.#links) + return null; + const osc8 = this.#osc8Rows.get(point.y)?.find(range => point.x >= range.startColumn && point.x < range.endColumn); + if (osc8) { + if (this.#links.osc8 === false) + return null; + const target = this.#links.osc8 + ? (!/[\u0000-\u0020\u007f]/u.test(osc8.uri) && URL.canParse(osc8.uri) ? osc8.uri : null) + : this.#hyperlinks.at(point); + if (!target || (this.#links.osc8 && this.#linkSnapshot?.revision !== this.#linkRevision)) + return null; + return { id: `osc8/${this.#linkGeneration}/${this.#linkRevision}/${osc8.row}/${osc8.startColumn}/${osc8.endColumn}`, + target, activation: "modifierClick" }; + } + const detection = this.#links.detection; + if (!detection) + return null; + const link = this.#detectedRows.get(point.y)?.find(range => point.x >= range.startColumn && point.x < range.endColumn)?.link; + return link ? { id: this.#detectedPointerId(link), target: link.activation.target, + activation: detection.activation ?? "modifierClick" } : null; + } + #activateLink(pointer, input) { + if (input.type !== "pointer" || this.#linkAt(input.point)?.id !== pointer.id || !this.#links) + return; + const detected = this.#presentedLinks.find(link => this.#detectedPointerId(link) === pointer.id); + if (detected) { + this.#performAction(detected.action, detected.activation, input).catch(error => this.#actionFailed(error)); + return; + } + if (!this.#links.osc8) { + try { + window.open(pointer.target, "_blank", "noopener,noreferrer"); + } + catch (error) { + this.#actionFailed(error); + } + return; + } + const range = this.#osc8Rows.get(input.point.y)?.find(range => input.point.x >= range.startColumn && input.point.x < range.endColumn); + const snapshot = this.#linkSnapshot; + if (!range || !snapshot) + return; + let text = ""; + for (let column = range.startColumn; column < range.endColumn; column++) { + const cell = snapshot.cells[range.row * snapshot.columns + column]; + if (cell?.width && !(cell.attributes & 64)) + text += cell.text; + } + const activation = Object.freeze({ + source: "osc8", ruleId: null, kind: "uri", target: pointer.target, text, + revision: this.#linkRevision, + ranges: Object.freeze([Object.freeze({ row: range.row, startColumn: range.startColumn, endColumn: range.endColumn })]), + }); + this.#performAction(this.#links.osc8.action, activation, input).catch(error => this.#actionFailed(error)); + } #forwardInput(input) { if (input.type === "key") { if (input.meta) @@ -822,6 +1011,12 @@ export class WebTerminal { this.#inputSerial++; this.#connected = false; this.#hyperlinks.update([]); + this.#osc8Rows.clear(); + this.#detectedLinks = []; + this.#replacePresentedLinks([]); + this.#linkSnapshot = undefined; + this.#pendingLinks = undefined; + this.#linkDetector.clear(); if (this.#input) this.#input.disabled = true; this.#mouse?.update(1, 1, 0); @@ -842,6 +1037,7 @@ export class WebTerminal { return; this.#disposed = true; this.#disconnect(); + this.#linkDetector.dispose(); this.#ready.reject(new DOMException("Terminal view was disposed", "AbortError")); clearTimeout(this.#readyTimer); clearTimeout(this.#compositionTimer); diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map index 661808d7e7c..51dd24e5e91 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/web-terminal.js.map @@ -1 +1 @@ -{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC/F,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAC5F,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAQ7C,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAErF,SAAS,eAAe,CAAoB,IAAgB,EAAE,QAAgB,EAAE,IAAiB;IAC/F,MAAM,OAAO,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IAC7C,IAAI,CAAC,CAAC,OAAO,YAAY,IAAI,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,QAAQ,EAAE,CAAC,CAAC;IACzF,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;GAGG;AACH,MAAM,OAAO,WAAW;IACb,OAAO,CAAiB;IACjC,QAAQ,CAAqB;IAC7B,SAAS,CAA6B;IACtC,8EAA8E;IAC9E,OAAO,CAAU;IACjB,QAAQ,CAAkB;IAC1B,OAAO,CAAqB;IAC5B,MAAM,CAAuB;IAC7B,MAAM,CAA2B;IACjC,SAAS,CAA6B;IACtC,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACnC,KAAK,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IAChC,OAAO,GAAG,eAAe,EAAE,CAAC;IAC5B,SAAS,GAAqB,EAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,aAAa,EAAE,CAAC,EAAE,CAAC;IACzG,KAAK,GAAiB,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IACtE,UAAU,GAAG,KAAK,CAAC;IACnB,OAAO,GAAG,KAAK,CAAC;IAChB,SAAS,CAAU;IACnB,SAAS,GAAG,KAAK,CAAC;IAClB,YAAY,GAAG,KAAK,CAAC;IACrB,YAAY,CAA4C;IACxD,cAAc,CAAqB;IACnC,iBAAiB,CAA4C;IAC7D,iBAAiB,CAA2B;IAC5C,MAAM,GAAG,OAAO,CAAC,aAAa,EAAe,CAAC;IAC9C,WAAW,CAA4C;IACvD,MAAM,GAAkB,EAAE,CAAC;IAC3B,WAAW,GAAG,EAAE,CAAC;IACjB,MAAM,GAAG,EAAE,CAAC;IACZ,SAAS,GAAG,KAAK,CAAC;IAClB,SAAS,GAAqB,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;IAClE,iBAAiB,GAA6B,EAAE,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC;IACvF,iBAAiB,GAA6B,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IACpF,YAAY,GAA+B,IAAI,CAAC;IAChD,YAAY,GAAG,KAAK,CAAC;IACrB,QAAQ,CAAe;IACvB,WAAW,CAAkB;IAC7B,WAAW,CAAkB;IAC7B,gBAAgB,GAAG,EAAE,CAAC;IACtB,WAAW,GAAG,CAAC,CAAC;IAChB,QAAQ,GAAG,KAAK,CAAC;IACjB,OAAO,CAAc;IACrB,QAAQ,CAAkC;IAC1C,gBAAgB,GAAG,KAAK,CAAC;IACzB,YAAY,GAAG,CAAC,CAAC;IACjB,YAAY,CAA0B;IACtC,iBAAiB,CAAkB;IACnC,iBAAiB,GAAG,EAAE,CAAC;IACvB,WAAW,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IACtC,WAAW,GAAG,IAAI,UAAU,EAAE,CAAC;IAE/B,gGAAgG;IAChG,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,SAAsB,EAAE,OAA2B;QACpE,IAAI,CAAC,CAAC,SAAS,YAAY,WAAW,CAAC;YAAE,MAAM,IAAI,SAAS,CAAC,8CAA8C,CAAC,CAAC;QAC7G,IAAI,CAAC,OAAO,EAAE,GAAG;YAAE,MAAM,IAAI,SAAS,CAAC,sCAAsC,CAAC,CAAC;QAC/E,IAAI,OAAO,CAAC,MAAM,EAAE,OAAO;YAAE,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QACzD,IAAI,iBAAiB,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,CAAC,eAAe,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC;YACpG,MAAM,IAAI,KAAK,CAAC,uEAAuE,CAAC,CAAC;QAC3F,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,cAAc,IAAI,CAAC,MAAM,CAAC,eAAe;YACnE,CAAC,iBAAiB,CAAC,SAAS,CAAC,0BAA0B,EAAE,CAAC;YAC5D,MAAM,IAAI,KAAK,CAAC,yFAAyF,CAAC,CAAC;QAC7G,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC;QAC1C,IAAI,CAAC;YACH,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;YACvG,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,QAAQ,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,YAAoB,OAA2B;QAC7C,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;QACxB,IAAI,OAAO,CAAC,QAAQ,KAAK,SAAS,IAAI,OAAO,OAAO,CAAC,QAAQ,KAAK,SAAS;YACzE,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACpD,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC;QAC3C,IAAI,CAAC,SAAS,GAAG,iBAAiB,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QACrD,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC,CAAC,OAAO,CAAC,SAAS,YAAY,GAAG,CAAC;YACtE,CAAC,OAAO,OAAO,CAAC,SAAS,KAAK,QAAQ,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;YACtE,MAAM,IAAI,SAAS,CAAC,gDAAgD,CAAC,CAAC;QACxE,IAAI,OAAO,CAAC,aAAa,KAAK,SAAS,IAAI,CAAC,OAAO,OAAO,CAAC,aAAa,KAAK,UAAU;YACnF,OAAO,CAAC,aAAa,CAAC,WAAW,CAAC,IAAI,KAAK,eAAe,CAAC;YAC7D,MAAM,IAAI,SAAS,CAAC,mDAAmD,CAAC,CAAC;QAC3E,IAAI,CAAC,OAAO,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC;QACxC,IAAI,CAAC,QAAQ,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC;QAC/D,IAAI,CAAC,QAAQ,GAAG,IAAI,YAAY,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,kBAAkB,EAAE,CAAC,CAAC;QAClG,IAAI,CAAC,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC7C,IAAI,CAAC,OAAO,CAAC,SAAS,GAAG,gBAAgB,CAAC;QAC1C,IAAI,CAAC,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC;QAC3B,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,GAAG,gEAAgE,CAAC;IAChG,CAAC;IAED,IAAI,QAAQ,KAAuB,OAAO,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;IAClE,IAAI,IAAI,KAAmB,OAAO,EAAE,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IACtD,IAAI,SAAS,KAAK,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;IAC3C,IAAI,QAAQ,KAAc,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC;IAClD,iGAAiG;IACjG,IAAI,KAAK,KAAa,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IAC3C,IAAI,QAAQ,KAAuB,OAAO,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;IAClE,IAAI,gBAAgB,KAA+B,OAAO,EAAE,GAAG,IAAI,CAAC,iBAAiB,EAAE,CAAC,CAAC,CAAC;IAC1F,IAAI,gBAAgB,KAA+B,OAAO,EAAE,GAAG,IAAI,CAAC,iBAAiB,EAAE,CAAC,CAAC,CAAC;IAC1F,IAAI,WAAW,KAAiC,OAAO,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7G,IAAI,KAAK,KAAoB,OAAO,EAAE,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACzD,IAAI,UAAU,KAAK,OAAO,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC;IAC7C,IAAI,MAAM,KAA0B,OAAO,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;IACjE,IAAI,aAAa,KAAqB,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IACrE,IAAI,QAAQ;QACV,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACxC,OAAO,EAAE,GAAG,QAAQ,EAAE,UAAU,EAAE,QAAQ,CAAC,SAAS;YAClD,MAAM,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACvE,CAAC;IACD,IAAI,SAAS;QACX,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC;QAC1C,OAAO,EAAE,GAAG,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,MAAM,KAAK,OAAO,EAAE,OAAO,EAAE,SAAS,CAAC,MAAM,KAAK,SAAS;YAClG,OAAO,EAAE,IAAI,CAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,gBAAgB,EAAE,CAAC;IAC/D,CAAC;IAED,MAAM,CAAC,SAAsB;QAC3B,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO;YAAE,MAAM,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC;QACrE,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;QACtD,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ;YAAE,GAAG,CAAC,QAAQ,GAAG,MAAM,CAAC;QACrD,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;YAAE,GAAG,CAAC,QAAQ,GAAG,KAAK,CAAC;QACnD,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC;YAAE,MAAM,IAAI,SAAS,CAAC,+BAA+B,CAAC,CAAC;QAClG,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,KAAK,SAAS,IAAI,IAAI,CAAC,QAAQ,CAAC,KAAK,KAAK,MAAM;YAC/E,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,gBAAgB,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QACnF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,GAAG,IAAI,KAAK,GAAG,CAAC;YAAE,MAAM,IAAI,UAAU,CAAC,uCAAuC,CAAC,CAAC;QACvH,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC9D,IAAI,CAAC,OAAO,GAAG,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACrD,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC3D,MAAM,CAAC,SAAS,GAAG;;UAEb,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBA4BP,CAAC;QAChB,IAAI,CAAC,QAAQ,GAAG,eAAe,CAAC,MAAM,EAAE,UAAU,EAAE,cAAc,CAAC,CAAC;QACpE,IAAI,CAAC,OAAO,GAAG,eAAe,CAAC,MAAM,EAAE,QAAQ,EAAE,iBAAiB,CAAC,CAAC;QACpE,IAAI,CAAC,MAAM,GAAG,eAAe,CAAC,MAAM,EAAE,UAAU,EAAE,mBAAmB,CAAC,CAAC;QACvE,IAAI,CAAC,WAAW,GAAG,eAAe,CAAC,MAAM,EAAE,aAAa,EAAE,cAAc,CAAC,CAAC;QAC1E,IAAI,CAAC,WAAW,GAAG,eAAe,CAAC,MAAM,EAAE,aAAa,EAAE,cAAc,CAAC,CAAC;QAC1E,IAAI,CAAC,iBAAiB,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACvD,IAAI,CAAC,iBAAiB,CAAC,IAAI,GAAG,cAAc,CAAC;QAC7C,IAAI,CAAC,iBAAiB,CAAC,SAAS,GAAG,yBAAyB,CAAC;QAC7D,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC,OAAO,GAAG,8DAA8D,CAAC;QACtG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QAC5C,IAAI,CAAC,YAAY,GAAG,IAAI,WAAW,CAAC;YAClC,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,iBAAiB;YACtD,MAAM,EAAE,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,iBAAiB,EAAE,iBAAiB,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM;YAC/G,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ;gBAC5F,UAAU,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC;YACvF,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;YACpC,aAAa,EAAE,IAAI,CAAC,QAAQ,CAAC,aAAa;YAC1C,WAAW,EAAE,KAAK,CAAC,EAAE;gBACnB,IAAI,CAAC,iBAAiB,GAAG,KAAK,CAAC,CAAC,CAAC,wBAAwB,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACpF,IAAI,CAAC,iBAAiB,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,CAAC;gBACxC,IAAI,CAAC,uBAAuB,EAAE,CAAC;gBAC/B,IAAI,KAAK;oBAAE,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,iBAAiB,EAAE,OAAO,CAAC,CAAC;YACvE,CAAC;SACF,CAAC,CAAC;QACH,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,YAAY,EAAE,IAAI,CAAC,QAAQ,CAAC,KAAK,IAAI,qDAAqD,CAAC,CAAC;QACrH,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,CAAC;QAC5B,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC/B,MAAM,OAAO,GAAG,CAAC,SAAqB,EAAE,EAAE;YACxC,IAAI,CAAC;gBAAC,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;gBAAC,SAAS,EAAE,CAAC;YAAC,CAAC;YAChD,OAAO,KAAK,EAAE,CAAC;gBAAC,IAAI,CAAC,gBAAgB,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;gBAAC,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAAC,CAAC;QAC3F,CAAC,CAAC;QACF,IAAI,CAAC,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE;YACnG,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,SAAS;gBACrG,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC;YAC9B,KAAK,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;YACjF,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YAC3D,MAAM,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;YACjF,GAAG,EAAE,SAAS,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,SAAS,CAAC;YACrD,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC;YAC3C,OAAO,EAAE,CAAC,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,mBAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC;YACvE,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI;YACjG,aAAa,EAAE,GAAG,CAAC,EAAE;gBACnB,IAAI,CAAC;oBAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,qBAAqB,CAAC,CAAC;gBAAC,CAAC;gBAC1D,OAAO,KAAK,EAAE,CAAC;oBAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;gBAAC,CAAC;YAC9C,CAAC;SACF,CAAC,CAAC;QACH,IAAI,CAAC,aAAa,EAAE,CAAC;QACrB,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,cAAc,EAAE,iBAAiB,CAAC,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE;YAClG,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;QACxF,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QACvC,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,iBAAiB,EAAE,iBAAiB,CAAC,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE;YACrG,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,aAAa,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;QACzF,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QACvC,eAAe,CAAC,MAAM,EAAE,WAAW,EAAE,cAAc,CAAC,CAAC,gBAAgB,CAAC,aAAa,EAAE,KAAK,CAAC,EAAE;YAC3F,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC;gBAAE,OAAO;YAClE,IAAI,CAAC,KAAK,CAAC,MAAM,YAAY,OAAO,IAAI,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;gBACnE,KAAK,CAAC,MAAM,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;gBAAE,OAAO;YAC1E,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,IAAI,CAAC,KAAK,EAAE,CAAC;QACf,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QACvC,IAAI,CAAC,SAAS,GAAG,IAAI,cAAc,CAAC,OAAO,CAAC,EAAE;YAC5C,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC;YACjD,MAAM,OAAO,GAAG,KAAK,KAAK,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,MAAM,KAAK,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;YAC3E,IAAI,CAAC,KAAK,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;YAC/B,IAAI,CAAC,IAAI,EAAE,CAAC;YACZ,IAAI,OAAO;gBAAE,IAAI,CAAC,YAAY,EAAE,CAAC;QACnC,CAAC,CAAC,CAAC;QACH,kEAAkE;QAClE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAClC,MAAM,CAAC,gBAAgB,CAAC,UAAU,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QAC9F,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QACtH,IAAI,CAAC,WAAW,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;QACtH,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,SAAS,KAAK,SAAS;YAClD,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,GAAG,CAAC,sBAAsB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,mBAAmB,EAAE,CAAC;YAC7G,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,mBAAmB,EAAE,CAAC,CAAC;QAC/G,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,KAAwC,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;QAClH,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE;YAC7C,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,OAAO,IAAI,wBAAwB,CAAC,CAAC,CAAC;QACnE,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,cAAc,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,8CAA8C,CAAC,CAAC,CAAC,CAAC;QAC3H,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,0BAA0B,EAAE,CAAC;QACzD,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI;YAC3D,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IAC1C,CAAC;IAED,QAAQ,CAAC,OAA4B;QACnC,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO;QAC3B,IAAI,OAAO,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;YACjC,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;YACvB,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;QAC3C,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACrC,IAAI,IAAI,CAAC,OAAO;gBAAE,OAAO;YACzB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;YACpB,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YAC/B,IAAI,CAAC;gBACH,IAAI,CAAC,WAAW,EAAE,CAAC;gBACnB,IAAI,CAAC,IAAI,CAAC,SAAS;oBAAE,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;YACtF,CAAC;oBAAS,CAAC;gBACT,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,8BAA8B,OAAO,CAAC,OAAO,CAAC,IAAI,GAC7E,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,6BAA6B,CAAC,CAAC,CAAC;YAC/F,CAAC;QACH,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACrC,IAAI,OAAO,CAAC,KAAK,KAAK,OAAO,EAAE,CAAC;gBAC9B,IAAI,CAAC,WAAW,EAAE,CAAC;gBACnB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;YACjD,CAAC;YACD,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;QAC3D,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YACvC,MAAM,KAAK,GAAG,CAAC,IAAI,CAAC,YAAY,CAAC;YACjC,MAAM,eAAe,GAAG,KAAK,IAAK,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,CAAW;iBACxG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;YAC3D,IAAI,CAAC,SAAS,GAAG;gBACf,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI;gBAC5C,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,aAAa,EAAE,OAAO,CAAC,aAAa;aACnG,CAAC;YACF,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;YACzB,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC;YAC3B,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;YAC1B,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACzC,IAAI,IAAI,CAAC,SAAS,EAAE,IAAI,QAAQ,CAAC,aAAa,KAAK,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,aAAa;gBAAE,IAAI,CAAC,KAAK,EAAE,CAAC;YACzH,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;YAC5C,IAAI,eAAe,IAAI,OAAO,CAAC,SAAS,KAAK,IAAI,CAAC,KAAK,CAAC,SAAS;gBAAE,IAAI,CAAC,IAAI,EAAE,CAAC;YAC/E,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC;gBAC1B,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;gBAChC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;gBAC9B,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;YAClC,CAAC;YACD,IAAI,KAAK,IAAI,CAAC,CAAC,OAAO,CAAC,SAAS,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC;gBAAE,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;YACnF,IAAI,IAAI,CAAC,cAAc,KAAK,GAAG,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,IAAI,EAAE;gBAAE,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;YAClG,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;gBACtC,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;gBAChC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;YAC1D,CAAC;YACD,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;YAC1E,IAAI,eAAe;gBAAE,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/D,IAAI,KAAK;gBAAE,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACvD,IAAI,OAAO,CAAC,EAAE,KAAK,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,OAAO,CAAC,SAAS,KAAK,IAAI,CAAC,KAAK,CAAC,SAAS,IAAI,OAAO,CAAC,SAAS,KAAK,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC;gBAC7H,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAC1C,CAAC;YACD,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;gBAC3F,MAAM,YAAY,GAAG,CAAC,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,MAAM,KAAK,OAAO,CAAC,KAAK,CAAC;gBACtE,MAAM,eAAe,GAAG,CAAC,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,SAAS,CAAC,KAAK,KAAK,OAAO,CAAC,QAAQ,CAAC,KAAK;oBAC3F,IAAI,CAAC,SAAS,CAAC,UAAU,KAAK,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;gBAC5D,MAAM,YAAY,GAAG,CAAC,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,iBAAiB,CAAC,KAAK,KAAK,OAAO,CAAC,gBAAgB,CAAC,KAAK;oBACxG,IAAI,CAAC,iBAAiB,CAAC,YAAY,KAAK,OAAO,CAAC,gBAAgB,CAAC,YAAY,CAAC;gBAChF,MAAM,uBAAuB,GAAG,CAAC,IAAI,CAAC,YAAY;oBAChD,IAAI,CAAC,iBAAiB,CAAC,GAAG,KAAK,OAAO,CAAC,gBAAgB,CAAC,GAAG,CAAC;gBAC9D,MAAM,kBAAkB,GAAG,CAAC,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,YAAY,EAAE,KAAK,KAAK,OAAO,CAAC,WAAW,EAAE,KAAK;oBACtG,IAAI,CAAC,YAAY,EAAE,QAAQ,KAAK,OAAO,CAAC,WAAW,EAAE,QAAQ;oBAC7D,IAAI,CAAC,YAAY,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,EAAE,aAAa,CAAC;gBAC1E,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;gBAC5B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;gBACtB,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;gBACzC,IAAI,CAAC,iBAAiB,GAAG,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;gBACzD,IAAI,CAAC,iBAAiB,GAAG,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;gBACzD,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;gBAC5E,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;gBACzB,IAAI,YAAY;oBAAE,IAAI,CAAC,QAAQ,CAAC,aAAa,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;gBAC7D,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,eAAe;oBAAE,IAAI,CAAC,QAAQ,CAAC,gBAAgB,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;gBACxF,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,YAAY;oBAAE,IAAI,CAAC,QAAQ,CAAC,wBAAwB,EAAE,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;gBACrG,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,uBAAuB;oBAAE,IAAI,CAAC,QAAQ,CAAC,wBAAwB,EAAE,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;gBAChH,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,kBAAkB;oBAAE,IAAI,CAAC,QAAQ,CAAC,mBAAmB,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YACnG,CAAC;QACH,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YACtC,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;YAChC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC1D,CAAC;aAAM,IAAI,OAAO,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YACpC,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;YAC5B,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;gBAAE,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;YAChE,IAAI,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,CAAC,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,UAAU;gBACpF,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;gBACrD,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;gBAC/B,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC5B,CAAC;YACD,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;IAED,IAAI;QACF,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;QAChE,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC;QAC/D,MAAM,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QAC1F,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,GAAG,GAAG,KAAK,GAAG,KAAK,IAAI,CAAC;QACjD,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,GAAG,GAAG,MAAM,GAAG,KAAK,IAAI,CAAC;QACnD,2EAA2E;QAC3E,MAAM,KAAK,GAAG,gBAAgB,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC9C,IAAI,CAAC,WAAW,GAAG,EAAE,KAAK,EAAE,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;QACtG,IAAI,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC;QAC7B,MAAM,GAAG,GAAG,MAAM,CAAC,gBAAgB,IAAI,CAAC,CAAC;QACzC,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,KAAK,GAAG,GAAG,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,KAAK,GAAG,GAAG,CAAC,EAAE,CAAC,CAAC;IACnH,CAAC;IAED,WAAW;QACT,OAAO,aAAa,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACjE,CAAC;IAED,YAAY,CAAC,YAAY,GAAG,KAAK;QAC/B,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,KAAK,OAAO,IAAI,CAAC,YAAY;YAAE,OAAO;QAC3D,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,IAAI,IAAI,CAAC,YAAY,KAAK,SAAS,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO;QAC5G,iFAAiF;QACjF,IAAI,CAAC,YAAY,GAAG,UAAU,CAAC,GAAG,EAAE;YAClC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;YAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;YAChC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;gBAAE,OAAO;YAChE,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;YAC3C,IAAI,CAAC,IAAI,CAAC,OAAO,KAAK,IAAI,CAAC,SAAS,CAAC,OAAO,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,IAAI,CAAC,cAAc;gBAAE,OAAO;YAC1H,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACvC,CAAC,EAAE,EAAE,CAAC,CAAC;IACT,CAAC;IAED,KAAK,CAAC,OAA2B,EAAE,WAA2B,EAAE;QAC9D,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC/C,CAAC;IAED,KAAK,CAAC,OAAwB;QAC5B,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;QAC1F,IAAI,IAAI,CAAC,SAAS,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,gBAAgB,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC;YACzG,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACzD,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,aAAa,CAAC,OAAqB;QACjC,IAAI,IAAI,CAAC,SAAS,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,OAAO;QAChD,iBAAiB,CAAC,OAAO,CAAC,CAAC;QAC3B,IAAI,CAAC,YAAY,EAAE,CAAC;QACpB,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC;YAChF,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;YACtB,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,KAAK,MAAM;gBAAE,IAAI,CAAC,cAAc,EAAE,CAAC;YAC5D,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO;gBAAE,IAAI,CAAC,YAAY,EAAE,CAAC;QAC7E,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACtB,CAAC;IAED,kBAAkB;QAChB,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC/B,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QACjC,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACvB,IAAI,SAAS,CAAC,MAAM,KAAK,aAAa;YAAE,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;QAC9D,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,IAAI,CAAC,WAAW,CAAC,eAAe,CAAC,GAAG,SAAS,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE;gBAC/D,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;gBAC/C,OAAO,CAAC,SAAS,GAAG,WAAW,CAAC;gBAChC,OAAO,CAAC,YAAY,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;gBACpD,OAAO,CAAC,KAAK,CAAC,OAAO,GAAG,QAAQ,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,GAAG,GAAG,SAAS,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,GAAG,GAAG,WAAW,CAAC,KAAK,CAAC,SAAS,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,GAAG,GAAG,YAAY,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,GAAG,CAAC;gBACtP,OAAO,OAAO,CAAC;YACjB,CAAC,CAAC,CAAC,CAAC;YACJ,MAAM,IAAI,GAAG,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,cAAc,EAAE,iBAAiB,CAAC,CAAC;YAClF,IAAI,CAAC,MAAM,GAAG,CAAC,QAAQ,CAAC,SAAS,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YAC/E,IAAI,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC;YACjC,IAAI,CAAC,uBAAuB,EAAE,CAAC;QACjC,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE,IAAI,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC;QAClD,IAAI,CAAC,QAAQ,CAAC,gBAAgB,EAAE,CAAC,QAAQ,CAAC,CAAC;QAC3C,IAAI,CAAC,QAAQ,CAAC,iBAAiB,EAAE,CAAC,SAAS,CAAC,CAAC;IAC/C,CAAC;IAED,WAAW,CAAC,KAAa,IAAU,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACjE,YAAY,KAAK,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IACxC,cAAc,KAAK,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAEvE,8EAA8E;IAC9E,kBAAkB;QAChB,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;QACjE,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,CAAC;IAED,uBAAuB;QACrB,IAAI,CAAC,IAAI,CAAC,WAAW;YAAE,OAAO;QAC9B,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QACjC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC/B,MAAM,MAAM,GAAG,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,qBAAqB,EAAE,eAAe,CAAC,CAAC;QACzF,MAAM,CAAC,WAAW,GAAG,IAAI,CAAC,iBAAiB,IAAI,IAAI,CAAC,gBAAgB;YAClE,CAAC,SAAS,CAAC,MAAM,KAAK,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC;YAC7D,CAAC,QAAQ,CAAC,SAAS,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,OAAO,GAAG,QAAQ,CAAC,GAAG,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC1G,MAAM,CAAC,MAAM,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC;QACpC,MAAM,CAAC,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC,iBAAiB,IAAI,IAAI,CAAC,gBAAgB;YACpE,SAAS,CAAC,MAAM,KAAK,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;IAC1D,CAAC;IAED,aAAa,CAAC,KAAc;QAC1B,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QAC1E,IAAI,CAAC,gBAAgB,GAAG,wBAAwB,OAAO,CAAC,OAAO,EAAE,CAAC;QAClE,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC1B,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,OAAO,CAAC,CAAC;IACxC,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,EAAE,KAAK,GAAG,KAAK,KAA2B,EAAE;QAC9D,IAAI,OAAO,KAAK,KAAK,SAAS;YAAE,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACpF,MAAM,MAAM,GAAG,EAAE,IAAI,CAAC,WAAW,CAAC;QAClC,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;QAC7C,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC;QAC5C,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;QAC3B,IAAI,CAAC;YACH,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,SAAS;gBAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;YAC1F,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,KAAK,IAAI,OAAO,aAAa,KAAK,UAAU,EAAE,CAAC;gBACvE,MAAM,IAAI,KAAK,CAAC,2FAA2F,CAAC,CAAC;YAC/G,CAAC;YACD,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;YAClC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;YACrB,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC1B,iGAAiG;YACjG,MAAM,IAAI,GAAG,IAAI,aAAa,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;YAChH,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YAC7E,IAAI,KAAK,IAAI,MAAM,KAAK,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,KAAK,OAAO;gBACzE,IAAI,CAAC,SAAS,CAAC,SAAS,KAAK,WAAW,IAAI,IAAI,CAAC,QAAQ,CAAC,UAAU,KAAK,UAAU;gBACrF,IAAI,CAAC,cAAc,EAAE,CAAC;YACxB,OAAO,KAAK,CAAC;QACf,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,MAAM,KAAK,IAAI,CAAC,WAAW,EAAE,CAAC;gBAChC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;gBAChC,IAAI,CAAC,gBAAgB,GAAG,gBAAgB,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;YAChE,CAAC;YACD,MAAM,KAAK,CAAC;QACd,CAAC;gBAAS,CAAC;YACT,IAAI,MAAM,KAAK,IAAI,CAAC,WAAW;gBAAE,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;YACvD,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC5B,CAAC;IACH,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,YAAY,IAAI,CAAC,IAAI,CAAC,SAAS;YAC5D,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACrD,CAAC;IAED,IAAI,YAAY;QACd,OAAO,MAAM,CAAC,MAAM,CAAC;YACnB,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ;YAClE,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,IAAI,IAAI,EAAE,aAAa,EAAE,CAAC,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,SAAS,CAAC,aAAa,KAAK,CAAC;YAC1G,UAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO;YAC7D,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI;SACtE,CAAC,CAAC;IACL,CAAC;IAED,aAAa,CAAC,KAAoB;QAChC,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;YAC/E,IAAI,IAAI,CAAC,SAAS,IAAI,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,WAAW;gBAC7D,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC;YAClH,OAAO,QAAQ,CAAC;QAClB,CAAC;QACD,OAAO,KAAK,EAAE,CAAC;YACb,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YAC1B,OAAO,EAAE,KAAK,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC;QACvC,CAAC;IACH,CAAC;IAED,mBAAmB,CAAC,QAAqD,EAAE,KAAoB;QAC7F,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IACvG,CAAC;IAWD,KAAK,CAAC,SAAS,CAAC,MAAmC,EAAE,IAAc,EAAE,KAAqB;QACxF,OAAO,IAAI,CAAC,cAAc,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,cAAc,CAAC,MAAmC,EAAE,IAAc,EAAE,KAAqB;QAC7F,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;QACjE,IAAI,CAAC,gBAAgB,GAAG,EAAE,CAAC;QAC3B,IAAI,OAAO,MAAM,KAAK,UAAU;YAAE,OAAO,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QAChF,MAAM,YAAY,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC/C,IAAI,YAAY;YAAE,OAAO,YAAY,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QACtE,QAAQ,MAAM,EAAE,CAAC;YACf,KAAK,cAAc,CAAC,aAAa;gBAC/B,IAAI,IAAI,KAAK,SAAS;oBAAE,OAAO,IAAI,CAAC,aAAa,EAAE,CAAC;gBACpD,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;oBAAE,MAAM,IAAI,SAAS,CAAC,gCAAgC,CAAC,CAAC;gBAC3E,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,IAAI,OAAO,IAAI,CAAC,KAAK,KAAK,SAAS;oBAAE,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;gBACrH,OAAO,IAAI,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;YACnD,KAAK,cAAc,CAAC,cAAc,CAAC,CAAC,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC;YACjE,KAAK,cAAc,CAAC,cAAc,CAAC,CAAC,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC;YACjE,KAAK,cAAc,CAAC,YAAY,CAAC,CAAC,OAAO,IAAI,CAAC,YAAY,EAAE,CAAC;YAC7D,KAAK,cAAc,CAAC,WAAW;gBAC7B,IAAI,OAAO,IAAI,KAAK,QAAQ;oBAAE,MAAM,IAAI,UAAU,CAAC,8CAA8C,CAAC,CAAC;gBACnG,OAAO,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;YAChC,KAAK,cAAc,CAAC,WAAW;gBAC7B,IAAI,IAAI,CAAC,gBAAgB;oBAAE,MAAM,IAAI,KAAK,CAAC,sEAAsE,CAAC,CAAC;gBACnH,IAAI,CAAC,gBAAgB,GAAG,IAAI,CAAC;gBAC7B,IAAI,CAAC;oBACH,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,IAAI,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;wBAC/E,OAAO,MAAM,IAAI,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;oBACnD,IAAI,CAAC,IAAI,CAAC,SAAS;wBAAE,OAAO,MAAM,IAAI,CAAC,cAAc,EAAE,CAAC;oBACxD,OAAO;gBACT,CAAC;wBAAS,CAAC;oBAAC,IAAI,CAAC,gBAAgB,GAAG,KAAK,CAAC;gBAAC,CAAC;YAC9C,OAAO,CAAC,CAAC,MAAM,IAAI,SAAS,CAAC,4BAA4B,MAAM,EAAE,CAAC,CAAC;QACrE,CAAC;IACH,CAAC;IAED,+EAA+E;IAC/E,KAAK,CAAC,IAAY;QAChB,IAAI,OAAO,IAAI,KAAK,QAAQ;YAAE,MAAM,IAAI,SAAS,CAAC,6BAA6B,CAAC,CAAC;QACjF,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC9E,IAAI,IAAI;YAAE,IAAI,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC9E,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,QAAQ;YAChC,MAAM,IAAI,KAAK,CAAC,6EAA6E,CAAC,CAAC;QACjG,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC;QACjC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC;QAC5C,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;QAC7C,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC;QACvC,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC;QAClD,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,MAAM,KAAK,IAAI,CAAC,YAAY,IAAI,UAAU,KAAK,IAAI,CAAC,QAAQ,CAAC,UAAU;YAC5F,WAAW,KAAK,IAAI,CAAC,SAAS,CAAC,SAAS,IAAI,QAAQ,CAAC,aAAa,KAAK,OAAO;YAChF,MAAM,IAAI,KAAK,CAAC,+FAA+F,CAAC,CAAC;QACnH,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACjB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,mGAAmG;IACnG,WAAW,CAAC,QAAiB;QAC3B,IAAI,OAAO,QAAQ,KAAK,SAAS;YAAE,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACrF,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;QACjE,IAAI,IAAI,CAAC,SAAS,KAAK,QAAQ;YAAE,OAAO;QACxC,MAAM,YAAY,GAAG,QAAQ,CAAC,aAAa,KAAK,IAAI,CAAC,OAAO;YAC1D,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,aAAa,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,aAAa,KAAK,IAAI,CAAC,MAAM,CAAC,CAAC;QACrG,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC;QAC1B,IAAI,CAAC,YAAY,EAAE,CAAC;QACpB,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC;QAC3B,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChB,IAAI,CAAC,MAAM,CAAC,KAAK,GAAG,EAAE,CAAC;YACvB,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;QAC3C,CAAC;QACD,qFAAqF;QACrF,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;QACtB,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACvB,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAChC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;QAC9B,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;QAChC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;QACxB,IAAI,YAAY;YAAE,IAAI,CAAC,KAAK,EAAE,CAAC;QAC/B,IAAI,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC;IAC/B,CAAC;IAED,aAAa,CAAC,KAAoB;QAChC,IAAI,KAAK,CAAC,IAAI,KAAK,KAAK,EAAE,CAAC;YACzB,IAAI,KAAK,CAAC,IAAI;gBAAE,MAAM,IAAI,KAAK,CAAC,kFAAkF,CAAC,CAAC;YACpH,IAAI,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC;QAC5G,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YAClC,IAAI,IAAI,CAAC,SAAS,EAAE;gBAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC/C,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YACjC,IAAI,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QAC1D,CAAC;IACH,CAAC;IAED,cAAc,CAAC,KAAoB,EAAE,KAAa,EAAE,gBAAgB,GAAG,IAAI;QACzE,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC3C,6FAA6F;QAC7F,IAAI,CAAC,gBAAgB,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS,IAAI,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,OAAO;YAAE,OAAO;QACxG,IAAI,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,OAAO;YACrC,CAAC,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,QAAQ,IAAI,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO;QAC7E,KAAK,EAAE,cAAc,EAAE,CAAC;QACxB,KAAK,EAAE,eAAe,EAAE,CAAC;QACzB,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS;YAAE,IAAI,CAAC,mBAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;aACxE,IAAI,QAAQ,CAAC,KAAK,KAAK,UAAU,CAAC,OAAO,EAAE,CAAC;YAC/C,IAAI,CAAC;gBAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YAAC,CAAC;YAClC,OAAO,KAAK,EAAE,CAAC;gBAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YAAC,CAAC;QAC9C,CAAC;IACH,CAAC;IAED,aAAa;QACX,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC;QAC1B,MAAM,OAAO,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC;QACnD,IAAI,SAAS,GAAG,KAAK,CAAC;QACtB,IAAI,iBAAiB,GAAkB,IAAI,CAAC;QAC5C,IAAI,CAAC,iBAAiB,GAAG,GAAG,EAAE;YAC5B,SAAS,GAAG,KAAK,CAAC;YAClB,iBAAiB,GAAG,IAAI,CAAC;YACzB,YAAY,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACvC,CAAC,CAAC;QACF,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE;YAC/C,IAAI,KAAK,CAAC,gBAAgB;gBAAE,OAAO;YACnC,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC;gBAAE,OAAO;YAClE,MAAM,MAAM,GAAG,KAAK,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC;YACvC,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,KAAK,CAAC,WAAW,IAAI,SAAS,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS,IAAI,KAAK,CAAC,GAAG,KAAK,MAAM;gBAAE,OAAO;YAClH,IAAI,KAAK,CAAC,gBAAgB,CAAC,UAAU,CAAC;gBAAE,OAAO;YAC/C,IAAI,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM;gBACvF,GAAG,cAAc,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,IAAI,CAAC,OAAO,CAAC,CAAC;QACpF,CAAC,EAAE,EAAE,GAAG,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;QAClC,KAAK,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE;YACtC,IAAI,KAAK,CAAC,gBAAgB,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,KAAK,CAAC,aAAa;gBAAE,OAAO;YAC/E,IAAI,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,aAAa,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;YAC/F,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC;QACnB,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,KAAK,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,GAAG,EAAE;YAC9C,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;gBAAE,OAAO;YAC9B,SAAS,GAAG,IAAI,CAAC;YACjB,iBAAiB,GAAG,IAAI,CAAC;YACzB,YAAY,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACvC,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,EAAE,KAAK,CAAC,EAAE;YAC/C,IAAI,CAAC,SAAS;gBAAE,OAAO;YACvB,SAAS,GAAG,KAAK,CAAC;YAClB,+EAA+E;YAC/E,iBAAiB,GAAG,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC;YAC9E,IAAI,CAAC,iBAAiB,GAAG,UAAU,CAAC,GAAG,EAAE;gBACvC,IAAI,iBAAiB;oBAAE,IAAI,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC,CAAC;gBACtF,iBAAiB,GAAG,IAAI,CAAC;gBACzB,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC;YACnB,CAAC,EAAE,CAAC,CAAC,CAAC;QACR,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,KAAK,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE;YACtC,MAAM,UAAU,GAAG,KAAK,YAAY,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;YACnE,IAAI,UAAU,EAAE,WAAW,IAAI,SAAS;gBAAE,OAAO;YACjD,YAAY,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;YACrC,MAAM,IAAI,GAAG,iBAAiB,IAAI,UAAU,EAAE,IAAI,IAAI,KAAK,CAAC,KAAK,CAAC;YAClE,iBAAiB,GAAG,IAAI,CAAC;YACzB,IAAI,IAAI,IAAI,UAAU,EAAE,SAAS,KAAK,iBAAiB;gBAAE,IAAI,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,KAAK,CAAC,CAAC;YAC5G,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC;QACnB,CAAC,EAAE,OAAO,CAAC,CAAC;IACd,CAAC;IAED,KAAK;QACH,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO;QAC3B,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC;QAC7D,MAAM,CAAC,KAAK,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;IACxC,CAAC;IAED,8EAA8E;IAC9E,cAAc;QACZ,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC9E,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;QAC1H,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QAChC,IAAI,CAAC,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;QAChF,IAAI,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC;YAC3B,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS;gBAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;YAC9E,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;YACrC,OAAO;QACT,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,GAAG,IAAI,EAAE,CAAC,CAAC;IAClD,CAAC;IAED,8EAA8E;IAC9E,MAAM,CAAC,OAAe,EAAE,IAAY;QAClC,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACvC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC9E,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;QAClG,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,CAAC,CAAC;QACxC,IAAI,CAAC,cAAc,GAAG,GAAG,OAAO,IAAI,IAAI,EAAE,CAAC;IAC7C,CAAC;IAED,8FAA8F;IAC9F,SAAS,CAAC,MAAsB;QAC9B,MAAM,IAAI,GAAG,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC5D,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;QAC1F,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAC3E,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC;QAC/F,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;QACpB,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAChC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;QAC9B,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;QAChC,IAAI,CAAC,IAAI,EAAE,CAAC;QACZ,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;QACxB,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC9C,CAAC;IAED,MAAM,KAAK,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,CAAC;IAE5C,WAAW;QACT,IAAI,CAAC,YAAY,EAAE,CAAC;QACpB,IAAI,CAAC,UAAU,GAAG,KAAK,CAAC;QACxB,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC5B,IAAI,IAAI,CAAC,MAAM;YAAE,IAAI,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,CAAC;QAC7C,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QAC7B,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,CAAC;QAC3B,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC1B,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAChC,IAAI,CAAC,YAAY,GAAG,SAAS,CAAC;IAChC,CAAC;IAED,KAAK,CAAC,KAAY;QAChB,IAAI,CAAC,WAAW,EAAE,CAAC;QACnB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC1B,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACjD,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,CAAC;IAC5B,CAAC;IAED,2EAA2E;IAC3E,OAAO;QACL,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO;QAC3B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,WAAW,EAAE,CAAC;QACnB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,YAAY,CAAC,4BAA4B,EAAE,YAAY,CAAC,CAAC,CAAC;QACjF,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC/B,YAAY,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACrC,IAAI,CAAC,SAAS,EAAE,UAAU,EAAE,CAAC;QAC7B,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACvB,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;QACxB,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC7B,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;IACxB,CAAC;CACF","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport type { MouseCapture } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark,\n WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #closed = false;\n #readOnly: boolean;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #resetComposition: (() => void) | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #title = \"\";\n #hasTitle = false;\n #progress: TerminalProgress = { state: \"none\", percentage: null };\n #shellIntegration: TerminalShellIntegration = { phase: \"unknown\", lastExitCode: null };\n #workingDirectory: TerminalWorkingDirectory = { uri: null, host: null, path: null };\n #commandMark: TerminalCommandMark | null = null;\n #hasActivity = false;\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n if (options.readOnly !== undefined && typeof options.readOnly !== \"boolean\")\n throw new TypeError(\"readOnly must be a boolean\");\n this.#readOnly = options.readOnly ?? false;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get readOnly(): boolean { return this.#readOnly; }\n /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */\n get title(): string { return this.#title; }\n get progress(): TerminalProgress { return { ...this.#progress }; }\n get shellIntegration(): TerminalShellIntegration { return { ...this.#shellIntegration }; }\n get workingDirectory(): TerminalWorkingDirectory { return { ...this.#workingDirectory }; }\n get commandMark(): TerminalCommandMark | null { return this.#commandMark ? { ...this.#commandMark } : null; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: this.#readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: this.#readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#connected && !this.viewport.pending ? this.#hyperlinks.at(point) : null,\n openHyperlink: uri => {\n try { window.open(uri, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n }\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"closed\") {\n if (this.#closed) return;\n this.#closed = true;\n clearTimeout(this.#readyTimer);\n try {\n this.#disconnect();\n if (!this.#disposed) this.#options.onClose?.(Object.freeze({ ...message.details }));\n } finally {\n this.#ready.reject(new Error(`Terminal WebSocket closed (${message.details.code}${\n message.details.reason ? `: ${message.details.reason}` : \"\"}) before mounting completed`));\n }\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n const titleChanged = !this.#hasTitle || this.#title !== message.title;\n const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state ||\n this.#progress.percentage !== message.progress.percentage;\n const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase ||\n this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode;\n const workingDirectoryChanged = !this.#hasActivity ||\n this.#workingDirectory.uri !== message.workingDirectory.uri;\n const commandMarkChanged = !this.#hasActivity || this.#commandMark?.phase !== message.commandMark?.phase ||\n this.#commandMark?.exitCode !== message.commandMark?.exitCode ||\n this.#commandMark?.rawParameters !== message.commandMark?.rawParameters;\n this.#title = message.title;\n this.#hasTitle = true;\n this.#progress = { ...message.progress };\n this.#shellIntegration = { ...message.shellIntegration };\n this.#workingDirectory = { ...message.workingDirectory };\n this.#commandMark = message.commandMark ? { ...message.commandMark } : null;\n this.#hasActivity = true;\n if (titleChanged) this.#options.onTitleChange?.(this.#title);\n if (!this.#disposed && progressChanged) this.#options.onProgressChange?.(this.progress);\n if (!this.#disposed && shellChanged) this.#options.onShellIntegrationChange?.(this.shellIntegration);\n if (!this.#disposed && workingDirectoryChanged) this.#options.onWorkingDirectoryChange?.(this.workingDirectory);\n if (!this.#disposed && commandMarkChanged) this.#options.onCommandMarkChange?.(this.commandMark);\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected &&\n (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#canInput() || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#canInput()) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly && [\"input\", \"paste\", \"key\", \"mouse\", \"resize\", \"requestPrimary\"].includes(command.type))\n throw new Error(\"Terminal view does not accept input\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: !this.#readOnly && this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: this.#readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try {\n const decision = this.#policy.resolve(Object.freeze(input), this.inputContext);\n if (this.#readOnly && decision.route === InputRoute.Application)\n return { route: input.type === \"pointer\" || input.type === \"wheel\" ? InputRoute.Continue : InputRoute.Consume };\n return decision;\n }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */\n setReadOnly(readOnly: boolean): void {\n if (typeof readOnly !== \"boolean\") throw new TypeError(\"readOnly must be a boolean\");\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n if (this.#readOnly === readOnly) return;\n const inputFocused = document.activeElement === this.element &&\n (!this.element.shadowRoot?.activeElement || this.element.shadowRoot.activeElement === this.#input);\n this.#readOnly = readOnly;\n this.#inputSerial++;\n this.#resetComposition?.();\n if (this.#input) {\n this.#input.value = \"\";\n this.#input.disabled = !this.#canInput();\n }\n // Set the policy before cancelling so pending moves and button releases cannot leak.\n this.#mouse?.cancel();\n this.#mouse?.refresh();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#queueResize(true);\n if (inputFocused) this.focus();\n this.#selectionUI?.refresh();\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.#resetComposition = () => {\n composing = false;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n };\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n if (!this.#canInput()) return;\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n if (!composing) return;\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file +{"version":3,"file":"web-terminal.js","sourceRoot":"","sources":["../src/web-terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { captureMouse } from \"./mouse-input.js\";\nimport { normalizeFont } from \"./terminal-font.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { dimensions, normalizeSizing, requestedGrid, fittedScale } from \"./terminal-sizing.js\";\nimport { HistoryState } from \"./history-state.js\";\nimport { terminalThemeCss } from \"./terminal-theme.js\";\nimport { InputPolicy, InputRoute, TerminalAction, inputModifiers } from \"./input-policy.js\";\nimport { assertCommandSize } from \"./protocol.js\";\nimport { SelectionUI } from \"./selection-ui.js\";\nimport { Hyperlinks } from \"./hyperlinks.js\";\nimport { LinkDetection } from \"./link-detection.js\";\nimport { normalizeLinks } from \"./link-options.js\";\nimport type { DetectedLink, LinkDetectionSnapshot } from \"./link-detection.js\";\nimport type { MouseCapture, PointerHyperlink } from \"./mouse-input.js\";\nimport type { CopySelectionOptions, InputActionHandler, InputDecision, InputBinding,\n TerminalActionName, TerminalGeometry, TerminalInput, TerminalInputContext, TerminalPeer,\n TerminalRendererPreference, TerminalSelection, TerminalSizing, TerminalSizingState, TerminalStats, TerminalViewport,\n TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark,\n TerminalLinkOptions, TerminalLinkActivation, TerminalPoint,\n WebTerminalHandle, WebTerminalOptions } from \"./types.js\";\nimport type { HyperlinkRange, InputCommand, TerminalCommand, WorkerInputMessage, WorkerOutputMessage } from \"./wire-types.js\";\nimport { errorMessage, isRecord } from \"./validation.js\";\nexport { InputRoute, TerminalAction, defaultInputBindings } from \"./input-policy.js\";\n\nfunction requiredElement(root: ParentNode, selector: string, type: new () => T): T {\n const element = root.querySelector(selector);\n if (!(element instanceof type)) throw new Error(`Missing terminal element: ${selector}`);\n return element;\n}\n\n/**\n * First-party HWT1 client. Owns only the element it appends, not the caller's\n * container or the server terminal. The HWT1 wire and this spike API evolve together.\n */\nexport class WebTerminal implements WebTerminalHandle {\n readonly element: HTMLDivElement;\n #options: WebTerminalOptions;\n #renderer: TerminalRendererPreference;\n // DOM and worker fields are initialized by mount before a handle is returned.\n #worker!: Worker;\n #surface!: HTMLDivElement;\n #canvas!: HTMLCanvasElement;\n #input!: HTMLTextAreaElement;\n #mouse: MouseCapture | undefined;\n #observer: ResizeObserver | undefined;\n #listeners = new AbortController();\n #size = { width: 0, height: 0 };\n #sizing = normalizeSizing();\n #geometry: TerminalGeometry = { columns: 80, rows: 24, cellWidth: 10, cellHeight: 20, mouseTracking: 0 };\n #peer: TerminalPeer = { id: null, primaryId: null, isPrimary: false };\n #connected = false;\n #closed = false;\n #readOnly: boolean;\n #disposed = false;\n #hasGeometry = false;\n #resizeTimer: ReturnType | undefined;\n #lastRequested: string | undefined;\n #compositionTimer: ReturnType | undefined;\n #resetComposition: (() => void) | undefined;\n #ready = Promise.withResolvers();\n #readyTimer: ReturnType | undefined;\n #stats: TerminalStats = {};\n #screenText = \"\";\n #title = \"\";\n #hasTitle = false;\n #progress: TerminalProgress = { state: \"none\", percentage: null };\n #shellIntegration: TerminalShellIntegration = { phase: \"unknown\", lastExitCode: null };\n #workingDirectory: TerminalWorkingDirectory = { uri: null, host: null, path: null };\n #commandMark: TerminalCommandMark | null = null;\n #hasActivity = false;\n #history: HistoryState;\n #highlights!: HTMLDivElement;\n #inspection!: HTMLDivElement;\n #inspectionError = \"\";\n #copySerial = 0;\n #copying = false;\n #policy: InputPolicy;\n #actions: Map;\n #clipboardAction = false;\n #inputSerial = 0;\n #selectionUI: SelectionUI | undefined;\n #selectionOverlay!: HTMLDivElement;\n #selectionUIError = \"\";\n #canvasSize = { width: 0, height: 0 };\n #hyperlinks = new Hyperlinks();\n #links: false | TerminalLinkOptions;\n #linkDetector: LinkDetection;\n #linkGeneration = 1;\n #linkRevision = 0;\n #linkSerial = 0;\n #linkSnapshot: LinkDetectionSnapshot | undefined;\n #osc8Rows = new Map();\n #detectedLinks: readonly DetectedLink[] = [];\n #presentedLinks: readonly DetectedLink[] = [];\n #detectedRows = new Map();\n #hoveredLinkId: string | undefined;\n #pendingLinks: { revision: number; generation: number; serial: number; links: readonly DetectedLink[] } | undefined;\n\n /** Resolves after a connected terminal frame is presented. Supply signal to cancel mounting. */\n static async mount(container: HTMLElement, options: WebTerminalOptions): Promise {\n if (!(container instanceof HTMLElement)) throw new TypeError(\"A terminal container HTMLElement is required\");\n if (!options?.url) throw new TypeError(\"A terminal WebSocket URL is required\");\n if (options.signal?.aborted) throw options.signal.reason;\n if (normalizeRenderer(options.renderer) === \"webgpu\" && (!window.isSecureContext || !navigator.gpu)) {\n throw new Error(\"The requested WebGPU renderer requires WebGPU over HTTPS or localhost\");\n }\n if (!window.Worker || !window.ResizeObserver || !window.OffscreenCanvas ||\n !HTMLCanvasElement.prototype.transferControlToOffscreen) {\n throw new Error(\"WebTerminal requires module workers, ResizeObserver, and a transferable OffscreenCanvas\");\n }\n const terminal = new WebTerminal(options);\n try {\n await Promise.all([terminal.#ready.promise, Promise.resolve().then(() => terminal.#start(container))]);\n return terminal;\n } catch (error) {\n terminal.dispose();\n throw error;\n }\n }\n\n private constructor(options: WebTerminalOptions) {\n this.#options = options;\n if (options.readOnly !== undefined && typeof options.readOnly !== \"boolean\")\n throw new TypeError(\"readOnly must be a boolean\");\n this.#readOnly = options.readOnly ?? false;\n this.#renderer = normalizeRenderer(options.renderer);\n if (options.workerUrl !== undefined && !(options.workerUrl instanceof URL) &&\n (typeof options.workerUrl !== \"string\" || !options.workerUrl.trim()))\n throw new TypeError(\"workerUrl must be a nonempty URL string or URL\");\n if (options.linkDetectionWorkerUrl !== undefined && !(options.linkDetectionWorkerUrl instanceof URL) &&\n (typeof options.linkDetectionWorkerUrl !== \"string\" || !options.linkDetectionWorkerUrl.trim()))\n throw new TypeError(\"linkDetectionWorkerUrl must be a nonempty URL string or URL\");\n if (options.onLinkDetectionError !== undefined && typeof options.onLinkDetectionError !== \"function\")\n throw new TypeError(\"onLinkDetectionError must be a function\");\n if (options.onSelectionUI !== undefined && (typeof options.onSelectionUI !== \"function\" ||\n options.onSelectionUI.constructor.name === \"AsyncFunction\"))\n throw new TypeError(\"onSelectionUI must be a synchronous event handler\");\n this.#policy = new InputPolicy(options);\n this.#actions = new Map(Object.entries(options.actions ?? {}));\n this.#links = normalizeLinks(options.links, new Set(this.#actions.keys()));\n this.#linkDetector = new LinkDetection({\n workerUrl: options.linkDetectionWorkerUrl === undefined ? undefined : new URL(options.linkDetectionWorkerUrl, location.href),\n actions: new Set(this.#actions.keys()),\n onChange: (revision, links) => {\n if (this.#disposed || !this.#connected || revision !== this.#linkRevision) return;\n this.#detectedLinks = links;\n this.#replacePresentedLinks([]);\n this.#requestLinkDecorations();\n },\n onError: error => {\n if (this.#disposed) return;\n try { this.#options.onStatus?.(`Link detection: ${error.message}`, \"error\"); }\n finally { this.#options.onLinkDetectionError?.(error); }\n },\n });\n this.#linkDetector.configure(this.#links ? this.#links.detection : false);\n this.#history = new HistoryState(command => this.#send(command), () => this.#inspectionChanged());\n this.element = document.createElement(\"div\");\n this.element.className = \"hex1b-terminal\";\n this.element.tabIndex = -1;\n this.element.style.cssText = \"width:100%;height:100%;min-width:0;min-height:0;contain:strict\";\n }\n\n get geometry(): TerminalGeometry { return { ...this.#geometry }; }\n get peer(): TerminalPeer { return { ...this.#peer }; }\n get connected() { return this.#connected; }\n get readOnly(): boolean { return this.#readOnly; }\n /** Current presented workload title; retained on disconnect/dispose. Treat as untrusted text. */\n get title(): string { return this.#title; }\n get progress(): TerminalProgress { return { ...this.#progress }; }\n get shellIntegration(): TerminalShellIntegration { return { ...this.#shellIntegration }; }\n get workingDirectory(): TerminalWorkingDirectory { return { ...this.#workingDirectory }; }\n get commandMark(): TerminalCommandMark | null { return this.#commandMark ? { ...this.#commandMark } : null; }\n get stats(): TerminalStats { return { ...this.#stats }; }\n get screenText() { return this.#screenText; }\n get sizing(): TerminalSizingState { return { ...this.#sizing }; }\n get inputBindings(): InputBinding[] { return this.#policy.bindings; }\n get viewport(): TerminalViewport {\n const viewport = this.#history.viewport;\n return { ...viewport, followTail: viewport.following,\n offset: viewport.available ? viewport.liveTop - viewport.top : 0 };\n }\n get selection(): TerminalSelection {\n const selection = this.#history.selection;\n return { ...selection, active: selection.status === \"valid\", pending: selection.status === \"pending\",\n copying: this.#copying, copyError: this.#inspectionError };\n }\n\n #start(container: HTMLElement): void {\n if (this.#options.signal?.aborted) throw this.#options.signal.reason;\n const url = new URL(this.#options.url, location.href);\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (![\"ws:\", \"wss:\"].includes(url.protocol)) throw new TypeError(\"A ws: or wss: URL is required\");\n const scale = this.#options.scale === undefined || this.#options.scale === \"auto\"\n ? Math.min(3, Math.max(0.5, window.devicePixelRatio || 1)) : this.#options.scale;\n if (!Number.isFinite(scale) || scale < 0.5 || scale > 3) throw new RangeError(\"Backing scale must be 0.5-3 or 'auto'\");\n const font = normalizeFont(this.#options.font, location.href);\n this.#sizing = normalizeSizing(this.#options.sizing);\n const shadow = this.element.attachShadow({ mode: \"open\" });\n shadow.innerHTML = `\n \n
\n \n
\n \n \n
\n \n \n \n
`;\n this.#surface = requiredElement(shadow, \".surface\", HTMLDivElement);\n this.#canvas = requiredElement(shadow, \"canvas\", HTMLCanvasElement);\n this.#input = requiredElement(shadow, \"textarea\", HTMLTextAreaElement);\n this.#highlights = requiredElement(shadow, \".highlights\", HTMLDivElement);\n this.#inspection = requiredElement(shadow, \".inspection\", HTMLDivElement);\n this.#selectionOverlay = document.createElement(\"div\");\n this.#selectionOverlay.slot = \"selection-ui\";\n this.#selectionOverlay.className = \"hex1b-selection-overlay\";\n this.#selectionOverlay.style.cssText = \"position:relative;width:100%;height:100%;pointer-events:none\";\n this.element.append(this.#selectionOverlay);\n this.#selectionUI = new SelectionUI({\n element: this.element, overlay: this.#selectionOverlay,\n button: requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement), signal: this.#listeners.signal,\n getState: () => ({ selection: this.selection, viewport: this.viewport, geometry: this.geometry,\n canvasSize: this.#canvasSize, connected: this.#connected, readOnly: this.#readOnly }),\n runAction: this.runAction.bind(this),\n onSelectionUI: this.#options.onSelectionUI,\n reportError: error => {\n this.#selectionUIError = error ? `Selection UI failed: ${errorMessage(error)}` : \"\";\n this.#selectionOverlay.hidden = !!error;\n this.#renderInspectionStatus();\n if (error) this.#options.onStatus?.(this.#selectionUIError, \"error\");\n }\n });\n this.#selectionUI.refresh();\n this.#input.setAttribute(\"aria-label\", this.#options.label || \"Terminal input. Click outside to use page controls.\");\n this.#input.disabled = true;\n container.append(this.element);\n const inspect = (operation: () => void) => {\n try { this.#inspectionError = \"\"; operation(); }\n catch (error) { this.#inspectionError = errorMessage(error); this.#inspectionChanged(); }\n };\n this.#mouse = captureMouse(this.#canvas, command => this.#inputCommand(command), () => this.focus(), {\n state: () => ({ historical: !this.viewport.following || this.viewport.pending, readOnly: this.#readOnly,\n selection: this.selection }),\n begin: (point, selection) => inspect(() => this.#history.begin(point, selection)),\n extend: point => inspect(() => this.#history.extend(point)),\n scroll: (delta, endpoint) => inspect(() => this.#history.scroll(delta, endpoint)),\n end: cancelled => this.#history.endGesture(cancelled),\n resolve: input => this.#resolveInput(input),\n execute: (decision, input) => this.#executeInputAction(decision, input),\n hyperlink: point => this.#linkAt(point),\n hoverHyperlink: link => {\n this.#hoveredLinkId = link?.id;\n if (this.#links && this.#links.detection && this.#links.detection.decoration === \"hover\")\n this.#requestLinkDecorations();\n },\n openHyperlink: (link, input) => this.#activateLink(link, input),\n });\n this.#bindKeyboard();\n requiredElement(this.#inspection, \".return-live\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.ScrollToLive).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(this.#inspection, \".copy-selection\", HTMLButtonElement).addEventListener(\"click\", () => {\n this.runAction(TerminalAction.CopySelection).catch(error => this.#actionFailed(error));\n }, { signal: this.#listeners.signal });\n requiredElement(shadow, \".viewport\", HTMLDivElement).addEventListener(\"pointerdown\", event => {\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n if ((event.target instanceof Element && event.target.closest(\"button\")) ||\n event.target === this.#canvas || event.target === this.#input) return;\n event.preventDefault();\n this.focus();\n }, { signal: this.#listeners.signal });\n this.#observer = new ResizeObserver(entries => {\n const { width, height } = entries[0].contentRect;\n const changed = width !== this.#size.width || height !== this.#size.height;\n this.#size = { width, height };\n this.#fit();\n if (changed) this.#queueResize();\n });\n // Observe the caller's outer box, never the fitted inner surface.\n this.#observer.observe(container);\n window.addEventListener(\"pagehide\", () => this.dispose(), { signal: this.#listeners.signal });\n this.#options.signal?.addEventListener(\"abort\", () => this.dispose(), { once: true, signal: this.#listeners.signal });\n this.#readyTimer = setTimeout(() => this.#fail(new Error(\"Timed out waiting for the terminal's first frame\")), 30000);\n this.#worker = this.#options.workerUrl === undefined\n ? new Worker(new URL(\"./terminal-worker.js\", import.meta.url), { type: \"module\", name: \"Hex1b WebTerminal\" })\n : new Worker(new URL(this.#options.workerUrl, location.href), { type: \"module\", name: \"Hex1b WebTerminal\" });\n this.#worker.addEventListener(\"message\", (event: MessageEvent) => this.#message(event.data));\n this.#worker.addEventListener(\"error\", event => {\n event.preventDefault();\n this.#fail(new Error(event.message || \"Terminal worker failed\"));\n });\n this.#worker.addEventListener(\"messageerror\", () => this.#fail(new Error(\"Terminal worker message could not be decoded\")));\n const canvas = this.#canvas.transferControlToOffscreen();\n this.#post({ type: \"init\", canvas, url: url.href, scale, font,\n renderer: this.#renderer }, [canvas]);\n this.#postLinkConfiguration();\n }\n\n #message(message: WorkerOutputMessage): void {\n if (this.#disposed) return;\n if (message.type === \"connected\") {\n this.#connected = true;\n this.#input.disabled = !this.#canInput();\n } else if (message.type === \"closed\") {\n if (this.#closed) return;\n this.#closed = true;\n clearTimeout(this.#readyTimer);\n try {\n this.#disconnect();\n if (!this.#disposed) this.#options.onClose?.(Object.freeze({ ...message.details }));\n } finally {\n this.#ready.reject(new Error(`Terminal WebSocket closed (${message.details.code}${\n message.details.reason ? `: ${message.details.reason}` : \"\"}) before mounting completed`));\n }\n } else if (message.type === \"status\") {\n if (message.level === \"error\") {\n this.#disconnect();\n this.#ready.reject(new Error(message.message));\n }\n this.#options.onStatus?.(message.message, message.level);\n } else if (message.type === \"geometry\") {\n this.#linkRevision = message.revision;\n this.#osc8Rows.clear();\n for (const range of message.hyperlinks) {\n const row = this.#osc8Rows.get(range.row) ?? [];\n row.push(range);\n this.#osc8Rows.set(range.row, row);\n }\n this.#replacePresentedLinks([]);\n this.#pendingLinks = undefined;\n const first = !this.#hasGeometry;\n const geometryChanged = first || ([\"columns\", \"rows\", \"cellWidth\", \"cellHeight\", \"mouseTracking\"] as const)\n .some(field => this.#geometry[field] !== message[field]);\n this.#geometry = {\n columns: message.columns, rows: message.rows,\n cellWidth: message.cellWidth, cellHeight: message.cellHeight, mouseTracking: message.mouseTracking\n };\n this.#hasGeometry = true;\n const oldPeer = this.#peer;\n this.#peer = message.peer;\n this.#input.disabled = !this.#canInput();\n if (this.#canInput() && document.activeElement === this.element && !this.element.shadowRoot?.activeElement) this.focus();\n this.#hyperlinks.update(message.hyperlinks);\n if (geometryChanged || oldPeer.isPrimary !== this.#peer.isPrimary) this.#fit();\n if (!this.#peer.isPrimary) {\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n }\n if (first || (!oldPeer.isPrimary && this.#peer.isPrimary)) this.#queueResize(true);\n if (this.#lastRequested === `${message.columns}x${message.rows}`) this.#lastRequested = undefined;\n if (Object.hasOwn(message, \"history\")) {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n }\n if (message.linkGeneration === this.#linkGeneration) {\n if (message.linkSnapshot) this.#acceptLinkSnapshot(message.linkSnapshot);\n else {\n if (this.#linkSnapshot) this.#linkSnapshot = { ...this.#linkSnapshot, revision: message.revision };\n this.#linkDetector.advance(message.revision);\n }\n }\n this.#mouse?.update(message.columns, message.rows, message.mouseTracking);\n if (geometryChanged) this.#options.onGeometry?.(this.geometry);\n if (first) this.#options.onSizingChange?.(this.sizing);\n if (oldPeer.id !== this.#peer.id || oldPeer.primaryId !== this.#peer.primaryId || oldPeer.isPrimary !== this.#peer.isPrimary) {\n this.#options.onRoleChange?.(this.peer);\n }\n if (!this.#disposed && this.#connected && (this.#peer.id !== null || this.#peer.isPrimary)) {\n const titleChanged = !this.#hasTitle || this.#title !== message.title;\n const progressChanged = !this.#hasActivity || this.#progress.state !== message.progress.state ||\n this.#progress.percentage !== message.progress.percentage;\n const shellChanged = !this.#hasActivity || this.#shellIntegration.phase !== message.shellIntegration.phase ||\n this.#shellIntegration.lastExitCode !== message.shellIntegration.lastExitCode;\n const workingDirectoryChanged = !this.#hasActivity ||\n this.#workingDirectory.uri !== message.workingDirectory.uri;\n const commandMarkChanged = !this.#hasActivity || this.#commandMark?.phase !== message.commandMark?.phase ||\n this.#commandMark?.exitCode !== message.commandMark?.exitCode ||\n this.#commandMark?.rawParameters !== message.commandMark?.rawParameters;\n this.#title = message.title;\n this.#hasTitle = true;\n this.#progress = { ...message.progress };\n this.#shellIntegration = { ...message.shellIntegration };\n this.#workingDirectory = { ...message.workingDirectory };\n this.#commandMark = message.commandMark ? { ...message.commandMark } : null;\n this.#hasActivity = true;\n if (titleChanged) this.#options.onTitleChange?.(this.#title);\n if (!this.#disposed && progressChanged) this.#options.onProgressChange?.(this.progress);\n if (!this.#disposed && shellChanged) this.#options.onShellIntegrationChange?.(this.shellIntegration);\n if (!this.#disposed && workingDirectoryChanged) this.#options.onWorkingDirectoryChange?.(this.workingDirectory);\n if (!this.#disposed && commandMarkChanged) this.#options.onCommandMarkChange?.(this.commandMark);\n }\n } else if (message.type === \"linkSnapshot\") {\n if (message.generation === this.#linkGeneration && message.snapshot.revision === this.#linkRevision)\n this.#acceptLinkSnapshot(message.snapshot);\n } else if (message.type === \"linkDecorations\") {\n const pending = this.#pendingLinks;\n if (this.#connected && pending && pending.serial === message.serial &&\n pending.generation === message.generation && message.generation === this.#linkGeneration &&\n pending.revision === message.revision && message.revision === this.#linkRevision) {\n this.#pendingLinks = undefined;\n this.#replacePresentedLinks(pending.links);\n this.#mouse?.refresh();\n }\n } else if (message.type === \"history\") {\n this.#screenText = message.text;\n this.#history.accept(message.history, message.revision);\n } else if (message.type === \"stats\") {\n this.#stats = message.stats;\n if (message.text !== undefined) this.#screenText = message.text;\n if (message.stats.revision > 0 && this.#hasTitle && this.#hasActivity && this.#connected &&\n (this.#peer.id !== null || this.#peer.isPrimary)) {\n clearTimeout(this.#readyTimer);\n this.#ready.resolve(this);\n }\n this.#options.onStats?.(this.stats, message.text);\n }\n }\n\n #fit() {\n const width = this.#geometry.columns * this.#geometry.cellWidth;\n const height = this.#geometry.rows * this.#geometry.cellHeight;\n const scale = fittedScale(this.#size, this.#geometry, this.#peer.isPrimary, this.#sizing);\n this.#surface.style.width = `${width * scale}px`;\n this.#surface.style.height = `${height * scale}px`;\n // Overlay positions use layout pixels, before any ancestor CSS transforms.\n const style = getComputedStyle(this.#surface);\n this.#canvasSize = { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };\n this.#selectionUI?.refresh();\n const dpr = window.devicePixelRatio || 1;\n this.#post({ type: \"viewport\", width: Math.ceil(width * scale * dpr), height: Math.ceil(height * scale * dpr) });\n }\n\n #fittedGrid() {\n return requestedGrid(this.#size, this.#geometry, this.#sizing);\n }\n\n #queueResize(includeFixed = false) {\n if (this.#sizing.mode === \"fixed\" && !includeFixed) return;\n if (!this.#canInput() || !this.#peer.isPrimary || this.#resizeTimer !== undefined || this.#disposed) return;\n // Throttle (rather than debounce) so dragging a primary view updates peers live.\n this.#resizeTimer = setTimeout(() => {\n this.#resizeTimer = undefined;\n const grid = this.#fittedGrid();\n if (!grid || !this.#peer.isPrimary || !this.#canInput()) return;\n const key = `${grid.columns}x${grid.rows}`;\n if ((grid.columns === this.#geometry.columns && grid.rows === this.#geometry.rows) || key === this.#lastRequested) return;\n this.resize(grid.columns, grid.rows);\n }, 50);\n }\n\n #post(message: WorkerInputMessage, transfer: Transferable[] = []): void {\n this.#worker?.postMessage(message, transfer);\n }\n\n #send(command: TerminalCommand): void {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly && [\"input\", \"paste\", \"key\", \"mouse\", \"resize\", \"requestPrimary\"].includes(command.type))\n throw new Error(\"Terminal view does not accept input\");\n this.#post({ type: \"command\", command });\n }\n\n #inputCommand(command: InputCommand): void {\n if (this.#disposed || !this.#canInput()) return;\n assertCommandSize(command);\n this.#inputSerial++;\n if ([\"input\", \"paste\", \"key\"].includes(command.type) && this.viewport.available) {\n this.#mouse?.cancel();\n if (this.selection.status !== \"none\") this.clearSelection();\n if (!this.viewport.following || this.viewport.pending) this.scrollToLive();\n }\n this.#send(command);\n }\n\n #inspectionChanged() {\n const viewport = this.viewport;\n const selection = this.selection;\n this.#mouse?.refresh();\n if (selection.status === \"invalidated\") this.#mouse?.cancel();\n if (this.#highlights) {\n this.#highlights.replaceChildren(...selection.ranges.map(range => {\n const element = document.createElement(\"span\");\n element.className = \"highlight\";\n element.setAttribute(\"part\", \"selection-highlight\");\n element.style.cssText = `left:${range.startColumn / this.#geometry.columns * 100}%;top:${range.row / this.#geometry.rows * 100}%;width:${(range.endColumn - range.startColumn) / this.#geometry.columns * 100}%;height:${100 / this.#geometry.rows}%`;\n return element;\n }));\n const live = requiredElement(this.#inspection, \".return-live\", HTMLButtonElement);\n live.hidden = !viewport.available || (viewport.following && !viewport.pending);\n live.disabled = !this.#connected;\n this.#renderInspectionStatus();\n }\n if (!this.#disposed) this.#selectionUI?.refresh();\n this.#options.onViewportChange?.(viewport);\n this.#options.onSelectionChange?.(selection);\n }\n\n scrollLines(delta: number): void { this.#history.scroll(delta); }\n scrollToLive() { this.#history.live(); }\n clearSelection() { this.#inspectionError = \"\"; this.#history.clear(); }\n\n /** Re-notifies selection UI hosts after an external styling/policy change. */\n refreshSelectionUI() {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#selectionUI?.refresh(true);\n }\n\n #renderInspectionStatus() {\n if (!this.#inspection) return;\n const selection = this.selection;\n const viewport = this.viewport;\n const status = requiredElement(this.#inspection, \".inspection-message\", HTMLSpanElement);\n status.textContent = this.#selectionUIError || this.#inspectionError ||\n (selection.status === \"unavailable\" ? \"\" : selection.message) ||\n (viewport.available && !viewport.following ? `${viewport.liveTop - viewport.top} rows above live` : \"\");\n status.hidden = !status.textContent;\n status.dataset.level = this.#selectionUIError || this.#inspectionError ||\n selection.status === \"invalidated\" ? \"error\" : \"info\";\n }\n\n #actionFailed(error: unknown): void {\n const failure = error instanceof Error ? error : new Error(String(error));\n this.#inspectionError = `Input action failed: ${failure.message}`;\n this.#inspectionChanged();\n this.#options.onInputError?.(failure);\n }\n\n async copySelection({ clear = false }: CopySelectionOptions = {}): Promise {\n if (typeof clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n const serial = ++this.#copySerial;\n const selectionId = this.selection.requestId;\n const generation = this.viewport.generation;\n this.#inspectionError = \"\";\n try {\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (!navigator.clipboard?.write || typeof ClipboardItem !== \"function\") {\n throw new Error(\"Clipboard writing is unavailable. Use a secure browser context with clipboard permission.\");\n }\n const text = this.#history.copy();\n this.#copying = true;\n this.#inspectionChanged();\n // Invoke the clipboard during the user gesture; producer extraction can complete asynchronously.\n const item = new ClipboardItem({ \"text/plain\": text.then(value => new Blob([value], { type: \"text/plain\" })) });\n const [value] = await Promise.all([text, navigator.clipboard.write([item])]);\n if (clear && serial === this.#copySerial && this.selection.status === \"valid\" &&\n this.selection.requestId === selectionId && this.viewport.generation === generation)\n this.clearSelection();\n return value;\n } catch (error) {\n if (serial === this.#copySerial) {\n this.#history.cancelCopy(error);\n this.#inspectionError = `Copy failed: ${errorMessage(error)}`;\n }\n throw error;\n } finally {\n if (serial === this.#copySerial) this.#copying = false;\n this.#inspectionChanged();\n }\n }\n\n #canInput() {\n return this.#connected && this.#hasGeometry && !this.#readOnly &&\n (this.#peer.id !== null || this.#peer.isPrimary);\n }\n\n get inputContext(): TerminalInputContext {\n return Object.freeze({\n terminal: this, selection: this.selection, viewport: this.viewport,\n buffer: this.viewport.buffer ?? null, mouseCaptured: !this.#readOnly && this.#geometry.mouseTracking !== 0,\n historical: !this.viewport.following || this.viewport.pending,\n readOnly: this.#readOnly, connected: this.#connected, peer: this.peer\n });\n }\n\n #resolveInput(input: TerminalInput): InputDecision {\n try {\n const decision = this.#policy.resolve(Object.freeze(input), this.inputContext);\n if (this.#readOnly && decision.route === InputRoute.Application)\n return { route: input.type === \"pointer\" || input.type === \"wheel\" ? InputRoute.Continue : InputRoute.Consume };\n return decision;\n }\n catch (error) {\n this.#actionFailed(error);\n return { route: InputRoute.Consume };\n }\n }\n\n #executeInputAction(decision: Extract, input: TerminalInput): void {\n this.#performAction(decision.action, decision.args, input).catch(error => this.#actionFailed(error));\n }\n\n /** Named actions are shared by controls and bindings. Custom callbacks receive context, args, and input. */\n runAction(action: \"copySelection\", args?: CopySelectionOptions, input?: TerminalInput): Promise;\n runAction(action: \"pasteClipboard\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"copyOrPaste\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"clearSelection\" | \"scrollToLive\", args?: undefined, input?: TerminalInput): Promise;\n runAction(action: \"scrollLines\", args: number, input?: TerminalInput): Promise;\n runAction(action: Name extends TerminalActionName ? never : Name,\n args?: unknown, input?: TerminalInput): Promise;\n runAction(action: InputActionHandler, args?: unknown, input?: TerminalInput): Promise;\n async runAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n return this.#performAction(action, args, input);\n }\n\n async #performAction(action: string | InputActionHandler, args?: unknown, input?: TerminalInput): Promise {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n this.#inspectionError = \"\";\n if (typeof action === \"function\") return action(this.inputContext, args, input);\n const customAction = this.#actions.get(action);\n if (customAction) return customAction(this.inputContext, args, input);\n switch (action) {\n case TerminalAction.CopySelection:\n if (args === undefined) return this.copySelection();\n if (!isRecord(args)) throw new TypeError(\"Copy options must be an object\");\n if (args.clear !== undefined && typeof args.clear !== \"boolean\") throw new TypeError(\"Copy clear must be a boolean\");\n return this.copySelection({ clear: args.clear });\n case TerminalAction.PasteClipboard: return this.pasteClipboard();\n case TerminalAction.ClearSelection: return this.clearSelection();\n case TerminalAction.ScrollToLive: return this.scrollToLive();\n case TerminalAction.ScrollLines:\n if (typeof args !== \"number\") throw new RangeError(\"Scroll delta must be a signed 32-bit integer\");\n return this.scrollLines(args);\n case TerminalAction.CopyOrPaste:\n if (this.#clipboardAction) throw new Error(\"A clipboard action is still in progress. Try again when it finishes.\");\n this.#clipboardAction = true;\n try {\n if (this.selection.active || (this.selection.pending && this.selection.canExtend))\n return await this.copySelection({ clear: true });\n if (!this.#readOnly) return await this.pasteClipboard();\n return;\n } finally { this.#clipboardAction = false; }\n default: throw new TypeError(`Unknown terminal action: ${action}`);\n }\n }\n\n /** Sends an explicit paste through the producer's mode-aware input encoder. */\n paste(text: string): void {\n if (typeof text !== \"string\") throw new TypeError(\"Paste text must be a string\");\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (text) this.#inputCommand({ type: \"paste\", text });\n }\n\n async pasteClipboard(): Promise {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!navigator.clipboard?.readText)\n throw new Error(\"Clipboard reading is unavailable. Use the browser's paste shortcut instead.\");\n const serial = this.#inputSerial;\n const generation = this.viewport.generation;\n const selectionId = this.selection.requestId;\n const focused = document.activeElement;\n const text = await navigator.clipboard.readText();\n if (!this.#canInput() || serial !== this.#inputSerial || generation !== this.viewport.generation ||\n selectionId !== this.selection.requestId || document.activeElement !== focused)\n throw new Error(\"Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again.\");\n this.paste(text);\n return text;\n }\n\n /** Changes per-view input policy without reconnecting; server authorization remains host-owned. */\n setReadOnly(readOnly: boolean): void {\n if (typeof readOnly !== \"boolean\") throw new TypeError(\"readOnly must be a boolean\");\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n if (this.#readOnly === readOnly) return;\n const inputFocused = document.activeElement === this.element &&\n (!this.element.shadowRoot?.activeElement || this.element.shadowRoot.activeElement === this.#input);\n this.#readOnly = readOnly;\n this.#inputSerial++;\n this.#resetComposition?.();\n if (this.#input) {\n this.#input.value = \"\";\n this.#input.disabled = !this.#canInput();\n }\n // Set the policy before cancelling so pending moves and button releases cannot leak.\n this.#mouse?.cancel();\n this.#mouse?.refresh();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#queueResize(true);\n if (inputFocused) this.focus();\n this.#selectionUI?.refresh();\n }\n\n setLinks(options: false | TerminalLinkOptions): void {\n if (this.#disposed) throw new Error(\"Terminal view is disposed\");\n if (options === undefined) throw new TypeError(\"Link options must be an object or false\");\n const next = normalizeLinks(options, new Set(this.#actions.keys()));\n this.#links = next;\n this.#linkGeneration++;\n this.#detectedLinks = [];\n this.#replacePresentedLinks([]);\n this.#pendingLinks = undefined;\n this.#linkSnapshot = undefined;\n this.#hoveredLinkId = undefined;\n this.#linkDetector.configure(next ? next.detection : false);\n this.#mouse?.cancel();\n this.#mouse?.refresh();\n this.#postLinkConfiguration();\n }\n\n #postLinkConfiguration(): void {\n const enabled = !!(this.#links && (this.#links.osc8 ||\n (this.#links.detection && this.#links.detection.rules.some(rule => rule.enabled !== false))));\n this.#post({ type: \"linkDetection\", enabled, generation: this.#linkGeneration });\n }\n\n #acceptLinkSnapshot(snapshot: LinkDetectionSnapshot): void {\n this.#linkSnapshot = snapshot;\n this.#detectedLinks = [];\n this.#replacePresentedLinks([]);\n this.#pendingLinks = undefined;\n this.#linkDetector.update(snapshot);\n this.#mouse?.refresh();\n }\n\n #detectedPointerId(link: DetectedLink): string {\n return `detected/${this.#linkGeneration}/${this.#linkRevision}/${link.id}`;\n }\n\n #replacePresentedLinks(links: readonly DetectedLink[]): void {\n this.#presentedLinks = links;\n this.#detectedRows.clear();\n for (const link of links) {\n for (const range of link.activation.ranges) {\n const row = this.#detectedRows.get(range.row) ?? [];\n row.push({ startColumn: range.startColumn, endColumn: range.endColumn, link });\n this.#detectedRows.set(range.row, row);\n }\n }\n }\n\n #requestLinkDecorations(): void {\n if (!this.#worker || !this.#connected || !this.#linkRevision) return;\n const detection = this.#links && this.#links.detection;\n if (!detection) return;\n const visible = detection.decoration === \"none\" ? [] : detection.decoration === \"hover\"\n ? this.#detectedLinks.filter(link => this.#detectedPointerId(link) === this.#hoveredLinkId)\n : this.#detectedLinks;\n this.#pendingLinks = { revision: this.#linkRevision, generation: this.#linkGeneration,\n serial: ++this.#linkSerial, links: this.#detectedLinks };\n this.#post({ type: \"linkDecorations\", revision: this.#linkRevision, generation: this.#linkGeneration,\n serial: this.#linkSerial, ranges: visible.flatMap(link => link.activation.ranges),\n underlineStyle: detection.underlineStyle ?? \"solid\" });\n }\n\n #linkAt(point: TerminalPoint): PointerHyperlink | null {\n if (!this.#connected || this.viewport.pending || !this.#links) return null;\n const osc8 = this.#osc8Rows.get(point.y)?.find(range =>\n point.x >= range.startColumn && point.x < range.endColumn);\n if (osc8) {\n if (this.#links.osc8 === false) return null;\n const target = this.#links.osc8\n ? (!/[\\u0000-\\u0020\\u007f]/u.test(osc8.uri) && URL.canParse(osc8.uri) ? osc8.uri : null)\n : this.#hyperlinks.at(point);\n if (!target || (this.#links.osc8 && this.#linkSnapshot?.revision !== this.#linkRevision)) return null;\n return { id: `osc8/${this.#linkGeneration}/${this.#linkRevision}/${osc8.row}/${osc8.startColumn}/${osc8.endColumn}`,\n target, activation: \"modifierClick\" };\n }\n const detection = this.#links.detection;\n if (!detection) return null;\n const link = this.#detectedRows.get(point.y)?.find(range =>\n point.x >= range.startColumn && point.x < range.endColumn)?.link;\n return link ? { id: this.#detectedPointerId(link), target: link.activation.target,\n activation: detection.activation ?? \"modifierClick\" } : null;\n }\n\n #activateLink(pointer: PointerHyperlink, input: TerminalInput): void {\n if (input.type !== \"pointer\" || this.#linkAt(input.point)?.id !== pointer.id || !this.#links) return;\n const detected = this.#presentedLinks.find(link => this.#detectedPointerId(link) === pointer.id);\n if (detected) {\n this.#performAction(detected.action, detected.activation, input).catch(error => this.#actionFailed(error));\n return;\n }\n if (!this.#links.osc8) {\n try { window.open(pointer.target, \"_blank\", \"noopener,noreferrer\"); }\n catch (error) { this.#actionFailed(error); }\n return;\n }\n const range = this.#osc8Rows.get(input.point.y)?.find(range =>\n input.point.x >= range.startColumn && input.point.x < range.endColumn);\n const snapshot = this.#linkSnapshot;\n if (!range || !snapshot) return;\n let text = \"\";\n for (let column = range.startColumn; column < range.endColumn; column++) {\n const cell = snapshot.cells[range.row * snapshot.columns + column];\n if (cell?.width && !(cell.attributes & 64)) text += cell.text;\n }\n const activation: TerminalLinkActivation = Object.freeze({\n source: \"osc8\", ruleId: null, kind: \"uri\", target: pointer.target, text,\n revision: this.#linkRevision,\n ranges: Object.freeze([Object.freeze({ row: range.row, startColumn: range.startColumn, endColumn: range.endColumn })]),\n });\n this.#performAction(this.#links.osc8.action, activation, input).catch(error => this.#actionFailed(error));\n }\n\n #forwardInput(input: TerminalInput): void {\n if (input.type === \"key\") {\n if (input.meta) throw new Error(\"Meta has no terminal key encoding; bind this shortcut to a local action instead.\");\n this.#inputCommand({ type: \"key\", key: input.key, ctrl: input.ctrl, alt: input.alt, shift: input.shift });\n } else if (input.type === \"paste\") {\n if (this.#canInput()) this.paste(input.text);\n } else if (input.type === \"text\") {\n this.#inputCommand({ type: \"input\", text: input.text });\n }\n }\n\n #dispatchInput(input: TerminalInput, event?: Event, allowApplication = true): void {\n const decision = this.#resolveInput(input);\n // Shortcuts may run on inspection controls, but Enter/Space must still activate the control.\n if (!allowApplication && decision.action === undefined && decision.route !== InputRoute.Consume) return;\n if (decision.route === InputRoute.Browser ||\n (decision.route === InputRoute.Continue && input.type === \"key\")) return;\n event?.preventDefault();\n event?.stopPropagation();\n if (decision.action !== undefined) this.#executeInputAction(decision, input);\n else if (decision.route !== InputRoute.Consume) {\n try { this.#forwardInput(input); }\n catch (error) { this.#actionFailed(error); }\n }\n }\n\n #bindKeyboard() {\n const input = this.#input;\n const options = { signal: this.#listeners.signal };\n let composing = false;\n let compositionCommit: string | null = null;\n this.#resetComposition = () => {\n composing = false;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n };\n this.element.addEventListener(\"keydown\", event => {\n if (event.defaultPrevented) return;\n if (event.composedPath().includes(this.#selectionOverlay)) return;\n const target = event.composedPath()[0];\n if (!this.#connected || event.isComposing || composing || event.key === \"Process\" || event.key === \"Dead\") return;\n if (event.getModifierState(\"AltGraph\")) return;\n this.#dispatchInput({ type: \"key\", key: event.key, code: event.code, repeat: event.repeat,\n ...inputModifiers(event) }, event, target === input || target === this.element);\n }, { ...options, capture: true });\n input.addEventListener(\"paste\", event => {\n if (event.defaultPrevented || !this.#connected || !event.clipboardData) return;\n this.#dispatchInput({ type: \"paste\", text: event.clipboardData.getData(\"text/plain\") }, event);\n input.value = \"\";\n }, options);\n input.addEventListener(\"compositionstart\", () => {\n if (!this.#canInput()) return;\n composing = true;\n compositionCommit = null;\n clearTimeout(this.#compositionTimer);\n }, options);\n input.addEventListener(\"compositionend\", event => {\n if (!composing) return;\n composing = false;\n // Accommodate browsers placing the final input before or after compositionend.\n compositionCommit = typeof event.data === \"string\" ? event.data : input.value;\n this.#compositionTimer = setTimeout(() => {\n if (compositionCommit) this.#dispatchInput({ type: \"text\", text: compositionCommit });\n compositionCommit = null;\n input.value = \"\";\n }, 0);\n }, options);\n input.addEventListener(\"input\", event => {\n const inputEvent = event instanceof InputEvent ? event : undefined;\n if (inputEvent?.isComposing || composing) return;\n clearTimeout(this.#compositionTimer);\n const text = compositionCommit ?? inputEvent?.data ?? input.value;\n compositionCommit = null;\n if (text && inputEvent?.inputType !== \"insertFromPaste\") this.#dispatchInput({ type: \"text\", text }, event);\n input.value = \"\";\n }, options);\n }\n\n focus() {\n if (this.#disposed) return;\n const target = this.#canInput() ? this.#input : this.element;\n target.focus({ preventScroll: true });\n }\n\n /** Request HMP1 primary explicitly; peer notifications confirm the result. */\n requestPrimary() {\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (this.#size.width <= 0 || this.#size.height <= 0) throw new Error(\"Show the terminal container before taking primary\");\n const grid = this.#fittedGrid();\n if (!grid) throw new Error(\"Show the terminal container before taking primary\");\n if (this.#peer.id === null) {\n if (!this.#peer.isPrimary) throw new Error(\"Waiting for the HMP1 connection\");\n this.resize(grid.columns, grid.rows);\n return;\n }\n this.#send({ type: \"requestPrimary\", ...grid });\n }\n\n /** Request a grid; never reflow locally before the authoritative response. */\n resize(columns: number, rows: number): void {\n const grid = dimensions(columns, rows);\n if (!this.#canInput()) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can request a terminal resize\");\n this.#send({ type: \"resize\", ...grid });\n this.#lastRequested = `${columns}x${rows}`;\n }\n\n /** Change the primary's sizing policy; applied grid dimensions still come from the server. */\n setSizing(sizing: TerminalSizing): void {\n const next = normalizeSizing(sizing, this.#sizing.fontSize);\n if (!this.#connected || this.#disposed) throw new Error(\"Terminal view is not connected\");\n if (this.#readOnly) throw new Error(\"Terminal view does not accept input\");\n if (!this.#peer.isPrimary) throw new Error(\"Only the primary view can change terminal sizing\");\n this.#sizing = next;\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n this.#lastRequested = undefined;\n this.#fit();\n this.#queueResize(true);\n this.#options.onSizingChange?.(this.sizing);\n }\n\n resync() { this.#send({ type: \"resync\" }); }\n\n #disconnect() {\n this.#inputSerial++;\n this.#connected = false;\n this.#hyperlinks.update([]);\n this.#osc8Rows.clear();\n this.#detectedLinks = [];\n this.#replacePresentedLinks([]);\n this.#linkSnapshot = undefined;\n this.#pendingLinks = undefined;\n this.#linkDetector.clear();\n if (this.#input) this.#input.disabled = true;\n this.#mouse?.update(1, 1, 0);\n this.#history.disconnect();\n this.#inspectionChanged();\n clearTimeout(this.#resizeTimer);\n this.#resizeTimer = undefined;\n }\n\n #fail(error: Error): void {\n this.#disconnect();\n this.#ready.reject(error);\n this.#options.onStatus?.(error.message, \"error\");\n this.#worker?.terminate();\n }\n\n /** Detach this view. The server-side shared terminal is not terminated. */\n dispose() {\n if (this.#disposed) return;\n this.#disposed = true;\n this.#disconnect();\n this.#linkDetector.dispose();\n this.#ready.reject(new DOMException(\"Terminal view was disposed\", \"AbortError\"));\n clearTimeout(this.#readyTimer);\n clearTimeout(this.#compositionTimer);\n this.#observer?.disconnect();\n this.#mouse?.dispose();\n this.#listeners.abort();\n this.#post({ type: \"stop\" });\n this.#worker?.terminate();\n this.element.remove();\n }\n}\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts index c50630cf91d..4a46d255906 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts @@ -1,4 +1,5 @@ -import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark, TerminalCloseDetails } from "./types.js"; +import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalLinkUnderlineStyle, TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats, TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration, TerminalWorkingDirectory, TerminalCommandMark, TerminalCloseDetails } from "./types.js"; +import type { LinkDetectionSnapshot } from "./link-detection.js"; export type SelectionText = { status: "valid"; text: string; @@ -172,6 +173,17 @@ export type WorkerInputMessage = { } | ({ type: "viewport"; } & TerminalSize) | { + type: "linkDetection"; + enabled: boolean; + generation: number; +} | { + type: "linkDecorations"; + revision: number; + generation: number; + serial: number; + ranges: readonly SelectionRange[]; + underlineStyle?: TerminalLinkUnderlineStyle; +} | { type: "command"; command: TerminalCommand; } | { @@ -225,9 +237,20 @@ export type WorkerOutputMessage = { shellIntegration: TerminalShellIntegration; workingDirectory: TerminalWorkingDirectory; commandMark: TerminalCommandMark | null; + linkGeneration?: number; + linkSnapshot?: LinkDetectionSnapshot; text: string; hyperlinks: HyperlinkRange[]; } & TerminalGeometry) | { + type: "linkSnapshot"; + generation: number; + snapshot: LinkDetectionSnapshot; +} | { + type: "linkDecorations"; + revision: number; + generation: number; + serial: number; +} | { type: "history"; history: HistoryMetadata | null; revision: number; diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map index ddb454e9626..3a0fb4a4ef0 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"wire-types.d.ts","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map index c55434a2dac..cfb94c1af1c 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/dist/wire-types.js.map @@ -1 +1 @@ -{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration,\n TerminalWorkingDirectory, TerminalCommandMark, TerminalCloseDetails } from \"./types.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n title: string;\n progress: TerminalProgress;\n shellIntegration: TerminalShellIntegration;\n workingDirectory: TerminalWorkingDirectory;\n commandMark: TerminalCommandMark | null;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" }\n | { type: \"closed\"; details: TerminalCloseDetails }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration;\n workingDirectory: TerminalWorkingDirectory; commandMark: TerminalCommandMark | null;\n text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file +{"version":3,"file":"wire-types.js","sourceRoot":"","sources":["../src/wire-types.ts"],"names":[],"mappings":"","sourcesContent":["import type { InputModifiers, PointerButton, SelectionMode, SelectionRange, TerminalLinkUnderlineStyle,\n TerminalBuffer, TerminalFont, TerminalGeometry, TerminalPeer, TerminalSize, TerminalStats,\n TerminalRendererPreference, TerminalStatusLevel, TerminalProgress, TerminalShellIntegration,\n TerminalWorkingDirectory, TerminalCommandMark, TerminalCloseDetails } from \"./types.js\";\nimport type { LinkDetectionSnapshot } from \"./link-detection.js\";\n\nexport type SelectionText =\n | { status: \"valid\"; text: string }\n | { status: \"none\" | \"invalidated\"; text: null };\nexport type HistorySelection = SelectionText & {\n requestId: number; mode: SelectionMode; ranges: SelectionRange[];\n};\nexport interface HistoryMetadata {\n generation: string; buffer: TerminalBuffer; totalRows: number; liveTop: number; top: number;\n following: boolean; requestId: number; rowIds: string[];\n selection: HistorySelection;\n copy: (SelectionText & { requestId: number }) | null;\n}\nexport interface TerminalCell {\n index: number; foreground: number; background: number; underlineColor: number;\n attributes: number; width: number; underlineStyle: number; text: string;\n}\nexport interface HyperlinkRange {\n row: number; startColumn: number; endColumn: number; uri: string;\n}\nexport interface ImageMetadata {\n key: string; width: number; height: number; byteLength: number; format: \"rgba\" | \"png\";\n}\nexport interface FrameImage extends ImageMetadata { bytes: Uint8Array }\nexport interface ImagePlacement {\n key: string; kind: \"kgp\" | \"sixel\";\n x: number; y: number; width: number; height: number;\n sourceX: number; sourceY: number; sourceWidth: number; sourceHeight: number;\n clipX: number; clipY: number; clipWidth: number; clipHeight: number; z: number;\n}\nexport interface FrameMetadata extends TerminalGeometry {\n version: 1; full: boolean; revision: number; baseRevision: number;\n peer: TerminalPeer; history: HistoryMetadata | null;\n title: string;\n progress: TerminalProgress;\n shellIntegration: TerminalShellIntegration;\n workingDirectory: TerminalWorkingDirectory;\n commandMark: TerminalCommandMark | null;\n defaultBackground?: number; defaultForeground?: number;\n cursor: { visible: boolean; x: number; y: number; shape: number };\n images: ImageMetadata[]; retainedImages: string[]; placements: ImagePlacement[]; warnings: string[];\n hyperlinks: HyperlinkRange[];\n stats: { workloadBytes: number; outputBatches: number; captureMs: number; elapsedMs: number };\n}\nexport interface TerminalFrame { metadata: FrameMetadata; cells: TerminalCell[]; images: FrameImage[] }\nexport type CellPosition = { x: number; y: number } & Partial>;\nexport type MouseButton = PointerButton | \"none\" | \"wheelUp\" | \"wheelDown\" | \"wheelLeft\" | \"wheelRight\";\nexport type MouseCommand = {\n type: \"mouse\"; action: \"down\" | \"up\" | \"move\" | \"wheel\"; button: MouseButton; count?: number;\n} & CellPosition;\nexport type InputCommand =\n | { type: \"input\" | \"paste\"; text: string }\n | { type: \"key\"; key: string; ctrl: boolean; alt: boolean; shift: boolean }\n | MouseCommand;\nexport type TerminalCommand = InputCommand\n | { type: \"viewport\"; requestId: number; delta?: number; live?: boolean;\n extend?: { row: number; column: number } }\n | { type: \"selection\"; action: \"clear\"; requestId: number }\n | { type: \"selection\"; action: \"start\" | \"extend\"; mode: SelectionMode;\n requestId: number; generation: string; rowId: string; column: number }\n | { type: \"copy\"; requestId: number; selectionRequestId: number; generation: string }\n | { type: \"resize\" | \"requestPrimary\"; columns: number; rows: number }\n | { type: \"resync\" }\n | { type: \"ack\"; revision: number };\nexport type WorkerInputMessage =\n | { type: \"init\"; canvas: OffscreenCanvas; url: string; scale: number; font: TerminalFont;\n renderer: TerminalRendererPreference }\n | ({ type: \"viewport\" } & TerminalSize)\n | { type: \"linkDetection\"; enabled: boolean; generation: number }\n | { type: \"linkDecorations\"; revision: number; generation: number; serial: number;\n ranges: readonly SelectionRange[]; underlineStyle?: TerminalLinkUnderlineStyle }\n | { type: \"command\"; command: TerminalCommand }\n | { type: \"stop\" };\nexport interface WorkerStats extends TerminalStats {\n revision: number; fullFrames: number; frames: number; presentations: number;\n changedCells: number; lastChangedCells: number; discardedFrames: number;\n imageCount: number; textureBytes: number; atlasGlyphs: number; atlasBytes: number;\n bytesReceived: number; imageUploadBytes: number; imagePayloadBytes: number;\n gpu: \"initializing\" | \"ready\" | \"error\" | \"stopped\"; connected: boolean; warnings: string[];\n fps: number; receivedKBps: number; workloadMBps: number;\n captureMs: number; rendererCpuMs: number; preparationCpuMs: number;\n workloadBytes: number; outputBatches: number; serverElapsedMs: number;\n history?: HistoryMetadata | null;\n}\nexport type WorkerOutputMessage =\n | { type: \"connected\" }\n | { type: \"closed\"; details: TerminalCloseDetails }\n | { type: \"status\"; message: string; level: TerminalStatusLevel }\n | ({ type: \"geometry\"; peer: TerminalPeer; history: HistoryMetadata | null;\n revision: number; title: string; progress: TerminalProgress; shellIntegration: TerminalShellIntegration;\n workingDirectory: TerminalWorkingDirectory; commandMark: TerminalCommandMark | null;\n linkGeneration?: number; linkSnapshot?: LinkDetectionSnapshot;\n text: string; hyperlinks: HyperlinkRange[] } & TerminalGeometry)\n | { type: \"linkSnapshot\"; generation: number; snapshot: LinkDetectionSnapshot }\n | { type: \"linkDecorations\"; revision: number; generation: number; serial: number }\n | { type: \"history\"; history: HistoryMetadata | null; revision: number; text: string }\n | { type: \"stats\"; stats: WorkerStats; text?: string };\n"]} \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index a45b5e1938c..b7a57129231 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.168.0-alpha.1573.1.2917e83", + "version": "0.168.0-alpha.1585.1.1de7974", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index a0e3fe6fb26..89d1967039d 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -493,7 +493,7 @@ public long? MaxFileSize /// await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions /// { /// Title = "Setup", - /// Command = new TerminalCommand("./setup.sh"), + /// Executable = "./setup.sh", /// Placement = TerminalPlacement.Dialog /// }); /// diff --git a/src/Aspire.Hosting/Terminals/TerminalCommand.cs b/src/Aspire.Hosting/Terminals/TerminalCommand.cs deleted file mode 100644 index a45b8c5e58f..00000000000 --- a/src/Aspire.Hosting/Terminals/TerminalCommand.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.CodeAnalysis; - -namespace Aspire.Hosting.Terminals; - -/// -/// Describes the process a terminal runs, and the grid it starts on. -/// -/// -/// -/// This is Aspire's own description of a terminal workload. It exists so terminals can be created from -/// AppHost code without the underlying terminal library (currently Hex1b) appearing in Aspire's public API. -/// Aspire translates it into whatever the implementation needs and attaches the transport itself. -/// -/// -/// The surface is deliberately narrow — a process, its arguments, and the environment it runs in. Hex1b can -/// also host an in-process TUI app rather than a child process, but that is not projected here because it -/// would put Hex1b's widget model into Aspire's public API, which is exactly what this type exists to avoid. -/// -/// -/// -/// Shell into a running container: -/// -/// var command = new TerminalCommand("docker") -/// { -/// Arguments = ["exec", "-it", containerName, "/bin/sh"] -/// }; -/// -/// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] -public sealed class TerminalCommand -{ - /// - /// The grid a terminal starts on before any viewer attaches. - /// - /// - /// Chosen to be comfortably wider than the 80x24 default so output that assumes a modern terminal is not - /// wrapped in the moments before a viewer attaches and negotiates the real size. - /// - private const int DefaultColumns = 120; - private const int DefaultRows = 32; - - /// - /// Initializes a new instance of the class. - /// - /// The executable to run. Resolved against PATH when not fully qualified. - public TerminalCommand(string executable) - { - ArgumentException.ThrowIfNullOrEmpty(executable); - - Executable = executable; - } - - /// - /// Gets the executable to run. - /// - public string Executable { get; } - - /// - /// Gets or sets the arguments passed to . - /// - /// is . - public IList Arguments - { - get; - set - { - // Validate on assignment rather than when the terminal is created. The arguments are not read until - // TerminalService translates this command into a process, which is far enough away that a null here - // would otherwise surface as an unattributed NullReferenceException inside terminal creation. - ArgumentNullException.ThrowIfNull(value); - field = value; - } - } = []; - - /// - /// Gets or sets the working directory the process starts in. Defaults to the AppHost's working directory. - /// - public string? WorkingDirectory { get; set; } - - /// - /// Gets environment variables applied to the process on top of the AppHost's own environment. - /// - /// - /// The process inherits the AppHost's environment and these are layered over it. That matters for - /// interactive workloads, which generally need an inherited PATH, HOME and TERM - /// to behave like a normal shell. - /// - public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); - - /// - /// Gets or sets the number of columns the terminal starts with. - /// - /// - /// This is only the initial grid. A viewer that attaches renegotiates the size to fit the space it has, - /// so this matters mainly for terminals driven by automation before anyone attaches. - /// - /// is less than one. - public int Columns - { - get; - set - { - // A zero or negative grid is not a terminal the emulator can render into, and the failure would - // otherwise appear deep inside the terminal library rather than at the assignment that caused it. - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); - field = value; - } - } = DefaultColumns; - - /// - /// Gets or sets the number of rows the terminal starts with. - /// - /// - /// is less than one. - public int Rows - { - get; - set - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); - field = value; - } - } = DefaultRows; -} diff --git a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs index d0bec68ed03..3374c36b43f 100644 --- a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs +++ b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs @@ -6,11 +6,25 @@ namespace Aspire.Hosting.Terminals; /// -/// Describes a terminal to be created by . +/// Describes the process, initial grid, and placement of a terminal created by . /// +/// +/// +/// var options = new TerminalLaunchOptions +/// { +/// Title = "Container shell", +/// Executable = "docker", +/// Arguments = ["exec", "-it", containerName, "/bin/sh"] +/// }; +/// +/// [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] public sealed class TerminalLaunchOptions { + // Start wider than 80x24 so output is not wrapped before a viewer negotiates its size. + private const int DefaultColumns = 120; + private const int DefaultRows = 32; + /// /// Gets or sets the title shown on the terminal's dock tab, and in the title bar when the terminal is /// detached into its own window. @@ -21,9 +35,83 @@ public sealed class TerminalLaunchOptions public required string Title { get; set; } /// - /// Gets or sets the process the terminal runs. + /// Gets or sets the executable to run. Resolved against PATH when not fully qualified. + /// + /// is . + /// is empty. + public required string Executable + { + get; + set + { + ArgumentException.ThrowIfNullOrEmpty(value); + field = value; + } + } + + /// + /// Gets or sets the arguments passed to . + /// + /// is . + public IList Arguments + { + get; + set + { + // Validate on assignment so invalid input fails here rather than later inside the terminal library. + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = []; + + /// + /// Gets or sets the working directory the process starts in. Defaults to the AppHost's working directory. + /// + public string? WorkingDirectory { get; set; } + + /// + /// Gets environment variables applied to the process on top of the AppHost's own environment. + /// + /// + /// The process inherits the AppHost's environment. Entries add or override variables without replacing the + /// rest of that environment, so interactive workloads retain inherited PATH, HOME, and + /// TERM values unless explicitly overridden. + /// + public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets or sets the requested initial number of columns. Defaults to 120. + /// + /// + /// This is only the initial grid. A viewer that attaches renegotiates the size to fit the space it has, + /// so this matters mainly for terminals driven by automation before anyone attaches. + /// The current HMP server initializes at 80 columns and 24 rows, overriding these requested dimensions. + /// + /// is less than one. + public int Columns + { + get; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + field = value; + } + } = DefaultColumns; + + /// + /// Gets or sets the requested initial number of rows. Defaults to 32. /// - public required TerminalCommand Command { get; set; } + /// + /// is less than one. + public int Rows + { + get; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + field = value; + } + } = DefaultRows; /// /// Gets or sets where the terminal is displayed. Defaults to . diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 480249886d4..5d23febf658 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -84,8 +84,7 @@ internal TerminalService(ILogger logger, IConfiguration configu /// when the AppHost shuts down. /// /// - /// , its , or its - /// is . + /// or its is . /// /// /// The is empty or consists only of white-space characters. @@ -97,9 +96,8 @@ internal TerminalService(ILogger logger, IConfiguration configu public AspireTerminal CreateTerminal(TerminalLaunchOptions options) { ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(options.Command); - return CreateTerminal(options.Title, options.Placement, CreateBuilder(options.Command)); + return CreateTerminal(options.Title, options.Placement, CreateBuilder(options)); } /// @@ -109,23 +107,23 @@ public AspireTerminal CreateTerminal(TerminalLaunchOptions options) /// This is the single point where Hex1b enters the picture, which is what keeps it out of the public API. /// The process options overload is used rather than WithPtyProcess(file, args) so the working /// directory and environment can be set; InheritEnvironment is left at its default of - /// , so layers over the AppHost's + /// , so layers over the AppHost's /// environment rather than replacing it. Interactive workloads need an inherited PATH/HOME/TERM to behave /// like a normal shell. /// - private static Hex1bTerminalBuilder CreateBuilder(TerminalCommand command) + private static Hex1bTerminalBuilder CreateBuilder(TerminalLaunchOptions options) { return Hex1bTerminal.CreateBuilder() - .WithDimensions(command.Columns, command.Rows) + .WithDimensions(options.Columns, options.Rows) .WithPtyProcess(process => { - process.FileName = command.Executable; - process.Arguments = [.. command.Arguments]; - process.WorkingDirectory = command.WorkingDirectory; + process.FileName = options.Executable; + process.Arguments = [.. options.Arguments]; + process.WorkingDirectory = options.WorkingDirectory; - if (command.EnvironmentVariables.Count > 0) + if (options.EnvironmentVariables.Count > 0) { - process.Environment = new Dictionary(command.EnvironmentVariables, StringComparer.Ordinal); + process.Environment = new Dictionary(options.EnvironmentVariables, StringComparer.Ordinal); } }); } @@ -135,7 +133,7 @@ private static Hex1bTerminalBuilder CreateBuilder(TerminalCommand command) /// /// /// Internal because the builder is a Hex1b type. This is the path used by workloads that a - /// cannot describe — notably the dock's built-in terminal, which runs an + /// cannot describe — notably the dock's built-in terminal, which runs an /// in-process Hex1b app rather than a child process. /// internal AspireTerminal CreateTerminal(string title, TerminalPlacement placement, Hex1bTerminalBuilder builder) diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 44d94a0f0a9..3b1eba18b65 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -1055,13 +1055,13 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.168.0-alpha.1573.1.2917e83"); + assert.equal(version, "0.168.0-alpha.1585.1.1de7974"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); // Central package rows have the form: - // + // // Match the exact Include value, not Hex1b.Tool or Hex1b.McpServer; // whitespace, attribute order and either XML quote style are allowed. const packages = await readFile(new URL("../../Directory.Packages.props", dashboard), "utf8"); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 8de8983bda1..5b37c1a7a83 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -1281,7 +1281,7 @@ public async Task WatchTerminals_StalledWriteRecoversInventoryAndPendingActivati { Title = "Before", Placement = TerminalPlacement.Dock, - Command = new TerminalCommand("bash") + Executable = "bash" }).Backend); terminal.Show(); var service = CreateDashboardService(serviceData, terminalService: terminalService); @@ -1337,7 +1337,7 @@ public async Task WatchTerminals_StalledWriteRecoversInventoryAndPendingActivati { Title = "After recovery", Placement = TerminalPlacement.Dock, - Command = new TerminalCommand("bash") + Executable = "bash" }); var change = await responses.ReadNextAsync().DefaultTimeout(); Assert.Equal(Aspire.DashboardService.Proto.V1.TerminalChangeType.Added, change.Change.ChangeType); diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index 21067fa1f5a..f3e131b878b 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -1334,7 +1334,7 @@ public async Task ExecuteCommandAsync_TerminalArguments_ReuseSessionAcrossIntera await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions { Title = "Shell", - Command = new TerminalCommand("bash"), + Executable = "bash", Placement = TerminalPlacement.Dialog }); var terminalDefinition = new InteractionInput diff --git a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs index 1da8a5910b0..d532e909efd 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs @@ -90,7 +90,7 @@ public async Task HandlePreservesIdentityAndLiveMetadata() { Title = "Before", Placement = TerminalPlacement.Dialog, - Command = new TerminalCommand("bash") + Executable = "bash" }); var backend = Assert.IsType(terminal.Backend); backend.Retitle("After"); diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs index 099f60598a9..97d06a80477 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -10,14 +10,72 @@ using Aspire.Hosting.Utils; using Hex1b; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIREFILESYSTEM001 // Use the hosting temporary directory abstraction. namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class Hex1bAspireTerminalTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CreateTerminal_UsesProcessOptions(bool overrideEnvironment) + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses a POSIX shell."); + + var home = Environment.GetEnvironmentVariable("HOME"); + var path = Environment.GetEnvironmentVariable("PATH"); + Assert.NotNull(home); + Assert.NotNull(path); + using var configuration = new ConfigurationManager(); + var fileSystem = new FileSystemService(configuration); + using var directory = fileSystem.TempDirectory.CreateTempSubdirectory("terminal-options-"); + await File.WriteAllTextAsync(Path.Combine(directory.Path, "working-directory-marker"), string.Empty); + + // Positional arguments preserve spaces without shell interpolation. Keep the process reading after + // "ready" so its screen remains available until the test disposes the terminal. + const string script = """ + set -eu + printf '%s\n' "$1" + test "$HOME" = "$2" + printf 'inherited-home\n' + test "$PATH" = "$3" + test "${ASPIRE_TERMINAL_TEST_SETTING-}" = "$4" + printf 'environment-ok\n' + test -f working-directory-marker + printf 'working-directory-ok\n' + printf 'ready\n' + read -r input + """; + var options = new TerminalLaunchOptions + { + Title = "Launch options", + Placement = TerminalPlacement.None, + Executable = "/bin/sh", + Arguments = ["-c", script, "terminal-options", "argument with spaces", home, + overrideEnvironment ? "/usr/bin:/bin" : path, overrideEnvironment ? "value with spaces" : string.Empty], + WorkingDirectory = directory.Path + }; + if (overrideEnvironment) + { + options.EnvironmentVariables["PATH"] = "/usr/bin:/bin"; + options.EnvironmentVariables["ASPIRE_TERMINAL_TEST_SETTING"] = "value with spaces"; + } + + await using var service = TestTerminalService.Create(); + await using var terminal = service.CreateTerminal(options); + terminal.Start(); + await terminal.WaitForTextAsync("ready").DefaultTimeout(); + + Assert.Equal( + ["argument with spaces", "inherited-home", "environment-ok", "working-directory-ok", "ready"], + terminal.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -30,11 +88,9 @@ public async Task DisposeAsync_TerminatesPtyProcessIgnoringHangupAndTermination( { Title = "Signal-resistant process", Placement = TerminalPlacement.None, - Command = new TerminalCommand("/bin/sh") - { - // Ignored signals survive exec. The fixed sleep is a backstop if the test host is killed. - Arguments = ["-c", "trap '' HUP TERM; printf 'pid:%s\\nprocess-ready\\n' \"$$\"; exec sleep 300"] - } + Executable = "/bin/sh", + // Ignored signals survive exec. The fixed sleep is a backstop if the test host is killed. + Arguments = ["-c", "trap '' HUP TERM; printf 'pid:%s\\nprocess-ready\\n' \"$$\"; exec sleep 300"] }); terminal.Start(); await terminal.WaitForTextAsync("process-ready").DefaultTimeout(); diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index c5cfe6022fa..84a8f51151e 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -360,7 +360,7 @@ private static AspireTerminal CreateTerminal(TerminalService service, TerminalPl => service.CreateTerminal(new TerminalLaunchOptions { Title = "Terminal", - Command = new TerminalCommand("bash"), + Executable = "bash", Placement = placement }); diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs index 41125e1f479..360e83198e6 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs @@ -43,7 +43,7 @@ public void AppHostTerminalIdIsNotMistakenForAResourceTerminal() var terminal = service.CreateTerminal(new TerminalLaunchOptions { Title = "Shell", - Command = new TerminalCommand("bash"), + Executable = "bash", }); Assert.False(ResourceTerminalCatalog.IsResourceTerminalId(terminal.Id)); diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalCommandTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalCommandTests.cs deleted file mode 100644 index 82a192b51ae..00000000000 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalCommandTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.Terminals; - -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. - -namespace Aspire.Hosting.Tests.Terminals; - -/// -/// Guards validation on . Everything here fails at the assignment that caused it -/// rather than later, inside terminal creation: the command is not translated into a process until -/// runs, which is far enough away from the -/// property set that an unvalidated value would surface as an unattributed failure from the terminal library. -/// -[Trait("Partition", "2")] -public class TerminalCommandTests -{ - [Fact] - public void Constructor_NullExecutable_Throws() - { - Assert.Throws(() => new TerminalCommand(null!)); - } - - [Fact] - public void Constructor_EmptyExecutable_Throws() - { - Assert.Throws(() => new TerminalCommand(string.Empty)); - } - - [Fact] - public void Constructor_OnlyRequiresExecutable() - { - var command = new TerminalCommand("bash"); - - Assert.Equal("bash", command.Executable); - Assert.Empty(command.Arguments); - Assert.Empty(command.EnvironmentVariables); - Assert.Null(command.WorkingDirectory); - } - - [Fact] - public void Arguments_Null_Throws() - { - var command = new TerminalCommand("bash"); - - var ex = Assert.Throws(() => command.Arguments = null!); - Assert.Equal("value", ex.ParamName); - } - - [Fact] - public void Arguments_SupportsSpreadAssignment() - { - string[] shell = ["/bin/sh"]; - var command = new TerminalCommand("docker") - { - Arguments = ["exec", "-it", "my-container", .. shell] - }; - - Assert.Equal(["exec", "-it", "my-container", "/bin/sh"], command.Arguments); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void Columns_NotPositive_Throws(int value) - { - var command = new TerminalCommand("bash"); - - var ex = Assert.Throws(() => command.Columns = value); - Assert.Equal("value", ex.ParamName); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void Rows_NotPositive_Throws(int value) - { - var command = new TerminalCommand("bash"); - - var ex = Assert.Throws(() => command.Rows = value); - Assert.Equal("value", ex.ParamName); - } - - [Fact] - public void Dimensions_DefaultToAModernGrid() - { - var command = new TerminalCommand("bash"); - - Assert.Equal(120, command.Columns); - Assert.Equal(32, command.Rows); - } - - [Fact] - public void Dimensions_AcceptPositiveValues() - { - var command = new TerminalCommand("bash") { Columns = 80, Rows = 24 }; - - Assert.Equal(80, command.Columns); - Assert.Equal(24, command.Rows); - } -} diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs new file mode 100644 index 00000000000..2723c78f011 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs @@ -0,0 +1,146 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Terminals; + +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. + +namespace Aspire.Hosting.Tests.Terminals; + +/// +/// Guards launch option defaults and validation before process creation. +/// +[Trait("Partition", "2")] +public class TerminalLaunchOptionsTests +{ + [Fact] + public void Executable_Null_Throws() + { + Assert.Throws("value", () => new TerminalLaunchOptions { Title = "Shell", Executable = null! }); + } + + [Fact] + public void Executable_Empty_Throws() + { + Assert.Throws("value", () => new TerminalLaunchOptions { Title = "Shell", Executable = string.Empty }); + } + + [Fact] + public void Defaults_OnlyRequireTitleAndExecutable() + { + var options = new TerminalLaunchOptions { Title = "Shell", Executable = "bash" }; + + Assert.Equal("Shell", options.Title); + Assert.Equal("bash", options.Executable); + Assert.Empty(options.Arguments); + Assert.Empty(options.EnvironmentVariables); + Assert.Null(options.WorkingDirectory); + Assert.Equal(TerminalPlacement.Dock, options.Placement); + } + + [Fact] + public void Executable_InvalidAssignment_PreservesPreviousValue() + { + var options = new TerminalLaunchOptions { Title = "Shell", Executable = "bash" }; + + Assert.Throws("value", () => options.Executable = null!); + Assert.Throws("value", () => options.Executable = string.Empty); + + Assert.Equal("bash", options.Executable); + } + + [Fact] + public void Arguments_Null_Throws() + { + var options = new TerminalLaunchOptions { Title = "Shell", Executable = "bash" }; + + var ex = Assert.Throws(() => options.Arguments = null!); + Assert.Equal("value", ex.ParamName); + } + + [Fact] + public void Arguments_SupportsSpreadAssignment() + { + string[] shell = ["/bin/sh"]; + var options = new TerminalLaunchOptions + { + Title = "Container shell", + Executable = "docker", + Arguments = ["exec", "-it", "my-container", .. shell] + }; + + Assert.Equal(["exec", "-it", "my-container", "/bin/sh"], options.Arguments); + } + + [Fact] + public void EnvironmentVariables_SupportsCollectionInitializerAndOverrides() + { + var options = new TerminalLaunchOptions + { + Title = "Shell", + Executable = "bash", + EnvironmentVariables = + { + ["TERM"] = "xterm-256color", + ["MY_SETTING"] = "initial" + } + }; + options.EnvironmentVariables["MY_SETTING"] = "updated"; + + Assert.Equal("xterm-256color", options.EnvironmentVariables["TERM"]); + Assert.Equal("updated", options.EnvironmentVariables["MY_SETTING"]); + Assert.Equal(2, options.EnvironmentVariables.Count); + } + + [Fact] + public void Collections_AreNotSharedBetweenOptions() + { + var first = new TerminalLaunchOptions { Title = "First", Executable = "bash" }; + var second = new TerminalLaunchOptions { Title = "Second", Executable = "bash" }; + first.Arguments.Add("-i"); + first.EnvironmentVariables["MY_SETTING"] = "value"; + + Assert.Empty(second.Arguments); + Assert.Empty(second.EnvironmentVariables); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Columns_NotPositive_Throws(int value) + { + var options = new TerminalLaunchOptions { Title = "Shell", Executable = "bash" }; + + var ex = Assert.Throws(() => options.Columns = value); + Assert.Equal("value", ex.ParamName); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Rows_NotPositive_Throws(int value) + { + var options = new TerminalLaunchOptions { Title = "Shell", Executable = "bash" }; + + var ex = Assert.Throws(() => options.Rows = value); + Assert.Equal("value", ex.ParamName); + } + + [Fact] + public void Dimensions_DefaultToAModernGrid() + { + var options = new TerminalLaunchOptions { Title = "Shell", Executable = "bash" }; + + Assert.Equal(120, options.Columns); + Assert.Equal(32, options.Rows); + } + + [Fact] + public void Dimensions_AcceptPositiveValues() + { + var options = new TerminalLaunchOptions { Title = "Shell", Executable = "bash", Columns = 80, Rows = 24 }; + + Assert.Equal(80, options.Columns); + Assert.Equal(24, options.Rows); + } +} diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 32ae0a70a14..7071e0a1046 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -76,18 +76,6 @@ public void CreateTerminal_NullOptions_Throws() Assert.Throws(() => service.CreateTerminal(null!)); } - [Fact] - public void CreateTerminal_NullCommand_Throws() - { - var service = TestTerminalService.Create(); - - Assert.Throws(() => service.CreateTerminal(new TerminalLaunchOptions - { - Title = "Shell", - Command = null! - })); - } - [Theory] [InlineData(null, false)] [InlineData(null, true)] @@ -775,7 +763,7 @@ public void ListAll_IncludesTerminalsRegardlessOfPlacement() var hidden = service.CreateTerminal(new TerminalLaunchOptions { Title = "Automation", - Command = new TerminalCommand("bash"), + Executable = "bash", Placement = TerminalPlacement.None }); @@ -829,7 +817,7 @@ private static AspireTerminal CreateTerminal(TerminalService service, TerminalPl : service.CreateTerminal(new TerminalLaunchOptions { Title = title, - Command = new TerminalCommand("bash"), + Executable = "bash", Placement = placement }); @@ -837,7 +825,7 @@ private static AspireTerminal CreateInteractionTerminal(TerminalService service, => service.CreateTerminal(new TerminalLaunchOptions { Title = title, - Command = new TerminalCommand("bash"), + Executable = "bash", Placement = TerminalPlacement.Dialog }); @@ -845,7 +833,7 @@ private static Hex1bAspireTerminal CreateDockTerminal(TerminalService service, s => Assert.IsType(service.CreateTerminal(new TerminalLaunchOptions { Title = title, - Command = new TerminalCommand("bash"), + Executable = "bash", Placement = TerminalPlacement.Dock }).Backend); From 666536b9e4a9669aa2f7a82e1906b5d29ee4220c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 11:52:10 +1000 Subject: [PATCH 069/106] Add Command Prompt dock terminal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Terminals/Terminals.AppHost/AppHost.cs | 4 ++- .../TerminalInteractionCommands.cs | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index 0052df8a70e..a8a5b92b50f 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -53,7 +53,9 @@ if (OperatingSystem.IsWindows()) { // Local PowerShell launched directly by Hex1b, providing a Docker- and DCP-independent PTY debugging path. - shellbox.WithPowerShellDockCommand(); + shellbox + .WithPowerShellDockCommand() + .WithCommandPromptDockCommand(); // Single-replica executable wrapping cmd.exe to demonstrate that // WithTerminal() also works for arbitrary executables, not just projects. diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 46ebc531ff2..f976755b515 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -251,6 +251,37 @@ public static IResourceBuilder WithPowerShellDockCommand(this }); } + /// + /// Adds a command that opens a local Command Prompt session in the terminal dock. + /// + /// + /// The Command Prompt process is launched directly by the AppHost-owned Hex1b terminal, so this command provides + /// another Windows shell for testing terminal behavior without Docker or DCP's PTY implementation. + /// + [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")] + public static IResourceBuilder WithCommandPromptDockCommand(this IResourceBuilder container) + { + return container.WithCommand( + "terminal-dock-command-prompt", + "Open Command Prompt (terminal dock)", + executeCommand: commandContext => + { + var terminalService = commandContext.Services.GetRequiredService(); + + // The terminal remains open until the user closes its dock tab or the AppHost shuts down. + var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + { + Title = "Command Prompt", + Executable = "cmd.exe" + }); + + terminal.Start(); + terminal.Show(); + + return Task.FromResult(CommandResults.Success()); + }); + } + /// /// Adds a command that plays a terminal-based guessing game by driving the process from AppHost code. /// From 06dd32452af904b9d8d3263c87d1f75b32ff00f7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 16:13:27 +1000 Subject: [PATCH 070/106] Hide terminal dock in standalone dashboards Gate the dock and navigation toggle on resource-service availability, without hiding them during a temporary disconnect. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/MainLayout.razor | 4 +- .../Layout/MainLayoutTerminalTests.cs | 40 ++++++++++++++++++- .../Layout/MainLayoutTests.cs | 2 + 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index af1f1c764fc..ea7efa695a9 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -53,7 +53,7 @@ } - @if (!_isSwitchingRuns && !DashboardClient.IsReadOnly) + @if (!_isSwitchingRuns && DashboardClient.IsEnabled && !DashboardClient.IsReadOnly) { - @if (!_isSwitchingRuns && !DashboardClient.IsReadOnly) + @if (!_isSwitchingRuns && DashboardClient.IsEnabled && !DashboardClient.IsReadOnly) { @* Removing the dock cancels its selected-run subscription and shortcut. Returning to the live run starts a new subscription rather than reusing the stream chosen for a previous selection. *@ diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs index 59bd87470a1..9aa68f392d9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs @@ -19,6 +19,44 @@ namespace Aspire.Dashboard.Components.Tests.Layout; public partial class MainLayoutTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TerminalDock_RequiresResourceService(bool isEnabled) + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(isEnabled: isEnabled, terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalView(this); + TerminalSetupHelpers.SetupTerminalDock(this); + SetupMainLayoutServices(dashboardClient: client); + + var cut = RenderComponent(builder => builder.Add(p => p.ViewportInformation, + new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false))); + var label = Services.GetRequiredService>()[nameof(Resources.Layout.MainLayoutToggleTerminalDock)].Value; + var shortcuts = Services.GetRequiredService(); + + Assert.Equal(isEnabled ? 1 : 0, cut.FindComponents().Count); + Assert.Equal(isEnabled ? 1 : 0, cut.FindAll($"fluent-button[aria-label='{label}']").Count); + await cut.InvokeAsync(() => shortcuts.OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock)); + + if (isEnabled) + { + cut.WaitForAssertion(() => Assert.Equal(1, client.ActiveTerminalSubscriptionCount)); + Assert.Single(cut.FindAll(".terminal-dock")); + var dock = cut.FindComponent().Instance; + await cut.InvokeAsync(() => client.SetConnectionState(DashboardConnectionState.Disconnected)); + cut.Render(); + Assert.Same(dock, cut.FindComponent().Instance); + Assert.Single(cut.FindAll($"fluent-button[aria-label='{label}']")); + await cut.InvokeAsync(() => dock.DisposeAsync().AsTask()).DefaultTimeout(); + } + else + { + Assert.Empty(cut.FindAll(".terminal-dock")); + Assert.Equal(0, client.TerminalSubscriptionCount); + } + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -26,7 +64,7 @@ public async Task TerminalDock_RunSelection_OnlySubscribesWhileLive(bool startHi { var updates = Channel.CreateUnbounded(); var subscriptionDisposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var client = new TestDashboardClient(terminalChannelProvider: () => updates) + var client = new TestDashboardClient(isEnabled: true, terminalChannelProvider: () => updates) { OnTerminalSubscriptionDisposed = () => subscriptionDisposed.TrySetResult() }; diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 6a7f0ec931b..a0212fa616f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -1073,6 +1073,8 @@ private void SetupMainLayoutServices( JSInterop.SetupModule("window.registerGlobalKeydownListener", _ => true); JSInterop.SetupModule("window.registerOpenTextVisualizerOnClick", _ => true); + JSInterop.SetupVoid("registerResourceServiceConnectionProvider", _ => true).SetVoidResult(); + JSInterop.SetupVoid("updateResourceServiceConnectionState", _ => true).SetVoidResult(); LayoutSetupHelpers.SetupMobileNavMenuKeyboardNavigation(this); JSInterop.Setup("window.getBrowserInfo").SetResult(new BrowserInfo { TimeZone = "abc", UserAgent = "mozilla" }); From eb78e057fd82ca30766137d3f60e1557bbd81906 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 16:14:48 +1000 Subject: [PATCH 071/106] Remove duplicate terminal title-bar dimensions Keep dimensions and stale-callback protection in the footer while removing the redundant header display. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor | 4 ---- .../Controls/TerminalViewTests.cs | 17 +++++++++-------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index 670cf0d7f6b..ffc73e20b37 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -17,10 +17,6 @@ {
@(ResourceName ?? Loc[nameof(Resources.ConsoleLogs.ConsoleLogsViewTerminalOption)].Value) - @if (_state.Cols > 0 && _state.Rows > 0) - { - @_state.Cols × @_state.Rows - }
}
diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 1a832269bfd..25d6b7fca6d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -143,7 +143,7 @@ public void AutoFit_ChangesDuringInitializationApplyWithoutReconnecting() } [Fact] - public async Task TerminalChrome_UsesCurrentDimensionsAndIgnoresStaleCallbacks() + public async Task TerminalFooter_UsesCurrentDimensionsAndIgnoresStaleCallbacks() { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var initialization = module.Setup("initTerminal", _ => true); @@ -154,25 +154,26 @@ public async Task TerminalChrome_UsesCurrentDimensionsAndIgnoresStaleCallbacks() await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState { - TerminalId = 1, Generation = 2, Cols = 120, Rows = 30, Connected = true + TerminalId = 1, Generation = 2, Cols = 120, Rows = 30, SizeKey = "120x30", Connected = true })); - Assert.Equal("120 \u00d7 30", cut.Find(".terminal-dimensions").TextContent); + Assert.Equal("120x30", cut.FindComponent>().Instance.Value); + Assert.Empty(cut.FindAll(".terminal-dimensions")); foreach (var state in new[] { - new TerminalToolbarState { TerminalId = 1, Generation = 1, Cols = 80, Rows = 24 }, - new TerminalToolbarState { TerminalId = 2, Generation = 3, Cols = 80, Rows = 24 } + new TerminalToolbarState { TerminalId = 1, Generation = 1, Cols = 80, Rows = 24, SizeKey = "80x24" }, + new TerminalToolbarState { TerminalId = 2, Generation = 3, Cols = 80, Rows = 24, SizeKey = "80x24" } }) { await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(state)); - Assert.Equal("120 \u00d7 30", cut.Find(".terminal-dimensions").TextContent); + Assert.Equal("120x30", cut.FindComponent>().Instance.Value); } await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState { - TerminalId = 1, Generation = 2, Cols = 132, Rows = 50, Connected = true + TerminalId = 1, Generation = 2, Cols = 132, Rows = 50, SizeKey = "132x50", Connected = true })); - Assert.Equal("132 \u00d7 50", cut.Find(".terminal-dimensions").TextContent); + Assert.Equal("132x50", cut.FindComponent>().Instance.Value); Assert.Single(initialization.Invocations); } From fd9250553dcbd598cc05cc7281f1bfb479132b27 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 16:39:04 +1000 Subject: [PATCH 072/106] Launch terminal windows directly from browser clicks Move resource window launch to the terminal header and share a preconfigured native-click control with dock detachment. Preserve activation, keyed callbacks, popup tracking, and ownership across asynchronous server notifications. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 14 + .../Components/Controls/TerminalView.razor | 8 + .../Components/Controls/TerminalView.razor.cs | 5 + .../Controls/TerminalView.razor.css | 8 + .../Controls/TerminalWindowButton.razor | 13 + .../Controls/TerminalWindowButton.razor.cs | 149 ++++++++++ .../Components/Layout/TerminalDock.razor | 19 +- .../Components/Layout/TerminalDock.razor.cs | 70 ++--- .../Components/Pages/ConsoleLogs.razor | 1 + .../Components/Pages/ConsoleLogs.razor.cs | 53 ---- .../Model/TerminalWindowLauncher.cs | 134 ++++----- .../Resources/ConsoleLogs.Designer.cs | 6 + .../Resources/ConsoleLogs.resx | 3 + .../Resources/xlf/ConsoleLogs.cs.xlf | 5 + .../Resources/xlf/ConsoleLogs.de.xlf | 5 + .../Resources/xlf/ConsoleLogs.es.xlf | 5 + .../Resources/xlf/ConsoleLogs.fr.xlf | 5 + .../Resources/xlf/ConsoleLogs.it.xlf | 5 + .../Resources/xlf/ConsoleLogs.ja.xlf | 5 + .../Resources/xlf/ConsoleLogs.ko.xlf | 5 + .../Resources/xlf/ConsoleLogs.pl.xlf | 5 + .../Resources/xlf/ConsoleLogs.pt-BR.xlf | 5 + .../Resources/xlf/ConsoleLogs.ru.xlf | 5 + .../Resources/xlf/ConsoleLogs.tr.xlf | 5 + .../Resources/xlf/ConsoleLogs.zh-Hans.xlf | 5 + .../Resources/xlf/ConsoleLogs.zh-Hant.xlf | 5 + .../wwwroot/js/app-terminalwindow.js | 106 +++++-- .../Controls/TerminalViewTests.cs | 22 ++ .../Controls/TerminalWindowButtonTests.cs | 150 ++++++++++ .../JavaScript/TerminalWindow.test.mjs | 272 +++++++++++++++++- .../Layout/TerminalDockTests.cs | 91 +++++- .../Pages/ConsoleLogsTerminalTests.cs | 40 ++- .../Shared/TerminalSetupHelpers.cs | 20 +- 33 files changed, 1039 insertions(+), 210 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor create mode 100644 src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 4f64566402a..a3b48df6aac 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -272,6 +272,20 @@ rendered inside the toolbar's options (⋯) `AspireMenuButton`: visible` transition the page calls `refreshLayout` on the JS terminal to fit the terminal to the new available space. +The resource Terminal view offers an icon-only **Open in new window** button +at the right of its title bar, not in the page's Options menu or Console view. +The dock keeps its detach button in the tab strip; dialogs and detached windows +do not offer another launch button. Launch buttons stay disabled until their +native click listener, terminal key, complete URL, and font preference are ready. +The browser opens or focuses the named window before notifying Blazor, so a +pending server response cannot delay popup creation. Browser popup policy can +still block the launch, in which case the dashboard shows feedback. + +Resource terminals keep their inline viewer while the extra window is open. +Dock terminals show a detached placeholder until the window closes or the user +chooses **Return to dock**. Disposing the opener leaves independent windows and +their AppHost-owned producers running. + The terminal frame keeps font decrease/increase buttons, the current font size, and the live columns-by-rows selector together in its bottom-right footer. A separate Fit button switches to container-sized rows and columns diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index ffc73e20b37..aba2318f612 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -17,6 +17,14 @@ {
@(ResourceName ?? Loc[nameof(Resources.ConsoleLogs.ConsoleLogsViewTerminalOption)].Value) + @if (ShowOpenInWindow && !string.IsNullOrEmpty(ResourceName)) + { + + }
}
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 49e19ed04ef..2ffce9b061a 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -80,6 +80,11 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable [Parameter] public bool Chromeless { get; set; } + /// Gets or sets whether the resource terminal titlebar offers an independent window. + /// Only the active resource Terminal view enables this. Chromeless surfaces never render this action. + [Parameter] + public bool ShowOpenInWindow { get; set; } + /// Gets or sets the per-surface key for page-lifetime font-size persistence. /// Detached windows seed their font from the opener without sharing live font preferences. [Parameter] diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css index b803917f8e8..ec64d375a49 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css @@ -89,6 +89,14 @@ text-overflow: ellipsis; } +.terminal-titlebar ::deep .terminal-open-window { + color: inherit; +} + +.terminal-titlebar ::deep .terminal-open-window svg { + fill: currentColor; +} + .terminal-dimensions { flex: 0 0 auto; margin-inline-start: 12px; diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor b/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor new file mode 100644 index 00000000000..4311ef6043f --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor @@ -0,0 +1,13 @@ +@namespace Aspire.Dashboard.Components.Controls + + + + diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor.cs new file mode 100644 index 00000000000..7f9cccb3191 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Dashboard.Model; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Localization; +using Microsoft.JSInterop; + +namespace Aspire.Dashboard.Components.Controls; + +/// A terminal launch button whose native listener opens the window before notifying Blazor. +public partial class TerminalWindowButton : ComponentBase, IAsyncDisposable +{ + private readonly string _buttonId = $"terminal-window-button-{Guid.NewGuid():N}"; + private TerminalWindowLauncher? _launcher; + private bool _ready; + private bool _disposed; + + /// Gets or sets the terminal's stable window key. + [Parameter] + public string? TerminalKey { get; set; } + + /// Gets or sets the dashboard-relative URL of the independent terminal viewer. + [Parameter] + public string? Url { get; set; } + + /// Gets or sets the originating font preference. Null keeps the button disabled until it is known. + [Parameter] + public int? FontSize { get; set; } + + /// Gets or sets the localized accessible label and tooltip. + [Parameter, EditorRequired] + public required string Label { get; set; } + + /// Gets or sets additional button classes. + [Parameter] + public string? Class { get; set; } + + /// Gets or sets whether this surface currently permits launching a window. + [Parameter] + public bool Disabled { get; set; } + + /// Raised with the clicked key and outcome, even if the selected terminal has since changed. + [Parameter] + public EventCallback<(string Key, TerminalWindowOpenResult Result)> OnWindowOpened { get; set; } + + /// Raised with the key of a tracked window that the user closed. + [Parameter] + public EventCallback OnWindowClosed { get; set; } + + [Inject] + public required IJSRuntime JS { get; init; } + + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + [Inject] + public required Microsoft.FluentUI.AspNetCore.Components.INotificationService ToastService { get; init; } + + // Transfer only the font preference. The independent viewer fits its own viewport instead of inheriting + // the opener's grid dimensions. Render this complete URL so the click needs no interop or terminal lookup. + private string? LaunchUrl => !string.IsNullOrEmpty(Url) && FontSize is > 0 + ? QueryHelpers.AddQueryString(NavigationManager.ToAbsoluteUri(Url).AbsoluteUri, "fontSize", + FontSize.Value.ToString(CultureInfo.InvariantCulture)) + : null; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _disposed) + { + return; + } + + _launcher = new TerminalWindowLauncher(JS, NavigationManager, OnOpenedAsync, + key => InvokeAsync(() => _disposed ? Task.CompletedTask : OnWindowClosed.InvokeAsync(key))); + try + { + await _launcher.RegisterAsync(_buttonId); + if (!_disposed) + { + _ready = true; + StateHasChanged(); + } + } + catch (JSDisconnectedException) + { + // The circuit has gone away, so there is no live UI to notify. + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to register the terminal window button."); + if (!_disposed) + { + await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.ConsoleLogs.TerminalToolbarOpenInWindowFailed)]); + } + } + } + + private Task OnOpenedAsync(string key, TerminalWindowOpenResult result) => InvokeAsync(async () => + { + if (_disposed) + { + return; + } + + if (result == TerminalWindowOpenResult.Failed) + { + Logger.LogWarning("The browser failed to open or focus a terminal window."); + await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.ConsoleLogs.TerminalToolbarOpenInWindowFailed)]); + } + else if (result == TerminalWindowOpenResult.Blocked && !OnWindowOpened.HasDelegate) + { + await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.ConsoleLogs.TerminalToolbarOpenInWindowBlocked)]); + } + + if (!_disposed) + { + await OnWindowOpened.InvokeAsync((key, result)); + } + }); + + /// Focuses an already detached window, or returns false if it has closed. + /// The terminal window key. + /// Whether the window is still open. + public Task FocusAsync(string key) => _launcher?.FocusAsync(key) ?? Task.FromResult(false); + + /// Closes an independent viewer without stopping its terminal producer. + /// The terminal window key. + /// A task that completes when the browser has closed the window. + public Task CloseAsync(string key) => _launcher?.CloseAsync(key) ?? Task.CompletedTask; + + /// + public async ValueTask DisposeAsync() + { + _disposed = true; + if (_launcher is { } launcher) + { + await launcher.DisposeAsync(); + } + } +} diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index dd6173af389..00bf58c7738 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -63,15 +63,15 @@ { @Loc[nameof(Resources.Layout.TerminalDockDetachBlocked)] } - - - + }
diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index f10b660b8db..1c6389e09eb 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -56,7 +56,7 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener ///
private readonly HashSet _detachedTerminalIds = []; - private TerminalWindowLauncher? _windowLauncher; + private TerminalWindowButton? _windowButton; private bool _popupBlocked; [Inject] @@ -203,44 +203,40 @@ private void Activate(string terminalId) private string GetPaneId(string terminalId) => $"{_elementIdPrefix}-pane-{terminalId}"; - private TerminalWindowLauncher WindowLauncher - => _windowLauncher ??= new TerminalWindowLauncher(JS, NavigationManager, OnDetachedWindowClosedAsync); + private string? ActiveTerminalWindowUrl => _activeTerminalId is { } id + ? $"terminal-window/apphost/{Uri.EscapeDataString(id)}" + : null; - /// - /// Pops the active terminal out into its own window. - /// - private async Task DetachActiveAsync() + private int? ActiveTerminalFontSize => _activeTerminalId is { } id && _terminalViews.TryGetValue(id, out var view) + ? view.FontSize + : null; + + private void OnTerminalToolbarStateChanged(TerminalToolbarState state) => StateHasChanged(); + + private async Task OnDetachedWindowOpenedAsync((string Key, TerminalWindowOpenResult Result) launch) { - if (_activeTerminalId is not { } terminalId) + if (_disposed) { return; } - _popupBlocked = false; - - try + var (terminalId, result) = launch; + if (!_terminals.Any(t => t.TerminalId == terminalId)) { - var url = NavigationManager.ToAbsoluteUri($"terminal-window/apphost/{Uri.EscapeDataString(terminalId)}").AbsoluteUri; - var fontSize = _terminalViews.TryGetValue(terminalId, out var view) ? view.FontSize : null; - var result = await WindowLauncher.OpenAsync(terminalId, url, fontSize).ConfigureAwait(true); - - if (result is TerminalWindowOpenResult.Blocked) - { - // Surfaced in the tab strip rather than swallowed: to the user, detaching just did nothing. - _popupBlocked = true; - } - else - { - _detachedTerminalIds.Add(terminalId); - _terminalViews.Remove(terminalId); - } - - StateHasChanged(); + // The watch stream can remove a terminal while its native launch notification is in flight. + // Reconcile the captured key; never detach the newly active tab in its place. + await CloseDetachedWindowAsync(terminalId); + return; } - catch (Exception ex) when (ex is not OperationCanceledException) + + _popupBlocked = result == TerminalWindowOpenResult.Blocked; + if (result is TerminalWindowOpenResult.Opened or TerminalWindowOpenResult.Focused) { - Logger.LogWarning(ex, "Failed to detach terminal {TerminalId} into a window.", terminalId); + _detachedTerminalIds.Add(terminalId); + _terminalViews.Remove(terminalId); } + + StateHasChanged(); } private async Task FocusDetachedWindowAsync(string terminalId) @@ -249,7 +245,7 @@ private async Task FocusDetachedWindowAsync(string terminalId) { // A window the browser closed without us noticing yet would otherwise leave the pane stuck on the // placeholder, so a failed focus reattaches instead. - if (!await WindowLauncher.FocusAsync(terminalId).ConfigureAwait(true)) + if (_windowButton is null || !await _windowButton.FocusAsync(terminalId).ConfigureAwait(true)) { await OnDetachedWindowClosedAsync(terminalId).ConfigureAwait(true); } @@ -264,7 +260,10 @@ private async Task ReturnToDockAsync(string terminalId) { try { - await WindowLauncher.CloseAsync(terminalId).ConfigureAwait(true); + if (_windowButton is { } button) + { + await button.CloseAsync(terminalId).ConfigureAwait(true); + } } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -453,7 +452,10 @@ private async Task CloseDetachedWindowAsync(string terminalId) { try { - await WindowLauncher.CloseAsync(terminalId).ConfigureAwait(true); + if (_windowButton is { } button) + { + await button.CloseAsync(terminalId).ConfigureAwait(true); + } } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -505,11 +507,11 @@ public async ValueTask DisposeAsync() _selfRef?.Dispose(); - if (_windowLauncher is { } launcher) + if (_windowButton is { } button) { // Leaves any detached windows open: they are viewers of AppHost-owned terminals and have no reason to // die because this circuit went away. - await launcher.DisposeAsync().ConfigureAwait(true); + await button.DisposeAsync().ConfigureAwait(true); } _cts.Dispose(); diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index 73954dfc61c..f5950487ca3 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -168,6 +168,7 @@
diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index dd39aee2db0..0cb0345b3b2 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -162,7 +162,6 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in private bool _selectedResourceHasTerminal; private string? _terminalResourceName; private int _terminalReplicaIndex; - private TerminalWindowLauncher? _terminalWindowLauncher; // View toggle for terminal resources. The page surfaces both LogViewer // and TerminalView in MainSection (both stay mounted so flipping does @@ -646,14 +645,6 @@ private void UpdateMenuButtons() IsDivider = true }); } - - _logsMenuItems.Add(new() - { - OnClick = OpenTerminalWindowAsync, - Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarOpenInWindow)], - Icon = new Icons.Regular.Size16.WindowNew(), - IsDisabled = _terminalResourceName is null, - }); } if (_activeView == ConsoleLogsView.Console) @@ -1252,11 +1243,6 @@ public async ValueTask DisposeAsync() await CancelAllSubscriptionsAsync(); - if (_terminalWindowLauncher is not null) - { - await _terminalWindowLauncher.DisposeAsync(); - } - TelemetryContext.Dispose(); } @@ -1366,45 +1352,6 @@ private Task HandleViewChangedAsync(string? newView) internal ResourceViewModel? GetResourceSnapshotForTest(string resourceName) => _resourceByName.TryGetValue(resourceName, out var resource) ? resource : null; - // Resource terminals never reattach, so the close callback has nothing to do: the inline view was live the - // whole time the window was open. - private TerminalWindowLauncher TerminalWindowLauncher - => _terminalWindowLauncher ??= new TerminalWindowLauncher(JS, NavigationManager, _ => Task.CompletedTask); - - /// - /// Opens the selected resource's terminal in its own resizable window. - /// - /// - /// Unlike the terminal dock, the inline view keeps rendering. Resource terminals are multi-headed, so the window - /// is an additional viewer rather than a relocation, and seeing the session on the resource page while working in - /// a larger window is the point of detaching it. - /// - private async Task OpenTerminalWindowAsync() - { - if (_terminalResourceName is not { Length: > 0 } resourceName) - { - return; - } - - try - { - var path = $"terminal-window/resource/{Uri.EscapeDataString(resourceName)}/{_terminalReplicaIndex}"; - var result = await TerminalWindowLauncher.OpenAsync( - key: $"resource:{resourceName}:{_terminalReplicaIndex}", - url: NavigationManager.ToAbsoluteUri(path).AbsoluteUri, - fontSize: _terminalViewRef?.FontSize).ConfigureAwait(true); - - if (result is TerminalWindowOpenResult.Blocked) - { - await ToastService.ShowErrorToastAsync(Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarOpenInWindowBlocked)]); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Logger.LogWarning(ex, "Failed to open a terminal window for resource {ResourceName}.", resourceName); - } - } - // IComponentWithTelemetry impl public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.ConsoleLogs); diff --git a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs index 96ab2fd786e..8b3f3775391 100644 --- a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs +++ b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs @@ -1,9 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Globalization; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.JSInterop; namespace Aspire.Dashboard.Model; @@ -27,7 +25,12 @@ public enum TerminalWindowOpenResult /// The browser blocked the popup. The caller is expected to tell the user, because from their point of view /// nothing happened. ///
- Blocked + Blocked, + + /// + /// The browser could not open or focus the window. + /// + Failed } /// @@ -47,79 +50,76 @@ public enum TerminalWindowOpenResult /// public sealed class TerminalWindowLauncher : IAsyncDisposable { - private const int DefaultWindowWidthPx = 960; - private const int DefaultWindowHeightPx = 600; - private readonly IJSRuntime _js; private readonly NavigationManager _navigationManager; + private readonly Func _onWindowOpened; private readonly Func _onWindowClosed; - private readonly HashSet _tracked = []; + private readonly string _id = Guid.NewGuid().ToString("N"); private DotNetObjectReference? _selfRef; private IJSObjectReference? _module; + private Task? _registrationTask; + private bool _disposed; /// /// Initializes a new instance of the class. /// /// The JS runtime for the owning component's circuit. /// The navigation manager providing the dashboard's base URI. + /// Invoked after a native click opens or focuses a window, with its captured key and outcome. /// /// Invoked with the terminal key when the user closes a detached window. Not raised for windows closed through /// , because the caller already knows about those. /// - public TerminalWindowLauncher(IJSRuntime js, NavigationManager navigationManager, Func onWindowClosed) + public TerminalWindowLauncher( + IJSRuntime js, + NavigationManager navigationManager, + Func onWindowOpened, + Func onWindowClosed) { ArgumentNullException.ThrowIfNull(js); ArgumentNullException.ThrowIfNull(navigationManager); + ArgumentNullException.ThrowIfNull(onWindowOpened); ArgumentNullException.ThrowIfNull(onWindowClosed); _js = js; _navigationManager = navigationManager; + _onWindowOpened = onWindowOpened; _onWindowClosed = onWindowClosed; } /// - /// Opens in a window dedicated to the terminal identified by , or - /// focuses the existing window if one is already open for it. + /// Registers a native click listener before the owning component enables its button. /// - /// - /// An opaque, page-stable identifier for the terminal — a dock terminal id, or a resource name and replica index. - /// - /// The dashboard URL that renders the detached terminal. - /// The originating view's font size, or null if the view has not reported one yet. - /// Requested window width, in pixels. - /// Requested window height, in pixels. - public async Task OpenAsync( - string key, - string url, - int? fontSize, - int widthPx = DefaultWindowWidthPx, - int heightPx = DefaultWindowHeightPx) - { - var module = await GetModuleAsync().ConfigureAwait(false); - - // Carry only the font preference across browser contexts, not the source grid dimensions: - // the new primary must calculate its own rows and columns from the popup's viewport. - if (fontSize is { } size) - { - url = QueryHelpers.AddQueryString(url, "fontSize", size.ToString(CultureInfo.InvariantCulture)); - } + /// The ID of the Fluent button carrying the current terminal key and complete URL. + /// A task that completes when the listener is registered. + public Task RegisterAsync(string buttonId) + => _registrationTask ??= RegisterCoreAsync(buttonId); - var result = await module.InvokeAsync( - "openTerminalWindow", key, url, widthPx, heightPx, _selfRef).ConfigureAwait(false); - - if (result is not "blocked") + private async Task RegisterCoreAsync(string buttonId) + { + _selfRef = DotNetObjectReference.Create(this); + var moduleUri = new Uri(new Uri(_navigationManager.BaseUri), "js/app-terminalwindow.js"); + _module = await _js.InvokeAsync("import", moduleUri.PathAndQuery).ConfigureAwait(false); + if (!_disposed) { - _tracked.Add(key); + await _module.InvokeVoidAsync("registerTerminalWindowButton", buttonId, _id, _selfRef).ConfigureAwait(false); } + } - return result switch + /// Receives the captured terminal key and browser result after the synchronous native launch. + /// The terminal key at the time of the click, not the current selection. + /// The browser's launch outcome. + /// A task that completes when the owning component has reconciled the outcome. + [JSInvokable] + public Task OnTerminalWindowOpenedAsync(string key, string result) + => _disposed ? Task.CompletedTask : _onWindowOpened(key, result switch { "opened" => TerminalWindowOpenResult.Opened, "focused" => TerminalWindowOpenResult.Focused, - _ => TerminalWindowOpenResult.Blocked - }; - } + "blocked" => TerminalWindowOpenResult.Blocked, + _ => TerminalWindowOpenResult.Failed + }); /// /// Brings the window for to the front. Returns if no window is open @@ -127,8 +127,8 @@ public async Task OpenAsync( /// public async Task FocusAsync(string key) { - var module = await GetModuleAsync().ConfigureAwait(false); - return await module.InvokeAsync("focusTerminalWindow", key).ConfigureAwait(false); + return !_disposed && _module is { } module && + await module.InvokeAsync("focusTerminalWindow", key).ConfigureAwait(false); } /// @@ -136,10 +136,10 @@ public async Task FocusAsync(string key) /// public async Task CloseAsync(string key) { - _tracked.Remove(key); - - var module = await GetModuleAsync().ConfigureAwait(false); - await module.InvokeVoidAsync("closeTerminalWindow", key).ConfigureAwait(false); + if (!_disposed && _module is { } module) + { + await module.InvokeVoidAsync("closeTerminalWindow", key).ConfigureAwait(false); + } } /// @@ -147,35 +147,36 @@ public async Task CloseAsync(string key) /// [JSInvokable] public Task OnTerminalWindowClosedAsync(string key) - { - _tracked.Remove(key); - return _onWindowClosed(key); - } - - private async Task GetModuleAsync() - { - // Imported lazily: most sessions never detach a terminal, and the import is only legal once the circuit can - // reach the browser, which rules out doing it in a constructor. - _selfRef ??= DotNetObjectReference.Create(this); - var moduleUri = new Uri(new Uri(_navigationManager.BaseUri), "js/app-terminalwindow.js"); - return _module ??= await _js.InvokeAsync( - "import", moduleUri.PathAndQuery).ConfigureAwait(false); - } + => _disposed ? Task.CompletedTask : _onWindowClosed(key); /// public async ValueTask DisposeAsync() { + if (_disposed) + { + return; + } + _disposed = true; + + if (_registrationTask is { } registration) + { + try + { + await registration.ConfigureAwait(false); + } + catch (Exception) + { + // The component reports registration failures. Still release a partially initialized module. + } + } + if (_module is { } module) { try { // Stop watching, but leave the windows open. They are independent viewers of an AppHost-owned // terminal, so closing them because the opener navigated away would throw away live work. - foreach (var key in _tracked) - { - await module.InvokeVoidAsync("untrackTerminalWindow", key).ConfigureAwait(false); - } - + await module.InvokeVoidAsync("unregisterTerminalWindowButton", _id).ConfigureAwait(false); await module.DisposeAsync().ConfigureAwait(false); } catch (JSDisconnectedException) @@ -184,7 +185,6 @@ public async ValueTask DisposeAsync() } } - _tracked.Clear(); _selfRef?.Dispose(); } } diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index 9292ab4cc68..a04a32475d6 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -273,6 +273,12 @@ public static string TerminalToolbarOpenInWindowBlocked { } } + public static string TerminalToolbarOpenInWindowFailed { + get { + return ResourceManager.GetString("TerminalToolbarOpenInWindowFailed", resourceCulture); + } + } + public static string ConsoleLogsViewConsoleOption { get { return ResourceManager.GetString("ConsoleLogsViewConsoleOption", resourceCulture); diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index 4c55f20ba2f..2aacf4c17dc 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -232,6 +232,9 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf index 27d389d5307..67c1b89671b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf index 6407649781b..5664523841d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf index a4dc96a34f2..e857b218c41 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf index 625a30fc54c..ce7615a67c8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf index 8245acba835..1d76fa6641c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf index 5aa74183d78..1bc8e0e171d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf index 165c6d4f127..511c030b5d4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf index 8a2a9932a41..48a95ab3b14 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf index 9426fed7363..20844306ef3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf index d6878449fd4..a59c8bd45f3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf index 6f3d0248eca..d4de1bd65ba 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf index b50acad3f2b..9a68e4c27d6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf index fc52bf14301..8ac8694835c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf @@ -202,6 +202,11 @@ The browser blocked the terminal window. Allow pop-ups for this site and try again. + + Unable to open the terminal window. Reload the dashboard and try again. + Unable to open the terminal window. Reload the dashboard and try again. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js index 5675632e61c..0f684d07899 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js @@ -12,6 +12,7 @@ // be stable and unique within the page. const openWindows = new Map(); +const launchers = new Map(); let pollHandle = null; // The opener finds out about a closed popup by polling `closed` rather than by listening for a `pagehide` message @@ -22,14 +23,88 @@ const POLL_INTERVAL_MS = 400; const DEFAULT_FEATURES = 'popup=yes,resizable=yes,scrollbars=no,menubar=no,toolbar=no,location=no,status=no'; +export function registerTerminalWindowButton(buttonId, id, owner) { + unregisterTerminalWindowButton(id); + const button = document.getElementById(buttonId); + if (!button) { + throw new Error('The terminal window button is no longer available.'); + } + const launcher = { button, owner, pending: Promise.resolve(), disposed: false }; + launcher.click = () => { + if (launcher.disposed || !button.isConnected || button.disabled || button.hasAttribute('disabled') || + button.getAttribute('aria-disabled') === 'true' || button.closest('[inert], [hidden]')) { + return; + } + + // Read the rendered metadata, not registration-time parameters: Blazor may have changed the active tab + // or font since wiring this listener. Missing metadata is an unconfigured button, not a blank popup. + const key = button.getAttribute('data-terminal-window-key'); + const url = button.getAttribute('data-terminal-window-url'); + if (!key || !url) { + return; + } + + let result; + try { + // Transient activation is window-scoped and can survive async work, but expires on a browser-defined + // timer. Opening here avoids dependence on server latency; popup policy can still block this call. + // https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation + result = openTerminalWindow(key, url, 960, 600, launcher); + } catch (error) { + console.error('Failed to open or focus the terminal window.', error); + result = 'failed'; + } + + const entry = openWindows.get(key); + notify(launcher, () => { + // A queued result must not resurrect a window that closed or was returned to the dock meanwhile. + if (result === 'opened' || result === 'focused') { + if (openWindows.get(key) !== entry || entry.win.closed) { + return; + } + } + return owner.invokeMethodAsync('OnTerminalWindowOpenedAsync', key, result); + }); + }; + launchers.set(id, launcher); + button.addEventListener('click', launcher.click); +} + +export function unregisterTerminalWindowButton(id) { + const launcher = launchers.get(id); + if (!launcher) { + return; + } + launcher.disposed = true; + launcher.button.removeEventListener('click', launcher.click); + launchers.delete(id); + for (const [key, entry] of openWindows) { + if (entry.owner === launcher) { + openWindows.delete(key); + } + } + stopPollingIfEmpty(); +} + +function notify(launcher, callback) { + // Serialize notifications, NOT browser operations. A slow open acknowledgement cannot delay a subsequent + // click's popup, and a close notification cannot overtake the corresponding detach acknowledgement. + launcher.pending = launcher.pending.then(() => { + if (!launcher.disposed) { + return callback(); + } + }).catch(error => console.warn('Could not update terminal window state in the dashboard.', error)); +} + /** * Opens a terminal in its own window, or focuses the window if one is already open for this key. * @returns {'opened'|'focused'|'blocked'} */ -export function openTerminalWindow(key, url, width, height, owner) { +function openTerminalWindow(key, url, width, height, owner) { const existing = openWindows.get(key); if (existing && !existing.win.closed) { existing.win.focus(); + existing.owner = owner; return 'focused'; } @@ -69,14 +144,7 @@ export function closeTerminalWindow(key) { if (entry && !entry.win.closed) { entry.win.close(); } -} - -/** - * Stops tracking a window without closing it. Used when the opening component goes away: the popup is an - * independent viewer of an AppHost-owned terminal and has no reason to die with the page that spawned it. - */ -export function untrackTerminalWindow(key) { - openWindows.delete(key); + stopPollingIfEmpty(); } export function isTerminalWindowOpen(key) { @@ -105,14 +173,20 @@ function ensurePolling() { openWindows.delete(key); - // A disposed component leaves a stale reference behind; a failed notification is not worth surfacing - // because the only consequence is that a page which is already going away misses a UI update. - entry.owner.invokeMethodAsync('OnTerminalWindowClosedAsync', key).catch(() => { }); + notify(entry.owner, () => { + if (!openWindows.has(key)) { + return entry.owner.owner.invokeMethodAsync('OnTerminalWindowClosedAsync', key); + } + }); } - if (openWindows.size === 0) { - clearInterval(pollHandle); - pollHandle = null; - } + stopPollingIfEmpty(); }, POLL_INTERVAL_MS); } + +function stopPollingIfEmpty() { + if (openWindows.size === 0 && pollHandle !== null) { + clearInterval(pollHandle); + pollHandle = null; + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 25d6b7fca6d..6db9dd98d78 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -74,6 +74,28 @@ public void ChromeAndFooter_RespectSurfaceParameters(bool chromeless, bool showD Assert.Equal(Resources.ConsoleLogs.TerminalToolbarIncreaseFontSize, cut.Find(".terminal-font-plus").GetAttribute("aria-label")); } + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + public void NewWindowAction_IsOnlyOfferedInAnEnabledResourceTitlebar(bool chromeless, bool showOpenInWindow, bool expected) + { + TerminalSetupHelpers.SetupTerminalView(this); + TerminalSetupHelpers.SetupTerminalWindows(this); + var cut = RenderComponent(builder => builder + .Add(p => p.ResourceName, "shell") + .Add(p => p.Chromeless, chromeless) + .Add(p => p.ShowOpenInWindow, showOpenInWindow)); + Assert.Equal(expected ? 1 : 0, cut.FindAll(".terminal-titlebar .terminal-open-window").Count); + if (expected) + { + Assert.True(cut.Find(".terminal-open-window").HasAttribute("disabled")); + } + cut.SetParametersAndRender(builder => builder.Add(p => p.ResourceName, null)); + Assert.Empty(cut.FindAll(".terminal-open-window")); + } + [Fact] public async Task InitialFontSize_SeedsMountWithoutResettingCurrentFont() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs new file mode 100644 index 00000000000..2d710fa4d7f --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +[UseCulture("en-US")] +public class TerminalWindowButtonTests : DashboardTestContext +{ + public TerminalWindowButtonTests() + { + FluentUISetupHelpers.AddCommonDashboardServices(this); + FluentUISetupHelpers.SetupFluentUIComponents(this); + FluentUISetupHelpers.SetupFluentButton(this); + } + + [Fact] + public void RegistrationAndMetadata_AreRequiredBeforeEnablingNativeButton() + { + Services.AddSingleton(new TestNavigationManager("http://localhost/aspire/nested/")); + var module = TerminalSetupHelpers.SetupTerminalWindows(this, "/aspire/nested"); + var registration = module.SetupVoid("registerTerminalWindowButton", _ => true); + var cut = RenderComponent(builder => builder + .Add(p => p.Label, "Open in new window") + .Add(p => p.TerminalKey, "first") + .Add(p => p.Url, "terminal-window/apphost/first") + .Add(p => p.FontSize, 19)); + + Assert.True(cut.FindComponent().Instance.Disabled); + Assert.False(cut.FindComponent().Instance.OnClick.HasDelegate); + cut.SetParametersAndRender(builder => builder + .Add(p => p.TerminalKey, "second #1/?%+") + .Add(p => p.Url, "terminal-window/apphost/second%20%231%2F%3F%25%2B") + .Add(p => p.FontSize, 23)); + Assert.True(cut.FindComponent().Instance.Disabled); + registration.SetVoidResult(); + cut.WaitForAssertion(() => Assert.False(cut.FindComponent().Instance.Disabled)); + Assert.Equal("second #1/?%+", cut.Find("fluent-button").GetAttribute("data-terminal-window-key")); + Assert.Equal("http://localhost/aspire/nested/terminal-window/apphost/second%20%231%2F%3F%25%2B?fontSize=23", + cut.Find("fluent-button").GetAttribute("data-terminal-window-url")); + Assert.Single(registration.Invocations); + + cut.SetParametersAndRender(builder => builder.Add(p => p.FontSize, null)); + Assert.True(cut.FindComponent().Instance.Disabled); + Assert.Null(cut.Find("fluent-button").GetAttribute("data-terminal-window-url")); + cut.SetParametersAndRender(builder => builder.Add(p => p.FontSize, 17).Add(p => p.Disabled, true)); + Assert.True(cut.FindComponent().Instance.Disabled); + cut.SetParametersAndRender(builder => builder.Add(p => p.Disabled, false).Add(p => p.TerminalKey, null)); + Assert.True(cut.FindComponent().Instance.Disabled); + cut.SetParametersAndRender(builder => builder.Add(p => p.TerminalKey, "second").Add(p => p.Url, null)); + Assert.True(cut.FindComponent().Instance.Disabled); + cut.SetParametersAndRender(builder => builder.Add(p => p.Url, "terminal-window/apphost/second")); + Assert.False(cut.FindComponent().Instance.Disabled); + } + + [Fact] + public async Task Notifications_UseCapturedKeyAndAreIgnoredAfterDisposal() + { + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + var opened = new List<(string Key, TerminalWindowOpenResult Result)>(); + var closed = new List(); + var cut = RenderComponent(builder => builder + .Add(p => p.Label, "Open") + .Add(p => p.TerminalKey, "first") + .Add(p => p.Url, "terminal-window/apphost/first") + .Add(p => p.FontSize, 19) + .Add(p => p.OnWindowOpened, launch => opened.Add(launch)) + .Add(p => p.OnWindowClosed, key => closed.Add(key))); + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + cut.SetParametersAndRender(builder => builder.Add(p => p.TerminalKey, "second")); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("first", "opened")); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("first", "focused")); + await cut.InvokeAsync(() => launcher.OnTerminalWindowClosedAsync("first")); + Assert.Equal([("first", TerminalWindowOpenResult.Opened), ("first", TerminalWindowOpenResult.Focused)], opened); + Assert.Equal(["first"], closed); + + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("second", "opened")); + await cut.InvokeAsync(() => launcher.OnTerminalWindowClosedAsync("second")); + Assert.Equal(2, opened.Count); + Assert.Equal(["first"], closed); + var registration = Assert.Single(module.Invocations, i => i.Identifier == "registerTerminalWindowButton"); + var unregister = Assert.Single(module.Invocations, i => i.Identifier == "unregisterTerminalWindowButton"); + Assert.Equal(registration.Arguments[1], unregister.Arguments[0]); + Assert.Equal(["registerTerminalWindowButton", "unregisterTerminalWindowButton"], + module.Invocations.Select(i => i.Identifier)); + } + + [Fact] + public async Task DisposalDuringRegistration_UnregistersOnceWithoutEnablingButton() + { + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + var registration = module.SetupVoid("registerTerminalWindowButton", _ => true); + var cut = RenderComponent(builder => builder + .Add(p => p.Label, "Open") + .Add(p => p.TerminalKey, "terminal") + .Add(p => p.Url, "terminal-window/apphost/terminal") + .Add(p => p.FontSize, 19)); + Assert.Single(registration.Invocations); + var disposing = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); + Assert.False(disposing.IsCompleted); + registration.SetVoidResult(); + await disposing; + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); + Assert.True(cut.FindComponent().Instance.Disabled); + Assert.Single(module.Invocations, i => i.Identifier == "unregisterTerminalWindowButton"); + } + + [Theory] + [InlineData("blocked", nameof(Resources.ConsoleLogs.TerminalToolbarOpenInWindowBlocked))] + [InlineData("failed", nameof(Resources.ConsoleLogs.TerminalToolbarOpenInWindowFailed))] + public async Task BrowserFailure_ShowsActionableToast(string result, string resourceKey) + { + TerminalSetupHelpers.SetupTerminalWindows(this); + var toasts = RenderComponent(); + var cut = RenderComponent(builder => builder.Add(p => p.Label, "Open")); + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("terminal", result)); + var toast = Assert.Single(toasts.FindComponents()).Instance; + Assert.Equal(ToastIntent.Error, toast.Intent); + Assert.Equal(resourceKey == nameof(Resources.ConsoleLogs.TerminalToolbarOpenInWindowBlocked) + ? Resources.ConsoleLogs.TerminalToolbarOpenInWindowBlocked + : Resources.ConsoleLogs.TerminalToolbarOpenInWindowFailed, toast.Title); + } + + [Fact] + public void RegistrationFailure_StaysDisabledAndShowsActionableToast() + { + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + module.SetupVoid("registerTerminalWindowButton", _ => true).SetException(new JSException("Module registration failed")); + var toasts = RenderComponent(); + var cut = RenderComponent(builder => builder + .Add(p => p.Label, "Open") + .Add(p => p.TerminalKey, "terminal") + .Add(p => p.Url, "terminal-window/apphost/terminal") + .Add(p => p.FontSize, 19)); + Assert.True(cut.FindComponent().Instance.Disabled); + toasts.WaitForAssertion(() => Assert.Equal(Resources.ConsoleLogs.TerminalToolbarOpenInWindowFailed, + Assert.Single(toasts.FindComponents()).Instance.Title)); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs index aeb01388c64..361ee4228ad 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs @@ -9,18 +9,32 @@ let keys; let calls; let poll; let windowDescriptor; -const owner = { invokeMethodAsync: async () => {} }; +let documentDescriptor; +let elements; +let buttons; +let notifications; +let registrations; +let nextId = 0; beforeEach(() => { keys = new Set(); calls = []; + buttons = new Map(); + notifications = []; + registrations = []; + elements = new Map(); poll = null; const contexts = new Map(); windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + documentDescriptor = Object.getOwnPropertyDescriptor(globalThis, "document"); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { getElementById: id => elements.get(id) ?? null }, + }); Object.defineProperty(globalThis, "window", { configurable: true, value: { - open(url, name) { + open(url, name, features) { // Browsers reuse and navigate an existing browsing context with the same target name. let popup = contexts.get(name); if (!popup || popup.closed) { @@ -33,7 +47,7 @@ beforeEach(() => { contexts.set(name, popup); } popup.url = url; - calls.push({ name, popup }); + calls.push({ name, popup, features }); return popup; }, }, @@ -50,19 +64,45 @@ afterEach(() => { terminalWindows.closeTerminalWindow(key); } poll?.(); + for (const id of registrations) { + terminalWindows.unregisterTerminalWindowButton(id); + } mock.restoreAll(); if (windowDescriptor) { Object.defineProperty(globalThis, "window", windowDescriptor); } else { delete globalThis.window; } + if (documentDescriptor) { + Object.defineProperty(globalThis, "document", documentDescriptor); + } else { + delete globalThis.document; + } }); function open(key, url) { keys.add(key); - return terminalWindows.openTerminalWindow(key, url, 800, 600, owner); + const existing = terminalWindows.isTerminalWindowOpen(key); + const button = buttons.get(key) ?? register(key, url).button; + button.setAttribute("data-terminal-window-url", url); + button.click(); + return terminalWindows.isTerminalWindowOpen(key) ? existing ? "focused" : "opened" : "blocked"; } +function register(key = "terminal", url = "https://localhost/dashboard/terminal-window/apphost/terminal?fontSize=17", callback) { + keys.add(key); + const button = new TestButton(key, url); + const id = `button-${++nextId}`; + const owner = { invokeMethodAsync: callback ?? (async (...args) => { notifications.push(args); }) }; + registrations.push(id); + elements.set(id, button); + buttons.set(key, button); + terminalWindows.registerTerminalWindowButton(id, id, owner); + return { id, button, owner }; +} + +const flushNotifications = () => new Promise(resolve => setImmediate(resolve)); + for (const [firstKey, secondKey] of [ ["resource:a.b:0", "resource:a_b:0"], ["resource:a:b:0", "resource:a_b:0"], @@ -105,7 +145,8 @@ test("the same key focuses its window and reuses its stable name after untrackin assert.equal(first.popup.focusCalls, 1); assert.equal(first.popup.url, firstUrl); - terminalWindows.untrackTerminalWindow(key); + terminalWindows.unregisterTerminalWindowButton(registrations[0]); + buttons.delete(key); assert.equal(first.popup.closed, false); assert.equal(terminalWindows.isTerminalWindowOpen(key), false); @@ -115,3 +156,224 @@ test("the same key focuses its window and reuses its stable name after untrackin assert.equal(calls[1].popup, first.popup); assert.equal(first.popup.url, nextUrl); }); + +test("native clicks open and focus synchronously, even while a previous .NET acknowledgement is pending", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { button } = register("first", "https://localhost/dashboard/terminal-window/apphost/first?fontSize=19", + (...args) => { + assert.ok(calls.length > 0, "window.open must precede the first .NET notification"); + notifications.push(args); + return promise; + }); + button.click(); + assert.equal(calls.length, 1); + assert.deepEqual(notifications, []); + assert.match(calls[0].features, /width=960,height=600$/); + await flushNotifications(); + assert.deepEqual(notifications, [["OnTerminalWindowOpenedAsync", "first", "opened"]]); + + button.click(); + assert.equal(calls.length, 1); + assert.equal(calls[0].popup.focusCalls, 1); + keys.add("second"); + button.setAttribute("data-terminal-window-key", "second"); + button.setAttribute("data-terminal-window-url", "https://localhost/dashboard/terminal-window/apphost/second?fontSize=23"); + button.click(); + assert.equal(calls.length, 2); + assert.equal(calls[1].popup.url, "https://localhost/dashboard/terminal-window/apphost/second?fontSize=23"); + assert.equal(notifications.length, 1); + + resolve(); + await flushNotifications(); + assert.deepEqual(notifications, [ + ["OnTerminalWindowOpenedAsync", "first", "opened"], + ["OnTerminalWindowOpenedAsync", "first", "focused"], + ["OnTerminalWindowOpenedAsync", "second", "opened"], + ]); +}); + +for (const gate of ["disabled-property", "disabled-attribute", "aria-disabled", "inert", "removed", "missing-key", "missing-url"]) { + test(`native listener rejects ${gate} controls without opening or notifying`, async () => { + const { button } = register(); + switch (gate) { + case "disabled-property": button.disabled = true; break; + case "disabled-attribute": button.setAttribute("disabled", ""); break; + case "aria-disabled": button.setAttribute("aria-disabled", "true"); break; + case "inert": button.inertAncestor = true; break; + case "removed": button.isConnected = false; break; + case "missing-key": button.removeAttribute("data-terminal-window-key"); break; + case "missing-url": button.removeAttribute("data-terminal-window-url"); break; + } + button.click(); + await flushNotifications(); + assert.deepEqual(calls, []); + assert.deepEqual(notifications, []); + assert.equal(poll, null); + }); +} + +test("blocked popups are reported with the captured key and can be retried", async () => { + const { button } = register(); + const open = mock.method(window, "open", () => null); + button.click(); + button.setAttribute("data-terminal-window-key", "changed"); + await flushNotifications(); + assert.deepEqual(notifications, [["OnTerminalWindowOpenedAsync", "terminal", "blocked"]]); + assert.equal(terminalWindows.isTerminalWindowOpen("terminal"), false); + assert.equal(poll, null); + + open.mock.restore(); + button.setAttribute("data-terminal-window-key", "terminal"); + button.click(); + await flushNotifications(); + assert.deepEqual(notifications[1], ["OnTerminalWindowOpenedAsync", "terminal", "opened"]); +}); + +test("re-registration removes the old listener and disposal leaves independent windows open", async () => { + const { button, id, owner } = register(); + terminalWindows.registerTerminalWindowButton(id, id, owner); + button.click(); + await flushNotifications(); + assert.equal(calls.length, 1); + assert.deepEqual(notifications, [["OnTerminalWindowOpenedAsync", "terminal", "opened"]]); + + terminalWindows.unregisterTerminalWindowButton(id); + button.click(); + await flushNotifications(); + assert.equal(calls.length, 1); + assert.equal(calls[0].popup.closed, false); + assert.equal(poll, null); + assert.equal(terminalWindows.isTerminalWindowOpen("terminal"), false); + assert.equal(notifications.length, 1); +}); + +test("disposing an old owner cannot untrack a window adopted by a replacement button", async () => { + const old = register(); + old.button.click(); + await flushNotifications(); + const current = register(); + current.button.click(); + terminalWindows.unregisterTerminalWindowButton(old.id); + await flushNotifications(); + assert.equal(terminalWindows.isTerminalWindowOpen("terminal"), true); + assert.equal(calls.length, 1); + assert.equal(calls[0].popup.focusCalls, 1); + calls[0].popup.close(); + poll(); + await flushNotifications(); + assert.deepEqual(notifications, [ + ["OnTerminalWindowOpenedAsync", "terminal", "opened"], + ["OnTerminalWindowOpenedAsync", "terminal", "focused"], + ["OnTerminalWindowClosedAsync", "terminal"], + ]); +}); + +test("a user close waits for the detach acknowledgement and is reported exactly once", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { button } = register("terminal", undefined, (...args) => { + notifications.push(args); + return args[0] === "OnTerminalWindowOpenedAsync" ? promise : Promise.resolve(); + }); + button.click(); + await flushNotifications(); + calls[0].popup.close(); + poll(); + assert.equal(poll, null); + assert.equal(notifications.length, 1); + resolve(); + await flushNotifications(); + assert.deepEqual(notifications, [ + ["OnTerminalWindowOpenedAsync", "terminal", "opened"], + ["OnTerminalWindowClosedAsync", "terminal"], + ]); +}); + +for (const end of ["return", "close", "dispose"]) { + test(`${end} cancels queued open notifications instead of resurrecting a detached pane`, async () => { + const { promise, resolve } = Promise.withResolvers(); + const { button, id } = register("terminal", undefined, (...args) => { + notifications.push(args); + return promise; + }); + button.click(); + await flushNotifications(); + button.click(); + if (end === "return") { + terminalWindows.closeTerminalWindow("terminal"); + } else if (end === "close") { + calls[0].popup.close(); + poll(); + } else { + terminalWindows.unregisterTerminalWindowButton(id); + } + resolve(); + await flushNotifications(); + assert.deepEqual(notifications, end === "close" + ? [["OnTerminalWindowOpenedAsync", "terminal", "opened"], ["OnTerminalWindowClosedAsync", "terminal"]] + : [["OnTerminalWindowOpenedAsync", "terminal", "opened"]]); + assert.equal(calls[0].popup.closed, end !== "dispose"); + }); +} + +test("reopening a closed window suppresses its obsolete queued close notification", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { button } = register("terminal", undefined, (...args) => { + notifications.push(args); + return promise; + }); + button.click(); + await flushNotifications(); + calls[0].popup.close(); + poll(); + button.click(); + assert.equal(calls.length, 2); + resolve(); + await flushNotifications(); + assert.deepEqual(notifications, [ + ["OnTerminalWindowOpenedAsync", "terminal", "opened"], + ["OnTerminalWindowOpenedAsync", "terminal", "opened"], + ]); +}); + +test("failed browser operations and rejected notifications are observed without poisoning later clicks", async () => { + const errors = []; + const warnings = []; + mock.method(console, "error", (...args) => errors.push(args)); + mock.method(console, "warn", (...args) => warnings.push(args)); + const open = mock.method(window, "open", () => { throw new Error("Browser unavailable"); }); + const { button } = register("terminal", undefined, async (...args) => { + notifications.push(args); + throw new Error("Circuit unavailable"); + }); + button.click(); + await flushNotifications(); + assert.deepEqual(notifications, [["OnTerminalWindowOpenedAsync", "terminal", "failed"]]); + assert.equal(errors.length, 1); + assert.equal(warnings.length, 1); + open.mock.restore(); + button.click(); + assert.equal(calls.length, 1); + await flushNotifications(); + assert.deepEqual(notifications[1], ["OnTerminalWindowOpenedAsync", "terminal", "opened"]); + assert.equal(warnings.length, 2); +}); + +class TestButton extends EventTarget { + isConnected = true; + disabled = false; + inertAncestor = false; + attributes = new Map(); + + constructor(key, url) { + super(); + this.setAttribute("data-terminal-window-key", key); + this.setAttribute("data-terminal-window-url", url); + } + + setAttribute(key, value) { this.attributes.set(key, value); } + getAttribute(key) { return this.attributes.get(key) ?? null; } + hasAttribute(key) { return this.attributes.has(key); } + removeAttribute(key) { this.attributes.delete(key); } + closest() { return this.inertAncestor ? this : null; } + click() { this.dispatchEvent(new Event("click")); } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 78f74bcb90e..45595ef7dbd 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -551,12 +551,12 @@ await cut.InvokeAsync(() => view.OnTerminalStateChanged(new TerminalToolbarState await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", terminalId, "third")); cut.WaitForAssertion(() => Assert.Equal(3, cut.FindComponents().Count)); - await cut.Find(".terminal-dock-detach").ClickAsync(new()); - var open = Assert.Single(JSInterop.Invocations, i => i.Identifier == "openTerminalWindow"); - Assert.Equal(terminalId, open.Arguments[0]); - Assert.Equal($"http://localhost{pathBase}/terminal-window/apphost/{escapedTerminalId}?fontSize=19", open.Arguments[1]); - Assert.Equal(960, open.Arguments[2]); - Assert.Equal(600, open.Arguments[3]); + var open = cut.Find(".terminal-dock-detach"); + Assert.Equal(terminalId, open.GetAttribute("data-terminal-window-key")); + Assert.Equal($"http://localhost{pathBase}/terminal-window/apphost/{escapedTerminalId}?fontSize=19", open.GetAttribute("data-terminal-window-url")); + Assert.False(open.HasAttribute("disabled")); + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync(terminalId, "opened")); var moduleImport = Assert.Single(JSInterop.Invocations, i => i.Identifier == "import" && i.Arguments[0] is string path && path.EndsWith("/js/app-terminalwindow.js", StringComparison.Ordinal)); Assert.Equal($"{pathBase}/js/app-terminalwindow.js", moduleImport.Arguments[0]); @@ -584,7 +584,8 @@ public async Task RecoverySnapshot_ClosesWindowForMissingTerminal() await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "detached")); cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); - await cut.Find(".terminal-dock-detach").ClickAsync(new()); + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("detached", "opened")); cut.WaitForAssertion(() => { Assert.Single(cut.FindAll(".terminal-dock-detached")); @@ -598,11 +599,81 @@ public async Task RecoverySnapshot_ClosesWindowForMissingTerminal() }); // The snapshot renders before window cleanup, and the JS call does not itself cause another render. - // Wait for that side effect independently of bUnit's render-triggered assertions. + // Wait independently of render-triggered assertions, on the renderer because bUnit's invocation + // dictionary is not safe to enumerate concurrently with the watch update's JS calls. await AsyncTestHelpers.AssertIsTrueRetryAsync( - () => JSInterop.Invocations.Any(i => i.Identifier == "closeTerminalWindow"), + () => cut.InvokeAsync(() => JSInterop.Invocations.Any(i => i.Identifier == "closeTerminalWindow")), "The removed terminal's detached window was not closed."); - var close = Assert.Single(JSInterop.Invocations, i => i.Identifier == "closeTerminalWindow"); + var close = await cut.InvokeAsync(() => Assert.Single(JSInterop.Invocations, i => i.Identifier == "closeTerminalWindow")); Assert.Equal("detached", close.Arguments[0]); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DelayedDetachNotification_ReconcilesCapturedTerminalRatherThanActiveTab(bool removeClickedTerminal) + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents().Count)); + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + + await cut.FindAll(".terminal-dock-tab-select")[1].ClickAsync(new()); + if (removeClickedTerminal) + { + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "first")); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock-tab"))); + } + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("first", "opened")); + Assert.Equal("second", cut.Find(".terminal-dock-tab.active .terminal-dock-tab-title").TextContent); + Assert.Single(cut.FindAll(".terminal-dock-pane.active .terminal-view")); + Assert.Empty(cut.FindAll(".terminal-dock-pane.active .terminal-dock-detached")); + Assert.Empty(client.ClosedTerminals); + + if (removeClickedTerminal) + { + Assert.Empty(cut.FindAll(".terminal-dock-detached")); + var close = Assert.Single(JSInterop.Invocations, i => i.Identifier == "closeTerminalWindow"); + Assert.Equal("first", close.Arguments[0]); + } + else + { + Assert.Single(cut.FindAll(".terminal-dock-pane.inactive .terminal-dock-detached")); + await cut.InvokeAsync(() => launcher.OnTerminalWindowClosedAsync("first")); + Assert.Empty(cut.FindAll(".terminal-dock-detached")); + Assert.Equal(2, cut.FindComponents().Count); + } + } + + [Fact] + public async Task BlockedDetach_KeepsInlineViewAndDisplaysFeedback() + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var cut = RenderComponent(); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "terminal")); + cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); + Assert.True(cut.Find(".terminal-dock-detach").HasAttribute("disabled")); + var view = cut.FindComponent().Instance; + await cut.InvokeAsync(() => view.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Connected = true, FontPx = 19 + })); + Assert.False(cut.Find(".terminal-dock-detach").HasAttribute("disabled")); + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("terminal", "blocked")); + Assert.Equal(Resources.Layout.TerminalDockDetachBlocked, cut.Find(".terminal-dock-popup-blocked").TextContent); + Assert.Same(view, cut.FindComponent().Instance); + Assert.Empty(cut.FindAll(".terminal-dock-detached")); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("terminal", "opened")); + Assert.Empty(cut.FindAll(".terminal-dock-popup-blocked")); + Assert.Single(cut.FindAll(".terminal-dock-detached")); + Assert.True(cut.Find(".terminal-dock-detach").HasAttribute("disabled")); + Assert.Empty(client.ClosedTerminals); + } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index eb015954b6e..bb3437a3964 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -124,15 +124,14 @@ await cut.InvokeAsync(() => terminal.OnTerminalStateChanged(new TerminalToolbarS TerminalId = 1, Generation = 1, Connected = true, FontPx = 17 })); - var open = Assert.Single(cut.Instance.LogsMenuItemsForTest, - item => item.Text == Resources.ConsoleLogs.TerminalToolbarOpenInWindow); - await cut.InvokeAsync(open.OnClick!); - - var invocation = Assert.Single(JSInterop.Invocations, i => i.Identifier == "openTerminalWindow"); - Assert.Equal($"resource:{resourceName}:{replicaIndex}", invocation.Arguments[0]); - Assert.Equal($"http://localhost{pathBase}/terminal-window/resource/{escapedResourceName}/{replicaIndex}?fontSize=17", invocation.Arguments[1]); - Assert.Equal(960, invocation.Arguments[2]); - Assert.Equal(600, invocation.Arguments[3]); + var open = cut.Find(".terminal-titlebar .terminal-open-window"); + Assert.Equal($"resource:{resourceName}:{replicaIndex}", open.GetAttribute("data-terminal-window-key")); + Assert.Equal($"http://localhost{pathBase}/terminal-window/resource/{escapedResourceName}/{replicaIndex}?fontSize=17", + open.GetAttribute("data-terminal-window-url")); + Assert.Equal(Resources.ConsoleLogs.TerminalToolbarOpenInWindow, open.GetAttribute("aria-label")); + Assert.False(open.HasAttribute("disabled")); + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync($"resource:{resourceName}:{replicaIndex}", "opened")); var moduleImport = Assert.Single(JSInterop.Invocations, i => i.Identifier == "import" && i.Arguments[0] is string path && path.EndsWith("/js/app-terminalwindow.js", StringComparison.Ordinal)); Assert.Equal($"{pathBase}/js/app-terminalwindow.js", moduleImport.Arguments[0]); @@ -171,8 +170,7 @@ public async Task TerminalResource_ViewPicker_MarksActiveViewAsChecked() cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name); cut.WaitForState(() => cut.FindComponents().Count > 0); - // In the Terminal view the menu is the two view-toggle items followed by the - // window action; the Console view additionally separates them with a divider. + // In the Terminal view the menu contains only the two view-toggle items. // Both toggles are modeled as checkable menu items so assistive technology can // announce the selection, and the live resource defaults to Terminal, so only // the Terminal item is checked. @@ -188,15 +186,27 @@ public async Task TerminalResource_ViewPicker_MarksActiveViewAsChecked() { Assert.Equal(MenuItemRole.Checkbox, item.Role); Assert.True(item.Checked); - }, - // The window action is not a view toggle, so it carries no checkable role. - item => Assert.Null(item.Role)); + }); + Assert.Single(cut.FindAll(".terminal-titlebar .terminal-open-window")); // Switching to Console moves the checked state to the Console item. await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Console))); cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Console); Assert.True(instance.LogsMenuItemsForTest[0].Checked); Assert.False(instance.LogsMenuItemsForTest[1].Checked); + Assert.Empty(cut.FindAll(".terminal-open-window")); + var logViewer = cut.FindComponent().Instance; + Assert.Equal([ + Resources.ConsoleLogs.ConsoleLogsViewConsoleOption, + Resources.ConsoleLogs.ConsoleLogsViewTerminalOption, + Resources.ConsoleLogs.DownloadLogs, + logViewer.ShowTimestamp ? Resources.ConsoleLogs.ConsoleLogsTimestampHide : Resources.ConsoleLogs.ConsoleLogsTimestampShow, + Resources.ConsoleLogs.ConsoleLogsTimestampShowUtc, + logViewer.NoWrapLogs ? Resources.ConsoleLogs.ConsoleLogsWrapLogs : Resources.ConsoleLogs.ConsoleLogsNoWrapLogs + ], instance.LogsMenuItemsForTest.Where(item => !item.IsDivider).Select(item => item.Text)); + Assert.Single(cut.FindComponents()); + await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Terminal))); + Assert.Single(cut.FindAll(".terminal-titlebar .terminal-open-window")); } [Fact] @@ -647,6 +657,7 @@ public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitia FluentUISetupHelpers.AddCommonDashboardServices(this); FluentUISetupHelpers.SetupFluentUIComponents(this); var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); + TerminalSetupHelpers.SetupTerminalWindows(this); var initTerminal = module.Setup("initTerminal", _ => true); var reconnectTerminal = module.Setup("reconnectTerminal", _ => true); reconnectTerminal.SetResult(2); @@ -743,6 +754,7 @@ private void SetupTerminalViewJsInterop() // assertions in these tests are about render-branch selection, not // about runtime terminal behaviour. TerminalSetupHelpers.SetupTerminalView(this); + TerminalSetupHelpers.SetupTerminalWindows(this); } private static ResourceViewModel CreateTerminalResource(string resourceName, int replicaIndex, int replicaCount, KnownResourceState state = KnownResourceState.Running) diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 223a052c3d6..793b6cf149c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -2,12 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Terminal; using Aspire.Dashboard.Tests.Shared; using Aspire.DashboardService.Proto.V1; using Bunit; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.JSInterop; using Assert = Xunit.Assert; namespace Aspire.Dashboard.Components.Tests.Shared; @@ -55,11 +57,25 @@ public static void SetupTerminalDock(TestContext context, string pathBase = "") dock.SetupVoid("registerTabNavigation", _ => true).SetVoidResult(); dock.SetupVoid("unregisterTabNavigation", _ => true).SetVoidResult(); + SetupTerminalWindows(context, pathBase); + } + + public static BunitJSModuleInterop SetupTerminalWindows(TestContext context, string pathBase = "") + { var windows = context.JSInterop.SetupModule($"{pathBase}/js/app-terminalwindow.js"); - windows.Setup("openTerminalWindow", _ => true).SetResult("opened"); + windows.SetupVoid("registerTerminalWindowButton", _ => true).SetVoidResult(); windows.Setup("focusTerminalWindow", _ => true).SetResult(true); windows.SetupVoid("closeTerminalWindow", _ => true).SetVoidResult(); - windows.SetupVoid("untrackTerminalWindow", _ => true).SetVoidResult(); + windows.SetupVoid("unregisterTerminalWindowButton", _ => true).SetVoidResult(); + return windows; + } + + public static TerminalWindowLauncher GetWindowLauncher(TestContext context, IRenderedFragment component) + { + // A terminal-watch update can render a child before that child's OnAfterRenderAsync registers its listener. + component.WaitForAssertion(() => Assert.Single(context.JSInterop.Invocations, i => i.Identifier == "registerTerminalWindowButton")); + var registration = Assert.Single(context.JSInterop.Invocations, i => i.Identifier == "registerTerminalWindowButton"); + return Assert.IsType>(registration.Arguments[2]).Value; } public static void AssertSingleTerminalConnection(TestContext context, string expectedWebSocketUrl) From 6d500a7a138502b2cf868bf5f87102d4c052394d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 16:40:22 +1000 Subject: [PATCH 073/106] Align terminal footer typography with dashboard message bars Apply small-text tokens to footer labels and Fluent shadow-root controls without changing terminal content font preferences. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Controls/TerminalView.razor.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css index ec64d375a49..fcc1c1cfb08 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css @@ -45,7 +45,11 @@ padding: 2px 8px; background: var(--colorNeutralBackground2); color: var(--colorNeutralForeground2); - font-size: 11px; + font-size: var(--fontSizeBase200); + line-height: var(--lineHeightBase200); + /* Fluent controls consume these tokens inside their shadow roots. */ + --fontSizeBase300: var(--fontSizeBase200); + --lineHeightBase300: var(--lineHeightBase200); } .terminal-controls { From 5d1138dba7ff6cc6fc42f6abf5eeac6530b793b8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 16:44:30 +1000 Subject: [PATCH 074/106] Track terminal dock usage and opening trigger Use correlated component telemetry for each visible interval, distinguishing User and AppHost triggers without recording terminal details or counting tab activation twice. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor.cs | 40 ++++- .../Telemetry/TelemetryComponentIds.cs | 1 + .../Telemetry/TelemetryPropertyKeys.cs | 3 + .../Layout/TerminalDockTelemetryTests.cs | 138 ++++++++++++++++++ .../Layout/TerminalDockTests.cs | 2 +- 5 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTelemetryTests.cs diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 1c6389e09eb..944b338e259 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Telemetry; using Aspire.DashboardService.Proto.V1; using Grpc.Core; using Microsoft.AspNetCore.Components; @@ -59,6 +61,11 @@ public sealed partial class TerminalDock : ComponentBase, IGlobalKeydownListener private TerminalWindowButton? _windowButton; private bool _popupBlocked; + internal ComponentTelemetryContext? TelemetryContext { get; private set; } + + [Inject] + public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; } + [Inject] public required IDashboardClient DashboardClient { get; init; } @@ -122,16 +129,27 @@ public Task ToggleAsync() => InvokeAsync(() => } else { - Show(); + Show(TerminalDockTrigger.User); + StateHasChanged(); } }); - private void Show() + private void Show(TerminalDockTrigger trigger) { + if (_isVisible) + { + return; + } + + // Construction eagerly starts the subscription, not a user-visible dock session. + // Each hidden-to-visible transition needs a fresh correlation and its own closing event. + TelemetryContext = new ComponentTelemetryContext(ComponentType.Control, TelemetryComponentIds.TerminalDock); + TelemetryContextProvider.Initialize(TelemetryContext); + TelemetryContext.UpdateTelemetryProperties( + [new(TelemetryPropertyKeys.TerminalDockTrigger, new AspireTelemetryProperty(trigger.ToString()))], Logger); _hasBeenOpened = true; _isVisible = true; - StateHasChanged(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -178,6 +196,8 @@ public Task SetHeightAsync(int heightPx, int viewportHeightPx) => InvokeAsync(() private void Hide() { + TelemetryContext?.Dispose(); + TelemetryContext = null; _isVisible = false; StateHasChanged(); } @@ -353,8 +373,7 @@ await InvokeAsync(async () => { // An overflow snapshot retains the latest Show() request even if its terminal has // since been removed. Reveal the dock, but never resurrect a removed terminal's tab. - _hasBeenOpened = true; - _isVisible = true; + Show(TerminalDockTrigger.AppHost); if (_terminals.Any(t => t.TerminalId == update.Snapshot.ActivatedTerminalId)) { _activeTerminalId = update.Snapshot.ActivatedTerminalId; @@ -440,8 +459,7 @@ await InvokeAsync(async () => _terminals.Add(descriptor); } _activeTerminalId = descriptor.TerminalId; - _hasBeenOpened = true; - _isVisible = true; + Show(TerminalDockTrigger.AppHost); break; } @@ -474,6 +492,8 @@ public async ValueTask DisposeAsync() } _disposed = true; + TelemetryContext?.Dispose(); + TelemetryContext = null; ShortcutManager.RemoveGlobalKeydownListener(this); // Stop updates before releasing browser-side state. A queued dispatcher callback observes _disposed and @@ -516,4 +536,10 @@ public async ValueTask DisposeAsync() _cts.Dispose(); } + + private enum TerminalDockTrigger + { + User, + AppHost + } } diff --git a/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs b/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs index 0609751f799..f81707408fb 100644 --- a/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs +++ b/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs @@ -24,5 +24,6 @@ public static class TelemetryComponentIds public const string InteractionMessageBar = nameof(InteractionMessageBar); public const string InteractionInputsDialog = nameof(InteractionInputsDialog); public const string InteractionProgressDialog = nameof(InteractionProgressDialog); + public const string TerminalDock = nameof(TerminalDock); public const string GenAIVisualizerDialog = nameof(GenAIVisualizerDialog); } diff --git a/src/Aspire.Dashboard/Telemetry/TelemetryPropertyKeys.cs b/src/Aspire.Dashboard/Telemetry/TelemetryPropertyKeys.cs index f80cf6fea2f..2e31058afb9 100644 --- a/src/Aspire.Dashboard/Telemetry/TelemetryPropertyKeys.cs +++ b/src/Aspire.Dashboard/Telemetry/TelemetryPropertyKeys.cs @@ -46,4 +46,7 @@ public static class TelemetryPropertyKeys // Command properties public const string CommandName = AspireDashboardPropertyPrefix + "Command.Name"; + + // Terminal dock properties + public const string TerminalDockTrigger = AspireDashboardPropertyPrefix + "TerminalDock.Trigger"; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTelemetryTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTelemetryTests.cs new file mode 100644 index 00000000000..31d8fa3a897 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTelemetryTests.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 System.Threading.Channels; +using Aspire.Dashboard.Components.Layout; +using Aspire.Dashboard.Components.Pages; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Telemetry; +using Aspire.Dashboard.Tests; +using Aspire.Dashboard.Tests.Shared; +using Aspire.DashboardService.Proto.V1; +using Bunit; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Layout; + +public partial class TerminalDockTests +{ + [Theory] + [InlineData("button", "User", true)] + [InlineData("keyboard", "User", true)] + [InlineData("activation", "AppHost", true)] + [InlineData("snapshot", "AppHost", true)] + [InlineData("button", "User", false)] + [InlineData("activation", "AppHost", false)] + public async Task Telemetry_TracksVisibleIntervalsAndOpeningTrigger(string action, string trigger, bool telemetryEnabled) + { + var updates = Channel.CreateUnbounded(); + var client = new TestDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var sender = new TestDashboardTelemetrySender { IsTelemetryEnabled = telemetryEnabled }; + Services.AddSingleton(sender); + var telemetryService = Services.GetRequiredService(); + await telemetryService.InitializeAsync(); + Assert.Equal(Enumerable.Repeat(TelemetryEndpoints.TelemetryPostProperty, + telemetryEnabled ? telemetryService._defaultProperties.Count : 0), DrainTelemetryEvents(sender)); + var cut = RenderComponent(); + Assert.Null(cut.Instance.TelemetryContext); + + var renderCount = cut.RenderCount; + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); + cut.WaitForAssertion(() => Assert.True(cut.RenderCount > renderCount)); + Assert.Null(cut.Instance.TelemetryContext); + Assert.Empty(DrainTelemetryEvents(sender)); + + switch (action) + { + case "button": + await cut.InvokeAsync(cut.Instance.ToggleAsync); + break; + case "keyboard": + await Services.GetRequiredService().OnGlobalKeyDown(AspireKeyboardShortcut.ToggleTerminalDock); + break; + case "activation": + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "first")); + break; + case "snapshot": + var snapshot = TerminalSetupHelpers.Snapshot("first", "second"); + snapshot.Snapshot.ActivatedTerminalId = "first"; + await updates.Writer.WriteAsync(snapshot); + break; + } + + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock:not(.collapsed)"))); + var firstContext = Assert.IsType(cut.Instance.TelemetryContext); + AssertDockTelemetryProperties(firstContext, trigger); + Assert.Equal(telemetryEnabled ? new[] + { + "/telemetry/userTask - $aspire/dashboard/component/initialize", + "/telemetry/operation - $aspire/dashboard/component/paramsSet" + } : [], DrainTelemetryEvents(sender)); + + await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "second")); + cut.WaitForAssertion(() => Assert.Equal("second", cut.Find("[role=tab][aria-selected=true]").TextContent.Trim())); + await cut.FindAll("[role=tab]")[0].ClickAsync(new()); + Assert.Equal("first", cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); + var recovery = TerminalSetupHelpers.Snapshot("first", "second"); + recovery.Snapshot.ActivatedTerminalId = "second"; + await updates.Writer.WriteAsync(recovery); + cut.WaitForAssertion(() => Assert.Equal("second", cut.Find("[role=tab][aria-selected=true]").TextContent.Trim())); + Assert.Same(firstContext, cut.Instance.TelemetryContext); + AssertDockTelemetryProperties(firstContext, trigger); + Assert.Empty(DrainTelemetryEvents(sender)); + + await cut.InvokeAsync(cut.Instance.ToggleAsync); + Assert.Null(cut.Instance.TelemetryContext); + Assert.Equal(telemetryEnabled ? new[] { "/telemetry/operation - $aspire/dashboard/component/dispose" } : [], + DrainTelemetryEvents(sender)); + + if (trigger == "User") + { + await updates.Writer.WriteAsync(recovery); + } + else + { + await cut.InvokeAsync(cut.Instance.ToggleAsync); + } + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock:not(.collapsed)"))); + var secondContext = Assert.IsType(cut.Instance.TelemetryContext); + Assert.NotSame(firstContext, secondContext); + AssertDockTelemetryProperties(secondContext, trigger == "User" ? "AppHost" : "User"); + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + Assert.Null(cut.Instance.TelemetryContext); + Assert.Equal(telemetryEnabled ? new[] + { + "/telemetry/userTask - $aspire/dashboard/component/initialize", + "/telemetry/operation - $aspire/dashboard/component/paramsSet", + "/telemetry/operation - $aspire/dashboard/component/dispose" + } : [], DrainTelemetryEvents(sender)); + } + + private static void AssertDockTelemetryProperties(ComponentTelemetryContext context, string trigger) + { + (string, string)[] expected = + [ + ("Aspire.Dashboard.ComponentId", "TerminalDock"), + ("Aspire.Dashboard.ComponentType", "Control"), + ("Aspire.Dashboard.TerminalDock.Trigger", trigger) + ]; + Assert.Equal(expected, context.Properties.OrderBy(p => p.Key) + .Select(p => (p.Key, Assert.IsType(p.Value.Value)))); + } + + private static string[] DrainTelemetryEvents(TestDashboardTelemetrySender sender) + { + List events = []; + while (sender.ContextChannel.Reader.TryRead(out var operation)) + { + events.Add(operation.Name); + } + return events.ToArray(); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 45595ef7dbd..d34a51f0174 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -21,7 +21,7 @@ namespace Aspire.Dashboard.Components.Tests.Layout; [UseCulture("en-US")] -public class TerminalDockTests : DashboardTestContext +public partial class TerminalDockTests : DashboardTestContext { [Theory] [InlineData("", "terminal", "terminal")] From bbc62f1cb48a75bf8292680c3e54543061afdd14 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 16:46:21 +1000 Subject: [PATCH 075/106] Link the empty terminal dock to terminal documentation Add a localized help link to the dashboard terminals guide. Track consolidated documentation and aka.ms redirect provisioning in microsoft/aspire.dev#1663 for milestone 13.6. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Components/Layout/TerminalDock.razor | 1 + src/Aspire.Dashboard/Resources/Layout.Designer.cs | 9 +++++++++ src/Aspire.Dashboard/Resources/Layout.resx | 4 ++++ src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf | 5 +++++ .../Layout/TerminalDockTests.cs | 5 +++++ 17 files changed, 84 insertions(+) diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 00bf58c7738..fd3fe3e4cea 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -133,6 +133,7 @@

@Loc[nameof(Resources.Layout.TerminalDockPanelHeading)]

@Loc[nameof(Resources.Layout.TerminalDockPanelBody)]

+ @Loc[nameof(Resources.Layout.TerminalDockMoreInformation)]

@Loc[nameof(Resources.Layout.TerminalDockPanelHint)]

diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index d743b5089ed..3a075bf0909 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -158,6 +158,15 @@ public static string TerminalDockPanelHint { return ResourceManager.GetString("TerminalDockPanelHint", resourceCulture); } } + + /// + /// Looks up a localized string similar to More information. + /// + public static string TerminalDockMoreInformation { + get { + return ResourceManager.GetString("TerminalDockMoreInformation", resourceCulture); + } + } /// /// Looks up a localized string similar to The browser blocked the terminal window. Allow pop-ups for the dashboard and try again.. diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 557cc8b70c3..debc1f03760 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -216,6 +216,10 @@ Press Shift+` to hide this panel. + + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Return to panel diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index fb684d28fb2..56a9a725fde 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index a6142bac497..013d59f8508 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index ade87c503b9..244bbb3b807 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index da73c816a29..f3cd2bc30e6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 81f705e36ff..2090ea10caa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index 4c7327f6720..60e4bec0d80 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 0a521e9d372..c91022e13fa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 29c78a668f6..596cc5ce0ea 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index 015cfca6632..bf2ab095610 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 95e5254ffb9..43af9c447a7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 72af66f436e..a0f24e2a11b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 58d84a34c30..d5e8fb418f0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 6b28c2f60f3..a8165c4e338 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -192,6 +192,11 @@ Hide terminal panel (Shift+`) + + More information + More information + Link to documentation about dashboard terminals, shown when the terminal dock is empty. + Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. Terminals are started by the app host. One appears here when app host code opens a terminal, or when a resource command runs an interactive tool. diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index d34a51f0174..6bef6f77097 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -118,6 +118,11 @@ public async Task WatchUpdates_ReplaceSnapshotAndSelectAppHostTerminals() await cut.InvokeAsync(cut.Instance.ToggleAsync); Assert.Equal("No terminals", cut.Find(".terminal-dock-panel-heading").TextContent); + var helpLink = cut.Find(".terminal-dock-panel a"); + Assert.Equal(Resources.Layout.TerminalDockMoreInformation, helpLink.TextContent); + Assert.Equal("https://aka.ms/aspire/dashboard-terminals", helpLink.GetAttribute("href")); + Assert.Equal("_blank", helpLink.GetAttribute("target")); + Assert.Equal("noopener noreferrer", helpLink.GetAttribute("rel")); Assert.Equal(["Open terminal in a new window", "Hide terminal panel (Shift+`)"], cut.FindAll(".terminal-dock-tabstrip fluent-button").Select(button => button.GetAttribute("aria-label"))); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); From 556f6d6afcc35904fc1d0a5233098541f1ec4ddb Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 17:17:36 +1000 Subject: [PATCH 076/106] Introduce dedicated terminal interactions and dialogs Add PromptTerminalAsync with progress-style work and cancellation semantics, preserve caller-owned terminal lifetime, and migrate the protocol, dashboard, playground, documentation, and tests away from terminal-valued inputs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 64 +- .../Terminals.AppHost/Scripts/numberguess.cs | 2 +- .../TerminalInteractionCommands.cs | 113 ++-- .../Dialogs/InteractionsInputDialog.razor | 25 - .../Dialogs/InteractionsInputDialog.razor.cs | 10 - .../Dialogs/InteractionsInputDialog.razor.css | 18 - .../Dialogs/InteractionsTerminalDialog.razor | 25 + .../InteractionsTerminalDialog.razor.cs | 40 ++ .../InteractionsTerminalDialog.razor.css | 14 + .../Interactions/InteractionsProvider.cs | 87 ++- .../InteractionsTerminalDialogViewModel.cs | 20 + .../ServiceClient/IDashboardClient.cs | 2 +- .../Telemetry/TelemetryComponentIds.cs | 1 + .../ResourceCommandService.cs | 4 - .../Dashboard/DashboardService.cs | 10 +- .../Dashboard/DashboardServiceData.cs | 8 +- .../Dashboard/proto/dashboard_service.proto | 20 +- src/Aspire.Hosting/IInteractionService.cs | 184 +++--- src/Aspire.Hosting/InteractionService.cs | 142 ++-- .../Terminals/TerminalPlacement.cs | 5 +- .../Terminals/TerminalService.cs | 4 +- .../Dialogs/InteractionsInputDialogTests.cs | 118 +--- .../InteractionsTerminalDialogTests.cs | 106 +++ .../InteractionsProviderTests.Terminal.cs | 156 +++++ .../Shared/InteractionsSetupHelpers.cs | 50 ++ .../Dashboard/DashboardServiceTests.cs | 109 ++++ .../ResourceCommandServiceTests.cs | 35 +- .../InteractionServiceTerminalTests.cs | 612 ++++++++++-------- tests/Shared/TestInteractionService.cs | 38 +- 29 files changed, 1269 insertions(+), 753 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor create mode 100644 src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor.css create mode 100644 src/Aspire.Dashboard/Model/Interaction/InteractionsTerminalDialogViewModel.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsTerminalDialogTests.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.Terminal.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Shared/InteractionsSetupHelpers.cs diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index a3b48df6aac..68be6d9cfe1 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -55,13 +55,73 @@ override the AppHost's inherited environment. Requested dimensions default to 12 columns and 32 rows. The current HMP server overrides those initial dimensions to 80 columns and 24 rows; viewer-driven resizing still applies after attachment. Placement defaults to the dock; -use `Dialog` for terminal interaction inputs or `None` for automation-only terminals. +use `Dialog` for terminal interactions or `None` for automation-only terminals. The creator owns the terminal. A dock terminal can outlive the command that created it: closing its tab or shutting down the AppHost disposes it. For a dialog-scoped terminal, use `await using` around creation and the interaction; closing the interaction alone does not dispose the terminal. +### Terminal interactions + +`IInteractionService.PromptTerminalAsync` displays one caller-owned terminal in +a dedicated dialog, following progress-interaction completion and cancellation +semantics. It is experimental under the same `ASPIRETERMINAL002` diagnostic. +The public API uses only Aspire types, not Hex1b types. + +```csharp +var interactions = app.Services.GetRequiredService(); +await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions +{ + Title = "Setup", + Executable = "./setup.sh", + Placement = TerminalPlacement.Dialog +}); +terminal.Start(); +var result = await interactions.PromptTerminalAsync( + "Running setup.", terminal, + new TerminalInteractionOptions + { + Title = "Setup", + PrimaryButtonText = "Cancel", + Work = async context => + { + await terminal.WaitForTextAsync("Continue? ", cancellationToken: context.CancellationToken); + await terminal.SendTextAsync("y\r", context.CancellationToken); + await terminal.WaitForTextAsync("Setup complete", cancellationToken: context.CancellationToken); + } + }, + cancellationToken); +``` + +- The supplied terminal must use `Dialog` placement and be the exact instance + still registered with the same AppHost. A foreign, disposed, or merely + same-ID handle is rejected. Attachment also checks availability. +- Successful `Work` completion closes the dialog and returns a successful + `InteractionResult`. The optional primary button requests cancellation; + it is not a form submit/accept button. Secondary and dismiss actions are hidden. +- User or external cancellation closes the dialog, signals + `TerminalContext.CancellationToken`, and waits for `Work` to finish before + returning a canceled result. A pre-canceled caller token throws before + publishing. Other callback failures remove the interaction and propagate. +- Without `Work`, the interaction waits for explicit completion, user + cancellation (when a button is configured), or external cancellation. + **Process exit does not close the dialog.** +- The caller starts and owns the terminal. Completion, cancellation, viewer + disposal, and dashboard disconnect never stop or dispose the producer. + Disconnecting only releases that viewer; the pending interaction can be + redisplayed on reconnect. The caller can reuse the terminal in later prompts. +- The dialog contains a chromeless terminal with its existing font/footer + controls, no duplicate title bar or launch button, and a wider viewport than + ordinary input dialogs. It uses the PathBase-aware AppHost terminal endpoint. + +Migration: replace `InputType.Terminal` / `InteractionInput.Terminal` passed to +`PromptInputsAsync` with `PromptTerminalAsync`. The old protobuf input field and +enum identifiers are reserved; the dedicated `prompt_terminal` payload carries +the terminal ID and optional boolean result. There is no terminal input value, +required-field validation, or command-argument cloning. This change does not +introduce an ATS/polyglot terminal API. + ## Process topology ```text @@ -196,7 +256,7 @@ closure (`1000`), abnormal transport loss (`1006`), close reason strings, and `wasClean` do not indicate producer completion and remain retryable. Each component registers a separate input policy with the dashboard and passes -its opaque `viewId` with the WebSocket URL. Changes to an interaction's disabled +its opaque `viewId` with the WebSocket URL. Changes to a view's read-only state update that policy before updating browser input behavior. The bridge applies `Hwt1PresentationAdapter.IsReadOnly` before dispatching each complete command, delegating validation and input gating to Hex1b. The browser uses diff --git a/playground/Terminals/Terminals.AppHost/Scripts/numberguess.cs b/playground/Terminals/Terminals.AppHost/Scripts/numberguess.cs index 9a6a32a5f66..751004bda7c 100644 --- a/playground/Terminals/Terminals.AppHost/Scripts/numberguess.cs +++ b/playground/Terminals/Terminals.AppHost/Scripts/numberguess.cs @@ -5,7 +5,7 @@ // (`dotnet run --file numberguess.cs -- `). // // It exists to demonstrate driving an interactive process from AppHost code: the AppHost shows this program in an -// InputType.Terminal interaction, then plays it by typing guesses and reading the replies back off the terminal +// PromptTerminalAsync interaction, then plays it by typing guesses and reading the replies back off the terminal // screen. Nothing here knows it is being automated - it is an ordinary Console.ReadLine app. // // The reply format is the contract the automation relies on, so it is deliberately unambiguous: diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index f976755b515..937d096b8ab 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -6,16 +6,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -// InputType.Terminal is an experimental spike. PromptInputsAsync is also experimental. -#pragma warning disable ASPIREINTERACTION001 - -// AppHost-owned terminals - TerminalService, AspireTerminal, TerminalLaunchOptions - are experimental. +// AppHost-owned terminals and terminal interactions are experimental. #pragma warning disable ASPIRETERMINAL002 namespace Terminals.AppHost; /// -/// Commands that exercise — an interaction input whose process is owned by the +/// Commands that exercise — a dialog whose terminal is owned by the /// AppHost itself rather than orchestrated by Aspire. /// /// @@ -64,18 +61,10 @@ public static IResourceBuilder WithAppHostShellCommand(this IResourceBuild terminal.Start(); - var result = await interactionService.PromptInputsAsync( - "AppHost shell", - "This shell is a child process of the AppHost. Closing the dialog terminates it.", - [ - new InteractionInput - { - Name = "shell", - Label = "Shell", - InputType = InputType.Terminal, - Terminal = terminal - } - ], + var result = await interactionService.PromptTerminalAsync( + "This shell is a child process of the AppHost. Cancel when finished; this command then disposes the terminal.", + terminal, + new TerminalInteractionOptions { Title = "AppHost shell", PrimaryButtonText = "Cancel" }, cancellationToken: commandContext.CancellationToken); return result.Canceled @@ -151,18 +140,10 @@ private static async Task ExecIntoContainerAsync( terminal.Start(); - var result = await interactionService.PromptInputsAsync( - title, + var result = await interactionService.PromptTerminalAsync( message, - [ - new InteractionInput - { - Name = "shell", - Label = "Container shell", - InputType = InputType.Terminal, - Terminal = terminal - } - ], + terminal, + new TerminalInteractionOptions { Title = title, PrimaryButtonText = "Cancel" }, cancellationToken: commandContext.CancellationToken); return result.Canceled @@ -174,7 +155,7 @@ private static async Task ExecIntoContainerAsync( /// Adds a command that opens a dock terminal shelled into this container and drives it with the automation API. /// /// - /// This is the counterpart to the interaction-input commands above. Instead of a modal dialog bound to a single + /// This is the counterpart to the terminal interaction commands above. Instead of a modal dialog bound to a single /// dialog lifetime, the terminal becomes a tab in the dashboard's terminal dock (Shift+`) that outlives the command /// that created it. It also exercises AspireTerminal's automation surface — send input, wait for output, /// read the screen — which is how AppHost code can script a terminal it owns. @@ -289,7 +270,7 @@ public static IResourceBuilder WithCommandPromptDockCommand(t /// /// This is the "automate an interactive prompt" scenario. Plenty of tools an AppHost needs to invoke are only /// available as interactive console programs — they log in, prompt for confirmation, ask which subscription to - /// use — and there is no API to call instead. An input plus + /// use — and there is no API to call instead. A dialog plus /// 's automation members lets AppHost code answer those prompts itself while the /// human watches it happen, and step in whenever it cannot. /// @@ -346,41 +327,34 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde // already compiling the script while the dialog is being raised. terminal.Start(); - using var gameCts = CancellationTokenSource.CreateLinkedTokenSource(commandContext.CancellationToken); - - // Raise the dialog before playing so a browser can attach while the opening moves are still being - // made — otherwise the human joins after the game is already won. - var dialogTask = interactionService.PromptInputsAsync( - "Number guess", - $"Guessing a number between 1 and {limit}. Every keystroke below is being typed by the AppHost.", - [ - new InteractionInput - { - Name = "game", - Label = "Number guess", - InputType = InputType.Terminal, - Terminal = terminal - } - ], - cancellationToken: gameCts.Token); - - var playTask = PlayNumberGuessAsync(terminal, limit, gameCts.Token); - - // The dialog only borrows the terminal. Cancel and join automation before leaving this scope, - // where the caller-owned terminal is disposed, and observe failures even after the dialog closes. - var dialogClosed = await Task.WhenAny(dialogTask, playTask).ConfigureAwait(false) == dialogTask; - if (dialogClosed) - { - await gameCts.CancelAsync(); - } - - int number; - int attempts; + var number = 0; + var attempts = 0; try { - (number, attempts) = await playTask; + // Work begins after the dialog is published and is joined before the caller disposes the + // terminal. Both the cancel button and command cancellation reach all automation calls. + var result = await interactionService.PromptTerminalAsync( + $"Guessing a number between 1 and {limit}. Every keystroke below is being typed by the AppHost.", + terminal, + new TerminalInteractionOptions + { + Title = "Number guess", + PrimaryButtonText = "Cancel", + Work = async context => + { + (number, attempts) = await PlayNumberGuessAsync(terminal, limit, context.CancellationToken); + // Leave the winning line visible before successful work completion closes the dialog. + await Task.Delay(TimeSpan.FromSeconds(2), context.CancellationToken); + } + }, + commandContext.CancellationToken); + + if (result.Canceled) + { + return CommandResults.Failure("Canceled"); + } } - catch (OperationCanceledException) when (gameCts.IsCancellationRequested) + catch (OperationCanceledException) when (commandContext.CancellationToken.IsCancellationRequested) { return CommandResults.Failure("Canceled"); } @@ -393,24 +367,9 @@ public static IResourceBuilder WithNumberGuessCommand(this IResourceBuilde .CreateLogger(nameof(TerminalInteractionCommands)) .LogError(ex, "The number guess automation failed unexpectedly."); - await gameCts.CancelAsync(); return CommandResults.Failure(ex.Message); } - if (dialogClosed) - { - return CommandResults.Failure("Canceled"); - } - - // Leave the winning line on screen long enough to read before the dialog disappears. - await Task.Delay(TimeSpan.FromSeconds(2), commandContext.CancellationToken); - - // Cancelling the token the prompt was started with is how code dismisses its own dialog, so the - // result replaces the terminal rather than stacking on top of it. The terminal itself is disposed by - // the `await using` above, once the answer has been shown. - await gameCts.CancelAsync(); - await dialogTask; - await interactionService.PromptMessageBoxAsync( "Number guess", $"Found it. The number was {number}, in {attempts} {(attempts == 1 ? "guess" : "guesses")}.", diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor index ca2c5a0ac2e..409eaff72c0 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor @@ -231,31 +231,6 @@
break; - case InputType.Terminal: - @* The terminal's process is owned by the AppHost, not by an orchestrated resource, so the - * session is tunneled over the dashboard gRPC connection instead of the terminal host UDS. - * - * Rendered chromeless: the dialog already supplies the label and framing, so the terminal's - * own titlebar and frame would be a second border around a control that is meant to line up - * with the dialog's other inputs. Chromeless also makes this a fit-to-pane surface that - * claims the HMP1 primary role on attach, so the grid fills the container as soon as the - * dialog opens rather than staying locked to the producer's grid until the user types. The - * footer is kept so the terminal can still be zoomed, minus the fixed-resolution picker — - * the container is a fixed box, so there is nothing for a chosen resolution to act on. *@ - var terminalId = localItem.ElementId; - -
- -
-
- break; default: @* Ignore unexpected InputTypes *@ break; diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs index 778641e866e..b9f2f63bc6c 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs @@ -299,16 +299,6 @@ private async Task ToggleSecretTextVisibilityAsync(InputViewModel inputModel) } } - /// - /// Builds the WebSocket endpoint that a terminal-typed input's TerminalView connects to. The query string - /// carries the terminal's opaque ID, which the dashboard forwards in an AttachTerminal gRPC call to - /// resolve the existing terminal in the AppHost's registry. - /// - private static string BuildInteractionTerminalEndpoint(InputViewModel inputModel) - { - return $"api/apphost-terminal?terminalId={Uri.EscapeDataString(inputModel.Input.TerminalId ?? string.Empty)}"; - } - private static Icon GetSecretTextIcon(InputViewModel inputModel) { return inputModel.IsSecretTextVisible diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css index 48043e5d0af..781c9ae7f74 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css @@ -56,21 +56,3 @@ .interaction-input-dialog .interaction-input ::deep fluent-text-input::part(end) { margin-inline-end: 0; } - -/* The Hex1b web terminal measures its container, so it needs an explicit, nonzero height. Its container also - opts out of the 75%/500px width cap that keeps ordinary form fields from stretching across the dialog. */ -.interaction-input-dialog .interaction-input ::deep .interaction-terminal-container { - width: 100%; - max-width: none; - height: 420px; - /* Flex children default to align-items: center from .input-line-container, which would collapse the terminal - to its content height. */ - align-self: stretch; - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - /* The terminal is rendered chromeless, so without this it would be an unbounded dark rectangle bleeding into - the dialog. Matches the stroke the dialog's text inputs use, so the terminal reads as another field. */ - border: var(--strokeWidthThin) solid var(--colorNeutralStroke1); - border-radius: var(--aspire-control-radius); -} diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor new file mode 100644 index 00000000000..2ec8ddbd38b --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor @@ -0,0 +1,25 @@ +@implements IDisposable +@using Aspire.Dashboard.Model.Interaction + + + +
+ @((MarkupString)Content.Message) +
+
+ +
+
+ + + @if (!string.IsNullOrEmpty(Dialog.Options.Footer.PrimaryAction.Label)) + { + + @Dialog.Options.Footer.PrimaryAction.Label + + } + +
diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor.cs new file mode 100644 index 00000000000..47d48dc3f3f --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model.Interaction; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Dialogs; + +public partial class InteractionsTerminalDialog +{ + private InteractionsTerminalDialogViewModel? _content; + + [Parameter] + public InteractionsTerminalDialogViewModel Content { get; set; } = default!; + + [CascadingParameter] + public IDialogInstance Dialog { get; set; } = default!; + + // Keep this relative to the dashboard base URI, not the current page. Terminal IDs are opaque and can contain + // query/path delimiters (for example "terminal #1/?%+"), so encode the entire ID as a single query value. + private string EndpointPathAndQuery => $"api/apphost-terminal?terminalId={Uri.EscapeDataString(Content.TerminalId)}"; + + protected override void OnParametersSet() + { + if (_content != Content) + { + _content?.OnInteractionUpdated = null; + _content = Content; + _content.OnInteractionUpdated = () => InvokeAsync(StateHasChanged); + } + } + + private Task CancelAsync() => Dialog.CloseAsync(DialogResult.Ok("cancel")); + + public void Dispose() + { + _content?.OnInteractionUpdated = null; + } +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor.css new file mode 100644 index 00000000000..f661d1594cd --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsTerminalDialog.razor.css @@ -0,0 +1,14 @@ +.interaction-message { + margin-bottom: 1rem; +} + +/* The terminal measures its container; an explicit height is needed even before it has any output. + Chromeless presentation keeps the dialog's title and the terminal's footer without a second title bar. */ +.interaction-terminal-container { + width: 100%; + height: min(420px, 60vh); + min-width: 0; + overflow: hidden; + border: var(--strokeWidthThin) solid var(--colorNeutralStroke1); + border-radius: var(--aspire-control-radius); +} diff --git a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs index a0cc8113d1d..3c65292fae8 100644 --- a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs +++ b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs @@ -31,6 +31,8 @@ public void Dispose() } internal record InteractionDialogReference(int InteractionId, DashboardDialogReference Dialog, ComponentTelemetryContext TelemetryContext) : IDisposable { + public bool CompletedByServer { get; set; } + public void Dispose() { TelemetryContext.Dispose(); @@ -200,7 +202,7 @@ private async Task InteractionsDisplayAsync() dialogComponentId = TelemetryComponentIds.InteractionMessageBox; openDialog = dialogService => dialogService.ShowDialogAsync(content, dialogParameters); } - else if (item.InputsDialog is { } inputs) + else if (item.InputsDialog is not null) { var vm = new InteractionsInputsDialogViewModel { @@ -222,11 +224,7 @@ private async Task InteractionsDisplayAsync() var dialogParameters = CreateDialogParameters(item, intent: null); dialogParameters.Id = "interactions-input-dialog"; - // Terminals need far more room than form fields: at the default 650px cap a terminal fits roughly - // 80 columns, so anything wider than that gets reflowed. Give terminal dialogs the full desktop - // width budget instead. - var hasTerminalInput = inputs.InputItems.Any(i => i.InputType == InputType.Terminal); - dialogParameters.Width = hasTerminalInput ? "75vw" : "min(650px, 75vw)"; + dialogParameters.Width = "min(650px, 75vw)"; dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => { // Only send notification of completion if the dialog was cancelled. @@ -246,10 +244,11 @@ private async Task InteractionsDisplayAsync() dialogComponentId = TelemetryComponentIds.InteractionInputsDialog; openDialog = dialogService => dialogService.ShowDialogAsync(vm, dialogParameters); } - else if (item.PromptProgress is { } promptProgress) + else if (item.PromptProgress is not null || item.PromptTerminal is not null) { var dialogParameters = CreateDialogParameters(item, intent: null); - dialogParameters.Width = "500px"; + // A terminal needs more horizontal space than the progress indicator or ordinary form fields. + dialogParameters.Width = item.PromptTerminal is not null ? "75vw" : "500px"; dialogParameters.ShowDismiss = false; dialogParameters.SecondaryAction = null; @@ -263,6 +262,13 @@ private async Task InteractionsDisplayAsync() dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => { // When the user clicks the cancel button, notify the server. + // Server completion also closes the view, but must not echo cancellation back to the AppHost. + if (_cts.IsCancellationRequested || + (_interactionDialogReference is { CompletedByServer: true } reference && reference.InteractionId == item.InteractionId)) + { + return; + } + var request = new WatchInteractionsRequestUpdate { InteractionId = item.InteractionId @@ -272,22 +278,44 @@ private async Task InteractionsDisplayAsync() { request.Complete = new InteractionComplete(); } + else if (item.PromptTerminal is { } terminal) + { + request.PromptTerminal = new InteractionPromptTerminal + { + TerminalId = terminal.TerminalId, + Result = false + }; + } else { - promptProgress.Result = false; - request.PromptProgress = promptProgress; + request.PromptProgress = new InteractionPromptProgress { Result = false }; } await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); }); - var vm = new InteractionsProgressDialogViewModel + if (item.PromptTerminal is { } promptTerminal) { - Message = GetMessageHtml(item) - }; + dialogParameters.Id = "interactions-terminal-dialog"; + var vm = new InteractionsTerminalDialogViewModel + { + TerminalId = promptTerminal.TerminalId, + Message = GetMessageHtml(item) + }; - dialogComponentId = TelemetryComponentIds.InteractionProgressDialog; - openDialog = dialogService => dialogService.ShowDialogAsync(vm, dialogParameters); + dialogComponentId = TelemetryComponentIds.InteractionTerminalDialog; + openDialog = dialogService => dialogService.ShowDialogAsync(vm, dialogParameters); + } + else + { + var vm = new InteractionsProgressDialogViewModel + { + Message = GetMessageHtml(item) + }; + + dialogComponentId = TelemetryComponentIds.InteractionProgressDialog; + openDialog = dialogService => dialogService.ShowDialogAsync(vm, dialogParameters); + } } else { @@ -368,14 +396,24 @@ private async Task WatchInteractionsAsync() case WatchInteractionsResponseUpdate.KindOneofCase.MessageBox: case WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog: case WatchInteractionsResponseUpdate.KindOneofCase.PromptProgress: + case WatchInteractionsResponseUpdate.KindOneofCase.PromptTerminal: if (_interactionDialogReference != null && - _interactionDialogReference.InteractionId == item.InteractionId && - _interactionDialogReference.Dialog.Instance is { } dialogInstance && - dialogInstance.Options.Parameters.TryGetValue("Content", out var content) && - content is InteractionsInputsDialogViewModel inputsVM) + _interactionDialogReference.InteractionId == item.InteractionId) { - // If the dialog is already open for this interaction, update it with the new data. - await inputsVM.UpdateInteractionAsync(item); + // Reconnection replays pending interactions. Update the open view instead of queuing + // another copy, even for dialogs without mutable content. + if (_interactionDialogReference.Dialog.Instance is { } dialogInstance && + dialogInstance.Options.Parameters.TryGetValue("Content", out var content)) + { + if (content is InteractionsInputsDialogViewModel inputsVM) + { + await inputsVM.UpdateInteractionAsync(item); + } + else if (content is InteractionsTerminalDialogViewModel terminalVM) + { + await terminalVM.UpdateMessageAsync(GetMessageHtml(item)); + } + } } else { @@ -447,6 +485,7 @@ await InvokeAsync(async () => // Close the interaction's dialog if it is open. if (_interactionDialogReference?.InteractionId == item.InteractionId) { + _interactionDialogReference.CompletedByServer = true; try { await InvokeAsync(_interactionDialogReference.Dialog.CloseAsync); @@ -610,6 +649,12 @@ public async ValueTask DisposeAsync() await TaskHelpers.WaitIgnoreCancelAsync(_dialogDisplayTask); await TaskHelpers.WaitIgnoreCancelAsync(_watchInteractionsTask); + + _interactionDialogReference?.Dispose(); + foreach (var messageBar in _openMessageBars) + { + messageBar.Dispose(); + } } private class KeyedInteractionCollection : KeyedCollection diff --git a/src/Aspire.Dashboard/Model/Interaction/InteractionsTerminalDialogViewModel.cs b/src/Aspire.Dashboard/Model/Interaction/InteractionsTerminalDialogViewModel.cs new file mode 100644 index 00000000000..9a2829a009e --- /dev/null +++ b/src/Aspire.Dashboard/Model/Interaction/InteractionsTerminalDialogViewModel.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model.Interaction; + +public sealed class InteractionsTerminalDialogViewModel +{ + public required string TerminalId { get; init; } + public required string Message { get; set; } + public Func? OnInteractionUpdated { get; set; } + + internal async Task UpdateMessageAsync(string message) + { + Message = message; + if (OnInteractionUpdated is not null) + { + await OnInteractionUpdated().ConfigureAwait(false); + } + } +} diff --git a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs index 93e956c13b2..567632db9ce 100644 --- a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs @@ -70,7 +70,7 @@ public interface IDashboardClient : IResourceRepository, IAsyncDisposable /// Opens a duplex byte stream to an AppHost-owned terminal. ///
/// - /// Used by terminal interaction inputs, docked terminals, and detached terminal windows. + /// Used by terminal interactions, docked terminals, and detached terminal windows. /// The returned stream carries HMP1 frames between the dashboard and the AppHost. The dashboard's terminal /// replica bridges this stream to the browser's HWT1 WebSocket connection. /// diff --git a/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs b/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs index f81707408fb..5afc2cb298b 100644 --- a/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs +++ b/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs @@ -24,6 +24,7 @@ public static class TelemetryComponentIds public const string InteractionMessageBar = nameof(InteractionMessageBar); public const string InteractionInputsDialog = nameof(InteractionInputsDialog); public const string InteractionProgressDialog = nameof(InteractionProgressDialog); + public const string InteractionTerminalDialog = nameof(InteractionTerminalDialog); public const string TerminalDock = nameof(TerminalDock); public const string GenAIVisualizerDialog = nameof(GenAIVisualizerDialog); } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index 8ac0851a4d0..ce93f1661f8 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs @@ -9,7 +9,6 @@ namespace Aspire.Hosting.ApplicationModel; #pragma warning disable ASPIREINTERACTION001 // PromptProgressAsync and related types are experimental. -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. /// /// A service to execute resource commands. @@ -805,9 +804,6 @@ private static InteractionInput CloneInput(InteractionInput input, string? value Description = input.Description, EnableDescriptionMarkdown = input.EnableDescriptionMarkdown, InputType = input.InputType, - // Input state belongs to this invocation, but the terminal is borrowed from its caller and may be - // intentionally reused across interactions. Preserve its identity without creating or owning a process. - Terminal = input.Terminal, Required = input.Required, Options = input.Options, DynamicLoading = input.DynamicLoading, diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 0d2d810fb48..4699fbe4c88 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -161,6 +161,10 @@ async Task WatchInteractionsInternal(CancellationToken cancellationToken) { change.PromptProgress = new InteractionPromptProgress(); } + else if (interaction.InteractionInfo is TerminalInteractionInfo terminal) + { + change.PromptTerminal = new InteractionPromptTerminal { TerminalId = terminal.TerminalId }; + } await responseStream.WriteAsync(change, cts.Token).ConfigureAwait(false); } @@ -272,10 +276,6 @@ internal static Aspire.DashboardService.Proto.V1.InteractionInput CreateInteract { dto.FileFilter = input.FileFilter; } - if (!string.IsNullOrEmpty(input.TerminalId)) - { - dto.TerminalId = input.TerminalId; - } dto.ValidationErrors.AddRange(input.ValidationErrors); return dto; } @@ -290,7 +290,6 @@ internal static Aspire.DashboardService.Proto.V1.InputType MapInputType(Aspire.H Aspire.Hosting.InputType.Boolean => Aspire.DashboardService.Proto.V1.InputType.Boolean, Aspire.Hosting.InputType.Number => Aspire.DashboardService.Proto.V1.InputType.Number, Aspire.Hosting.InputType.File => Aspire.DashboardService.Proto.V1.InputType.File, - Aspire.Hosting.InputType.Terminal => Aspire.DashboardService.Proto.V1.InputType.Terminal, _ => throw new InvalidOperationException($"Unexpected input type: {inputType}"), }; } @@ -305,7 +304,6 @@ public static Aspire.Hosting.InputType MapInputType(Aspire.DashboardService.Prot Aspire.DashboardService.Proto.V1.InputType.Boolean => InputType.Boolean, Aspire.DashboardService.Proto.V1.InputType.Number => InputType.Number, Aspire.DashboardService.Proto.V1.InputType.File => InputType.File, - Aspire.DashboardService.Proto.V1.InputType.Terminal => InputType.Terminal, _ => throw new InvalidOperationException($"Unexpected input type: {inputType}"), }; } diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index 2db1d49592b..2709a7662f6 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -209,6 +209,13 @@ await _interactionService.ProcessInteractionFromClientAsync( return new InteractionCompletionState { Complete = true, State = request.Notification.Result }; case WatchInteractionsRequestUpdate.KindOneofCase.PromptProgress: return new InteractionCompletionState { Complete = true, State = request.PromptProgress.Result }; + case WatchInteractionsRequestUpdate.KindOneofCase.PromptTerminal: + if (interaction.InteractionInfo is not Interaction.TerminalInteractionInfo terminal || + !string.Equals(terminal.TerminalId, request.PromptTerminal.TerminalId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The terminal response must match the interaction's terminal."); + } + return new InteractionCompletionState { Complete = true, State = request.PromptTerminal.Result }; case WatchInteractionsRequestUpdate.KindOneofCase.InputsDialog: var inputsInfo = (Interaction.InputsInteractionInfo)interaction.InteractionInfo; var options = (InputsDialogInteractionOptions)interaction.Options; @@ -388,4 +395,3 @@ internal enum ExecuteCommandResultType Failure, Canceled } - diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 8431454ead8..2e5886b2786 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -368,6 +368,7 @@ message WatchInteractionsRequestUpdate { InteractionInputsDialog inputs_dialog = 4; InteractionNotification notification = 5; InteractionPromptProgress prompt_progress = 7; + InteractionPromptTerminal prompt_terminal = 8; } // A flag indicating that the message is an update and shouldn't complete the interaction. @@ -393,6 +394,7 @@ message WatchInteractionsResponseUpdate { InteractionInputsDialog inputs_dialog = 18; InteractionNotification notification = 19; InteractionPromptProgress prompt_progress = 20; + InteractionPromptTerminal prompt_terminal = 21; } } // Represents the completion of an interaction. @@ -414,12 +416,21 @@ message InteractionNotification { message InteractionPromptProgress { optional bool result = 1; } +// A modal view onto a caller-owned terminal. Work completion or cancellation closes the interaction, +// independently of the terminal process lifetime. +message InteractionPromptTerminal { + string terminal_id = 1; + optional bool result = 2; +} // Represents a dialog that collects inputs from the user. message InteractionInputsDialog { repeated InteractionInput input_items = 1; } // Represents an input item in an interaction dialog. message InteractionInput { + reserved 19; + reserved "terminal_id"; + string label = 1; string placeholder = 2; InputType input_type = 3; @@ -438,9 +449,6 @@ message InteractionInput { int64 max_file_size = 16; bool allow_multiple_files = 17; string file_filter = 18; - // Identifies the AppHost-owned terminal backing an INPUT_TYPE_TERMINAL input. The dashboard passes this to - // /terminal/attach to open the tunnel. - string terminal_id = 19; } enum MessageIntent { MESSAGE_INTENT_NONE = 0; @@ -451,6 +459,9 @@ enum MessageIntent { MESSAGE_INTENT_CONFIRMATION = 5; } enum InputType { + reserved 7; + reserved "INPUT_TYPE_TERMINAL"; + INPUT_TYPE_UNSPECIFIED = 0; INPUT_TYPE_TEXT = 1; INPUT_TYPE_SECRET_TEXT = 2; @@ -458,7 +469,6 @@ enum InputType { INPUT_TYPE_BOOLEAN = 4; INPUT_TYPE_NUMBER = 5; INPUT_TYPE_FILE = 6; - INPUT_TYPE_TERMINAL = 7; } //////////////////////////////////////////// @@ -490,7 +500,7 @@ message UploadFileResponse { message TerminalClientFrame { // Formerly interaction_id/input_name. Terminals are now addressed by an opaque id // issued by the AppHost's TerminalService, which lets one tunnel serve both - // interaction-input terminals and dashboard terminal dock tabs. + // terminal interactions and dashboard terminal dock tabs. reserved 1, 2; reserved "interaction_id", "input_name"; diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index 89d1967039d..0738640a13b 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -124,6 +124,68 @@ public interface IInteractionService /// [Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] Task> PromptProgressAsync(string message, ProgressInteractionOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// Displays a caller-owned terminal in a dialog while optional work runs. + /// + /// The message to display above the terminal. + /// The exact terminal instance registered with this AppHost, with placement. + /// Optional title, cancel button text, message formatting, and work callback. + /// A token to cancel the interaction and signal cancellation to the work callback. + /// + /// An containing true when work or an explicit completion succeeds, + /// or a canceled result when the user or caller cancels the interaction. + /// + /// or is . + /// + /// The interaction service is unavailable, the terminal is not registered with this AppHost, or its placement is not . + /// + /// was canceled before the interaction began. + /// + /// + /// Create and start the terminal before prompting, and dispose it when the caller is finished with it. + /// The interaction borrows the terminal: completion, cancellation, and viewer disconnection do not stop or + /// dispose it. The same terminal can be reused across prompts. The terminal process exiting does not close + /// the dialog. + /// + /// + /// When is supplied, successful completion closes the dialog. + /// The optional labels a cancel button, not a submit button. + /// User or external cancellation closes the dialog and signals ; + /// this method waits for the callback to finish before returning. Non-cancellation callback exceptions are + /// propagated after the interaction is removed. Without a callback, the dialog waits for explicit completion, + /// the cancel button, or . + /// + /// + /// Secondary and dismiss buttons are not shown. Disconnecting a viewer does not complete the interaction; + /// reconnecting can display the pending dialog again. + /// + /// + /// + /// + /// await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + /// { + /// Title = "Setup", + /// Executable = "./setup.sh", + /// Placement = TerminalPlacement.Dialog + /// }); + /// terminal.Start(); + /// var result = await interactionService.PromptTerminalAsync("Running setup.", terminal, + /// new TerminalInteractionOptions + /// { + /// Title = "Setup", + /// PrimaryButtonText = "Cancel", + /// Work = async context => + /// { + /// await terminal.WaitForTextAsync("Continue? ", cancellationToken: context.CancellationToken); + /// await terminal.SendTextAsync("y\r", context.CancellationToken); + /// await terminal.WaitForTextAsync("Setup complete", cancellationToken: context.CancellationToken); + /// } + /// }, cancellationToken); + /// + /// + [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] + Task> PromptTerminalAsync(string message, AspireTerminal terminal, TerminalInteractionOptions? options = null, CancellationToken cancellationToken = default); } internal record QueueLoadOptions( @@ -339,9 +401,6 @@ public required string Name /// /// Gets or sets a value indicating whether the input is required. /// - /// - /// Must be for inputs because they do not produce a value. - /// public bool Required { get => _required; @@ -467,71 +526,6 @@ public long? MaxFileSize /// [AspireExportIgnore(Reason = "InteractionFileCollection owns server-local files and implements IDisposable, which is not ATS-compatible.")] public InteractionFileCollection GetFiles() => _files; - - /// - /// Gets the terminal to display for an input. Ignored by every other input type. - /// - /// - /// - /// The terminal is created and owned by the caller, not by the interaction. Create it with - /// TerminalService.CreateTerminal passing , hand it to the input, - /// and dispose it when the caller is finished with it. The dialog is a view onto the terminal; closing the dialog - /// stops showing it but does not stop the workload. - /// - /// - /// The supplied instance must still be registered with the resolved from the - /// current AppHost's service provider. Disposed handles and handles from another AppHost are rejected - /// before the dialog is shown. - /// - /// - /// Owning the terminal outside the interaction is what lets the AppHost script it through - /// 's automation members — before the dialog is raised, while it is open, and after - /// it closes — and lets the same terminal be shown by more than one dialog over its life. - /// - /// - /// - /// await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions - /// { - /// Title = "Setup", - /// Executable = "./setup.sh", - /// Placement = TerminalPlacement.Dialog - /// }); - /// - /// var dialog = interactionService.PromptInputsAsync( - /// "Setup", - /// "Running setup.", - /// [new InteractionInput { Name = "setup", InputType = InputType.Terminal, Terminal = terminal }], - /// cancellationToken: cts.Token); - /// - /// await terminal.WaitForTextAsync("Continue? "); - /// await terminal.SendTextAsync("y\r"); - /// - /// // Dismiss the dialog from code once the automation is done. - /// await cts.CancelAsync(); - /// - /// - /// - /// The terminal's must be . A dock - /// terminal is presented as a dock tab that outlives the code which created it, so showing one in a dialog would - /// render the same terminal through two competing presentations. - /// - /// - /// The workload starts lazily on the first attach or the first automation call, so a terminal created for a dialog - /// that is dismissed without ever being opened never spawns a process. - /// - /// - [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] - [AspireExportIgnore(Reason = "A terminal is a live local process attached to the AppHost; it cannot be serialized to polyglot app hosts.")] - public AspireTerminal? Terminal { get; init; } - - /// - /// Identifies the AppHost-owned terminal created for this input. Stamped by the interaction service when the - /// dialog is raised and sent to the dashboard so it can open the tunnel. - /// - /// - /// Deliberately internal: this is transport addressing, not something an AppHost author sets. - /// - internal string? TerminalId { get; set; } } /// @@ -872,15 +866,7 @@ public enum InputType /// /// A file input. Allows the user to select a file using the OS/browser file picker. /// - File, - /// - /// An interactive terminal. Renders a terminal that is attached to a session owned by the AppHost. - /// - /// - /// This input type is experimental. The terminal is created and owned by the caller and supplied through - /// ; the dialog is only a view onto it. - /// - Terminal + File } /// @@ -1025,6 +1011,50 @@ public sealed class ProgressContext public required CancellationToken CancellationToken { get; init; } } +/// +/// Options for displaying a caller-owned terminal in an interaction dialog. +/// +/// +/// Set to show a cancel button; by default there is no button. +/// Secondary and dismiss buttons are not shown. The terminal's lifetime is independent of these options. +/// +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public class TerminalInteractionOptions : InteractionOptions +{ + /// + /// Gets or sets the optional dialog title. No title is displayed by default. + /// + public string? Title { get; set; } + + /// + /// Gets or sets optional asynchronous work to run while the terminal dialog is displayed. + /// + /// + /// Successful completion closes the dialog. User or external cancellation signals + /// and the prompt waits for the callback to finish. + /// Exceptions propagate from after the dialog is removed. + /// Without work, the dialog waits for explicit completion or cancellation, not for terminal process exit. + /// Neither work completion nor cancellation disposes the caller-owned terminal. + /// + public Func? Work { get; set; } +} + +/// +/// Provides cancellation to the work callback of a terminal interaction. +/// +[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +public sealed class TerminalContext +{ + /// + /// Gets the token signaled when the user requests cancellation or the interaction is externally canceled. + /// + /// + /// Observe this token in automation calls and other asynchronous work. Cancellation does not stop the terminal; + /// its caller remains responsible for disposing it when no longer needed. + /// + public required CancellationToken CancellationToken { get; init; } +} + /// /// Specifies the intent or purpose of a message in an interaction. /// diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index d32c053e42c..df370105516 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -129,7 +129,7 @@ private async Task> PromptMessageBoxCoreAsync(string tit var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); var promptState = completion.State as bool?; - return promptState == null + return promptState is null ? InteractionResult.Cancel() : InteractionResult.Ok(promptState.Value); } @@ -164,45 +164,11 @@ public async Task> PromptInputsAsy // Create the collection early to validate names and generate missing ones var inputCollection = new InteractionInputCollection(inputs); var hasFileInputs = inputs.Any(input => input.InputType == InputType.File); - var hasTerminalInputs = inputs.Any(input => input.InputType == InputType.Terminal); // Validate inputs. for (var i = 0; i < inputs.Count; i++) { var input = inputs[i]; - if (input.InputType == InputType.Terminal) - { - // The input only borrows a terminal for presentation. Its caller-owned lifetime is independent - // of the dialog, so reusing the same terminal in later interactions is valid. - if (input.Required) - { - throw new InvalidOperationException($"The input '{input.Name}' has {nameof(InteractionInput.Required)} set to true, but {nameof(InputType.Terminal)} inputs do not produce a value and cannot be required."); - } - - if (input.Terminal is null) - { - throw new InvalidOperationException($"The input '{input.Name}' is a {nameof(InputType.Terminal)} input, so {nameof(InteractionInput.Terminal)} must be set to a terminal created by the caller."); - } - - // A dock terminal is presented as a dock tab that outlives the code which created it. Showing one in a - // dialog as well would render the same terminal through two competing presentations. - if (input.Terminal.Placement != TerminalPlacement.Dialog) - { - throw new InvalidOperationException($"The input '{input.Name}' sets {nameof(InteractionInput.Terminal)} to a terminal whose {nameof(AspireTerminal.Placement)} is {input.Terminal.Placement}. Terminals shown by an interaction must be created with {nameof(TerminalPlacement)}.{nameof(TerminalPlacement.Dialog)}."); - } - - // The dashboard resolves IDs in this AppHost's registry rather than using the supplied object. - // Require identity as well as registration; dialog placement above excludes resource-owned handles. - // Resolve the service only here so ordinary prompts do not require terminal infrastructure. - // Callers can still dispose after this check, so attachment must continue to validate availability. - if (_serviceProvider.GetService() is not { } terminalService || - !terminalService.TryGetTerminal(input.Terminal.Id, out var registeredTerminal) || - !ReferenceEquals(input.Terminal, registeredTerminal)) - { - throw new InvalidOperationException($"The input '{input.Name}' must reference the terminal instance registered with this AppHost's {nameof(TerminalService)}."); - } - } - if (input.DynamicLoading is { } dynamic) { if (dynamic.DependsOnInputs != null) @@ -239,18 +205,6 @@ public async Task> PromptInputsAsy .ToArray(); _fileUploadStore.StartInteraction(newState.InteractionId, fileInputs); } - if (hasTerminalInputs) - { - // The dashboard addresses a terminal by id, so carry the caller's terminal id on the input. The - // terminal is neither created nor disposed here: the caller owns it. - foreach (var input in inputs) - { - if (input.InputType == InputType.Terminal) - { - input.TerminalId = input.Terminal!.Id; - } - } - } AddInteractionUpdate(newState); using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState); @@ -339,6 +293,47 @@ public async Task> PromptNotificationAsync(string title, } public async Task> PromptProgressAsync(string message, ProgressInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + options ??= ProgressInteractionOptions.CreateDefault(); + return await PromptWorkAsync( + options.Title ?? string.Empty, message, options, new Interaction.ProgressInteractionInfo(), + options.Work is { } work ? token => work(new ProgressContext { CancellationToken = token }) : null, + cancellationToken).ConfigureAwait(false); + } + + public async Task> PromptTerminalAsync(string message, AspireTerminal terminal, TerminalInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + ArgumentNullException.ThrowIfNull(terminal); + EnsureServiceAvailable(); + cancellationToken.ThrowIfCancellationRequested(); + + // A dock terminal already has a presentation that outlives a prompt. Only a dialog terminal can be borrowed. + if (terminal.Placement != TerminalPlacement.Dialog) + { + throw new InvalidOperationException($"Terminals shown by an interaction must be created with {nameof(TerminalPlacement)}.{nameof(TerminalPlacement.Dialog)}; the supplied terminal has placement {terminal.Placement}."); + } + + // The dashboard resolves IDs in this AppHost's registry rather than using the supplied object. + // Require reference identity as well as registration and resolve the service only for terminal prompts. + // Callers can still dispose after this check, so attachment must continue validating availability. + if (_serviceProvider.GetService() is not { } terminalService || + !terminalService.TryGetTerminal(terminal.Id, out var registeredTerminal) || + !ReferenceEquals(terminal, registeredTerminal)) + { + throw new InvalidOperationException($"The terminal must be the instance registered with this AppHost's {nameof(TerminalService)}."); + } + + options ??= new TerminalInteractionOptions(); + return await PromptWorkAsync( + options.Title ?? string.Empty, message, options, new Interaction.TerminalInteractionInfo(terminal.Id), + options.Work is { } work ? token => work(new TerminalContext { CancellationToken = token }) : null, + cancellationToken).ConfigureAwait(false); + } + + private async Task> PromptWorkAsync( + string title, string message, InteractionOptions options, Interaction.InteractionInfoBase interactionInfo, + Func? work, CancellationToken cancellationToken) { EnsureServiceAvailable(); @@ -347,14 +342,12 @@ public async Task> PromptProgressAsync(string message, P try { - options ??= ProgressInteractionOptions.CreateDefault(); - - var newState = new Interaction(options.Title ?? string.Empty, message, options, new Interaction.ProgressInteractionInfo(), interactionCts.Token); + var newState = new Interaction(title, message, options, interactionInfo, interactionCts.Token); AddInteractionUpdate(newState); using var ctRegistration = cancellationToken.Register(OnInteractionCancellation, state: newState); - if (options.Work is { } work) + if (work is not null) { // When the button is clicked, CompletionTcs fires. Cancel the work's CT so it can stop. // Don't dispose the continuation task — it may not have completed when scope exits @@ -379,36 +372,23 @@ public async Task> PromptProgressAsync(string message, P try { - await work(new ProgressContext { CancellationToken = interactionCts.Token }).ConfigureAwait(false); - - // Work completed successfully. Complete the interaction. - if (!newState.CompletionTcs.TrySetResult(new InteractionCompletionState { Complete = true, State = true })) - { - var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); - return CreateProgressResult(completion); - } - - newState.State = Interaction.InteractionState.Complete; - AddInteractionUpdate(newState); + await work(interactionCts.Token).ConfigureAwait(false); - return InteractionResult.Ok(true); + CompleteWorkInteraction(newState, new InteractionCompletionState { Complete = true, State = true }); + return CreateWorkResult(await newState.CompletionTcs.Task.ConfigureAwait(false)); } catch (OperationCanceledException) when (interactionCts.IsCancellationRequested) { // The work was canceled. Complete the interaction if not already done. - newState.State = Interaction.InteractionState.Complete; - newState.CompletionTcs.TrySetResult(new InteractionCompletionState { Complete = true }); - AddInteractionUpdate(newState); + CompleteWorkInteraction(newState, new InteractionCompletionState { Complete = true }); return InteractionResult.Cancel(); } catch { // If work throws a non-cancellation exception, ensure the interaction is - // completed and removed so the progress dialog doesn't stay open indefinitely. - newState.State = Interaction.InteractionState.Complete; - newState.CompletionTcs.TrySetResult(new InteractionCompletionState { Complete = true }); - AddInteractionUpdate(newState); + // completed and removed so the dialog doesn't stay open indefinitely. + CompleteWorkInteraction(newState, new InteractionCompletionState { Complete = true }); throw; } @@ -419,7 +399,7 @@ public async Task> PromptProgressAsync(string message, P // - The user clicking the button (sends response from dashboard) // - External cancellation via cancellationToken (handled by OnInteractionCancellation registration) var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); - return CreateProgressResult(completion); + return CreateWorkResult(completion); } } finally @@ -428,7 +408,20 @@ public async Task> PromptProgressAsync(string message, P } } - private static InteractionResult CreateProgressResult(InteractionCompletionState completion) + private void CompleteWorkInteraction(Interaction interaction, InteractionCompletionState completion) + { + // Serialize work completion with client/external cancellation so only the winner removes and publishes + // completion. In particular, work that handles cancellation must not overwrite a canceled result. + lock (_onInteractionUpdatedLock) + { + if (_interactionCollection.Contains(interaction.InteractionId)) + { + CompleteInteractionCore(interaction, completion); + } + } + } + + private static InteractionResult CreateWorkResult(InteractionCompletionState completion) { var promptState = completion.State as bool?; @@ -825,4 +818,9 @@ public InputsInteractionInfo(InteractionInputCollection inputs) internal sealed class ProgressInteractionInfo : InteractionInfoBase { } + + internal sealed class TerminalInteractionInfo(string terminalId) : InteractionInfoBase + { + public string TerminalId { get; } = terminalId; + } } diff --git a/src/Aspire.Hosting/Terminals/TerminalPlacement.cs b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs index 9dc8ec6822c..6607a1609c4 100644 --- a/src/Aspire.Hosting/Terminals/TerminalPlacement.cs +++ b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs @@ -22,9 +22,8 @@ public enum TerminalPlacement Dock, /// - /// The terminal belongs to an interaction input and is displayed inside - /// that interaction's dialog. These are addressed directly by the dialog and are deliberately excluded - /// from the dock's tab list. + /// The terminal can be displayed by . + /// It remains caller-owned and is addressed directly by the dialog, not listed in the terminal dock. /// Dialog, diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 5d23febf658..cdc86ff1502 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -19,8 +19,8 @@ namespace Aspire.Hosting.Terminals; /// /// /// -/// Two experiences share this service: terminals belonging to an interaction -/// input, and terminals shown as tabs in the dashboard's terminal dock. They differ only in +/// Two experiences share this service: terminals displayed by +/// and terminals shown as tabs in the dashboard's terminal dock. They differ only in /// ; the lifetime, transport, and automation machinery is identical. /// /// diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs index 0168ad77f21..8aa96584595 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs @@ -1,22 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Dialogs; -using Aspire.Dashboard.Components.Resize; using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Interaction; -using Aspire.Dashboard.Tests; using Aspire.Dashboard.Tests.Shared; using Aspire.DashboardService.Proto.V1; -using Aspire.Tests.Shared; using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.FluentUI.AspNetCore.Components; using Xunit; @@ -25,91 +19,6 @@ namespace Aspire.Dashboard.Components.Tests.Dialogs; [UseCulture("en-US")] public sealed class InteractionsInputDialogTests : DashboardTestContext { - [Theory] - [InlineData("", "terminal", "terminal")] - [InlineData("/aspire/nested", "terminal", "terminal")] - [InlineData("", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] - [InlineData("/aspire/nested", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] - public async Task AppHostTerminalEndpoint_UsesDashboardBaseUri(string pathBase, string terminalId, string escapedTerminalId) - { - Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); - TerminalSetupHelpers.SetupTerminalView(this, pathBase); - var getCut = SetUpDialog(out var dialogService); - Services.GetRequiredService().NavigateTo("consolelogs/resource/other"); - var viewModel = new InteractionsInputsDialogViewModel - { - Interaction = new WatchInteractionsResponseUpdate - { - InteractionId = 1, - InputsDialog = new InteractionInputsDialog - { - InputItems = { new InteractionInput { Name = "shell", InputType = InputType.Terminal, TerminalId = terminalId } } - } - }, - Message = string.Empty, - DashboardClient = new TestDashboardClient(), - OnSubmitCallback = (_, _) => Task.CompletedTask - }; - - await dialogService.ShowDialogAsync(viewModel, new DialogParameters { Title = "Shell" }); - - getCut().WaitForAssertion(() => TerminalSetupHelpers.AssertSingleTerminalConnection(this, - $"wss://dashboard.example{pathBase}/api/apphost-terminal?terminalId={escapedTerminalId}")); - } - - [Theory] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public async Task Render_TerminalRespectsDisabledAndLoading(bool disabled, bool loading) - { - TerminalSetupHelpers.SetupTerminalView(this); - var getCut = SetUpDialog(out var dialogService); - var input = new InteractionInput - { - Name = "shell", - InputType = InputType.Terminal, - TerminalId = "terminal", - Disabled = disabled, - Loading = loading - }; - var viewModel = new InteractionsInputsDialogViewModel - { - Interaction = new WatchInteractionsResponseUpdate - { - InteractionId = 1, - InputsDialog = new InteractionInputsDialog { InputItems = { input } } - }, - Message = string.Empty, - DashboardClient = new TestDashboardClient(), - OnSubmitCallback = (_, _) => Task.CompletedTask - }; - - await dialogService.ShowDialogAsync(viewModel, new DialogParameters { Title = "Shell" }); - var cut = getCut(); - cut.WaitForAssertion(() => Assert.Equal(disabled || loading, cut.FindComponent().Instance.ReadOnly)); - var terminal = cut.FindComponent().Instance; - Assert.True(terminal.AutoFit); - Assert.False(terminal.ShowDimensionsPicker); - - foreach (var state in new (bool Disabled, bool Loading)[] { (false, false), (true, false), (true, true), (false, true), (false, false) }) - { - var update = viewModel.Interaction.Clone(); - update.InputsDialog.InputItems[0].Disabled = state.Disabled; - update.InputsDialog.InputItems[0].Loading = state.Loading; - await cut.InvokeAsync(() => viewModel.UpdateInteractionAsync(update)); - - cut.WaitForAssertion(() => - { - var current = cut.FindComponent().Instance; - Assert.Same(terminal, current); - Assert.Equal(state.Disabled || state.Loading, current.ReadOnly); - Assert.Equal("api/apphost-terminal?terminalId=terminal", current.EndpointPathAndQuery); - }); - } - } - [Theory] [InlineData(InputType.Text, false)] [InlineData(InputType.SecretText, false)] @@ -118,10 +27,8 @@ public async Task Render_TerminalRespectsDisabledAndLoading(bool disabled, bool [InlineData(InputType.Boolean, false)] [InlineData(InputType.Number, false)] [InlineData(InputType.File, false)] - [InlineData(InputType.Terminal, false)] public async Task Render_FieldIds_AreAssociatedUniqueAndStable(InputType inputType, bool allowCustomChoice) { - TerminalSetupHelpers.SetupTerminalView(this); var getCut = SetUpDialog(out var dialogService); var interaction = new WatchInteractionsResponseUpdate { @@ -780,10 +687,8 @@ public async Task Render_MultipleFileSelection_ValidatesMaximumFileCount(int fil private Func SetUpDialog(out DashboardDialogService dialogService) { - FluentUISetupHelpers.SetupDialogInfrastructure(this); FluentUISetupHelpers.SetupFluentInputLabel(this); FluentUISetupHelpers.SetupFluentTextField(this); - FluentUISetupHelpers.SetupFluentButton(this); FluentUISetupHelpers.SetupFluentInputFile(this); FluentUISetupHelpers.SetupFluentList(this); FluentUISetupHelpers.SetupFluentCombobox(this); @@ -791,28 +696,7 @@ private Func SetUpDialog(out DashboardDialogService dialogSer var module = JSInterop.SetupModule("./Components/Dialogs/InteractionsInputDialog.razor.js"); module.SetupVoid("togglePasswordVisibility", _ => true); - IRenderedFragment? cut = null; - TestDialogService? testDialogService = null; - testDialogService = new TestDialogService((content, _) => - { - cut = RenderComponent>(builder => - { - builder.Add(p => p.Value, testDialogService!.LastInstance!); - builder.AddChildContent(childBuilder => - { - childBuilder.Add(p => p.Content, Assert.IsType(content)); - }); - }); - return Task.CompletedTask; - }); - Services.RemoveAll(); - Services.AddSingleton(testDialogService); - - dialogService = new DashboardDialogService( - testDialogService, - new TestStringLocalizer(), - Services.GetRequiredService()); - return () => cut ?? throw new InvalidOperationException("The dialog was not rendered."); + return InteractionsSetupHelpers.SetupDialog(this, p => p.Content, out dialogService); } private static InteractionsInputsDialogViewModel CreateSecretTextViewModel() diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsTerminalDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsTerminalDialogTests.cs new file mode 100644 index 00000000000..d91a43e294a --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsTerminalDialogTests.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Dialogs; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Interaction; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Dialogs; + +[UseCulture("en-US")] +public sealed class InteractionsTerminalDialogTests : DashboardTestContext +{ + [Theory] + [InlineData("", "terminal", "terminal")] + [InlineData("/aspire/nested", "terminal", "terminal")] + [InlineData("", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] + [InlineData("/aspire/nested", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")] + public async Task AppHostTerminalEndpoint_UsesDashboardBaseUri(string pathBase, string terminalId, string escapedTerminalId) + { + Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); + TerminalSetupHelpers.SetupTerminalView(this, pathBase); + var getCut = InteractionsSetupHelpers.SetupDialog(this, p => p.Content, out var dialogService); + Services.GetRequiredService().NavigateTo("consolelogs/resource/other"); + var viewModel = new InteractionsTerminalDialogViewModel { TerminalId = terminalId, Message = "Message" }; + + await dialogService.ShowDialogAsync(viewModel, new DialogParameters { Title = "Shell" }); + + var cut = getCut(); + cut.WaitForAssertion(() => TerminalSetupHelpers.AssertSingleTerminalConnection(this, + $"wss://dashboard.example{pathBase}/api/apphost-terminal?terminalId={escapedTerminalId}")); + var terminal = cut.FindComponent().Instance; + Assert.True(terminal.Chromeless); + Assert.True(terminal.AutoFit); + Assert.False(terminal.ShowDimensionsPicker); + Assert.False(terminal.ReadOnly); + Assert.Empty(cut.FindComponents()); + Assert.Equal("Message", cut.Find(".interaction-message").TextContent.Trim()); + + await cut.InvokeAsync(() => viewModel.UpdateMessageAsync("Updated")); + cut.WaitForAssertion(() => Assert.Equal("Updated", cut.Find(".interaction-message strong").TextContent)); + Assert.Same(terminal, cut.FindComponent().Instance); + TerminalSetupHelpers.AssertSingleTerminalConnection(this, + $"wss://dashboard.example{pathBase}/api/apphost-terminal?terminalId={escapedTerminalId}"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Cancel work")] + public async Task PrimaryAction_IsOptionalAndAlwaysCancels(string? buttonText) + { + TerminalSetupHelpers.SetupTerminalView(this); + var getCut = InteractionsSetupHelpers.SetupDialog(this, p => p.Content, out var dialogService); + var viewModel = new InteractionsTerminalDialogViewModel { TerminalId = "terminal", Message = string.Empty }; + var dialog = await dialogService.ShowDialogAsync(viewModel, + new DialogParameters { PrimaryAction = buttonText, UseCustomFooter = true }); + var cut = getCut(); + var action = cut.FindComponents().Where(b => b.Instance.Class == "aspire-button aspire-neutral-button").ToList(); + if (string.IsNullOrEmpty(buttonText)) + { + Assert.Empty(action); + Assert.False(dialog.Result.IsCompleted); + await dialog.CloseAsync().DefaultTimeout(); + } + else + { + var button = Assert.Single(action); + Assert.Equal(buttonText, button.Find("fluent-button").TextContent.Trim()); + await cut.InvokeAsync(button.Instance.OnClick.InvokeAsync); + var result = await dialog.Result.DefaultTimeout(); + Assert.False(result.Cancelled); + Assert.Equal("cancel", result.Value); + } + } + + [Fact] + public async Task Dispose_DisconnectsOnlyTheViewerAndUnsubscribesUpdates() + { + TerminalSetupHelpers.SetupTerminalView(this); + var getCut = InteractionsSetupHelpers.SetupDialog(this, p => p.Content, out var dialogService); + var viewModel = new InteractionsTerminalDialogViewModel { TerminalId = "terminal", Message = string.Empty }; + var dialog = await dialogService.ShowDialogAsync(viewModel, new DialogParameters()); + var cut = getCut(); + cut.WaitForAssertion(() => Assert.Single(JSInterop.Invocations, i => i.Identifier == "initTerminal")); + + // Remove the dialog through the renderer so Blazor disposes its entire component subtree. + var host = Assert.IsAssignableFrom>>(cut); + host.SetParametersAndRender(parameters => parameters + .Add(p => p.Value, host.Instance.Value) + .AddChildContent(string.Empty)); + + cut.WaitForAssertion(() => Assert.Single(JSInterop.Invocations, i => i.Identifier == "disposeTerminal")); + Assert.Null(viewModel.OnInteractionUpdated); + Assert.False(dialog.Result.IsCompleted); + await viewModel.UpdateMessageAsync("After disposal"); + await dialog.CloseAsync().DefaultTimeout(); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.Terminal.cs b/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.Terminal.cs new file mode 100644 index 00000000000..78be8896550 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.Terminal.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Interaction; +using Aspire.Dashboard.Telemetry; +using Aspire.Dashboard.Tests.Shared; +using Aspire.DashboardService.Proto.V1; +using Aspire.Tests.Shared; +using Bunit; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Interactions; + +public partial class InteractionsProviderTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("Cancel work", false)] + [InlineData("Cancel work", true)] + public async Task ReceiveData_TerminalDialog_UsesDedicatedPresentationAndCancelResponse(string? buttonText, bool dismiss) + { + var updates = Channel.CreateUnbounded(); + var requests = Channel.CreateUnbounded(); + var shown = Channel.CreateUnbounded<(object? Content, DialogParameters Parameters)>(); + var client = new TestDashboardClient(isEnabled: true, interactionChannelProvider: () => updates, + sendInteractionUpdateChannel: requests); + var dialogs = new TestDialogService((content, parameters) => + { + shown.Writer.TryWrite((content, parameters)); + return Task.CompletedTask; + }); + SetupInteractionProviderServices(client, dialogs); + var cut = RenderComponent(); + await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate + { + InteractionId = 1, + Title = "Shell", + Message = "", + PrimaryButtonText = buttonText ?? string.Empty, + SecondaryButtonText = "Not used", + ShowSecondaryButton = true, + ShowDismiss = true, + PromptTerminal = new InteractionPromptTerminal { TerminalId = "terminal #1/?%+" } + }); + var (content, parameters) = await shown.Reader.ReadAsync().AsTask().DefaultTimeout(); + var vm = Assert.IsType(content); + Assert.Equal("terminal #1/?%+", vm.TerminalId); + Assert.Equal("<message>", vm.Message); + Assert.Equal("Shell", parameters.Title); + Assert.Equal("interactions-terminal-dialog", parameters.Id); + Assert.Equal("75vw", parameters.Width); + Assert.False(parameters.ShowDismiss); + Assert.Null(parameters.SecondaryAction); + Assert.Equal(string.IsNullOrEmpty(buttonText) ? null : buttonText, parameters.PrimaryAction); + Assert.True(parameters.UseCustomFooter); + Assert.True(parameters.PreventDismissOnOverlayClick); + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => cut.Instance._interactionDialogReference?.InteractionId == 1, "Terminal dialog opened."); + Assert.Equal(TelemetryComponentIds.InteractionTerminalDialog, + cut.Instance._interactionDialogReference!.TelemetryContext.Properties[TelemetryPropertyKeys.DashboardComponentId].Value); + + await cut.InvokeAsync(() => cut.Instance._interactionDialogReference!.Dialog.CloseAsync( + dismiss ? DialogResult.Cancel() : DialogResult.Ok("cancel"))).DefaultTimeout(); + var request = await requests.Reader.ReadAsync().AsTask().DefaultTimeout(); + Assert.Equal(1, request.InteractionId); + Assert.Equal(dismiss ? WatchInteractionsRequestUpdate.KindOneofCase.Complete : WatchInteractionsRequestUpdate.KindOneofCase.PromptTerminal, + request.KindCase); + if (!dismiss) + { + Assert.Equal(vm.TerminalId, request.PromptTerminal.TerminalId); + Assert.True(request.PromptTerminal.HasResult); + Assert.False(request.PromptTerminal.Result); + } + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => cut.Instance._interactionDialogReference is null, "Canceled dialog removed."); + Assert.False(requests.Reader.TryRead(out _)); + await cut.Instance.DisposeAsync().DefaultTimeout(); + } + + [Fact] + public async Task ReceiveData_TerminalDialogs_QueueUpdateReplayAndRemoveWithoutEchoingCompletion() + { + var updates = Channel.CreateUnbounded(); + var requests = Channel.CreateUnbounded(); + var shown = Channel.CreateUnbounded<(object? Content, DialogParameters Parameters)>(); + var client = new TestDashboardClient(isEnabled: true, interactionChannelProvider: () => updates, + sendInteractionUpdateChannel: requests); + var dialogs = new TestDialogService((content, parameters) => + { + shown.Writer.TryWrite((content, parameters)); + return Task.CompletedTask; + }); + SetupInteractionProviderServices(client, dialogs); + var cut = RenderComponent(); + await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate + { + InteractionId = 1, PromptProgress = new InteractionPromptProgress() + }); + Assert.IsType((await shown.Reader.ReadAsync().AsTask().DefaultTimeout()).Content); + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => cut.Instance._interactionDialogReference?.InteractionId == 1, "Progress dialog opened."); + + var terminal = new WatchInteractionsResponseUpdate + { + InteractionId = 2, Message = "Queued", PromptTerminal = new InteractionPromptTerminal { TerminalId = "terminal" } + }; + await updates.Writer.WriteAsync(terminal); + await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate + { + InteractionId = 3, PromptTerminal = new InteractionPromptTerminal { TerminalId = "never-opened" } + }); + await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate + { + InteractionId = 4, + InputsDialog = new InteractionInputsDialog + { + InputItems = { new InteractionInput { Name = "normal", InputType = InputType.Text, Required = true } } + } + }); + var queuedUpdate = terminal.Clone(); + queuedUpdate.Message = "Updated while queued"; + await updates.Writer.WriteAsync(queuedUpdate); + await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate { InteractionId = 3, Complete = new InteractionComplete() }); + await AsyncTestHelpers.AssertIsTrueRetryAsync(async () => await cut.Instance.GetMessagesProcessedAsync() == 6, "Queued updates processed."); + Assert.False(shown.Reader.TryRead(out _)); + + await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate { InteractionId = 1, Complete = new InteractionComplete() }); + var (content, _) = await shown.Reader.ReadAsync().AsTask().DefaultTimeout(); + var terminalVM = Assert.IsType(content); + Assert.Equal("Updated while queued", terminalVM.Message); + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => cut.Instance._interactionDialogReference?.InteractionId == 2, "Queued terminal dialog opened."); + var reference = cut.Instance._interactionDialogReference; + + var replay = terminal.Clone(); + replay.EnableMessageMarkdown = true; + replay.Message = "**Replayed**"; + await updates.Writer.WriteAsync(replay); + await AsyncTestHelpers.AssertIsTrueRetryAsync(async () => await cut.Instance.GetMessagesProcessedAsync() == 8, "Replay processed."); + Assert.Same(reference, cut.Instance._interactionDialogReference); + Assert.Contains("Replayed", terminalVM.Message, StringComparison.Ordinal); + Assert.False(shown.Reader.TryRead(out _)); + + await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate { InteractionId = 2, Complete = new InteractionComplete() }); + var (inputContent, inputParameters) = await shown.Reader.ReadAsync().AsTask().DefaultTimeout(); + var inputsVM = Assert.IsType(inputContent); + Assert.Equal("normal", Assert.Single(inputsVM.Inputs).Name); + Assert.Equal("min(650px, 75vw)", inputParameters.Width); + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => cut.Instance._interactionDialogReference?.InteractionId == 4, "Ordinary inputs dialog opened."); + Assert.False(requests.Reader.TryRead(out _)); + Assert.False(shown.Reader.TryRead(out _)); + + await cut.Instance.DisposeAsync().DefaultTimeout(); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/InteractionsSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/InteractionsSetupHelpers.cs new file mode 100644 index 00000000000..ac0a5ea9644 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/InteractionsSetupHelpers.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq.Expressions; +using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Tests; +using Aspire.Tests.Shared; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.FluentUI.AspNetCore.Components; +using Assert = Xunit.Assert; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +internal static class InteractionsSetupHelpers +{ + public static Func SetupDialog( + TestContext context, Expression> contentParameter, out DashboardDialogService dialogService) + where TDialog : ComponentBase + { + FluentUISetupHelpers.SetupDialogInfrastructure(context); + FluentUISetupHelpers.SetupFluentButton(context); + + IRenderedFragment? cut = null; + TestDialogService? testDialogService = null; + testDialogService = new TestDialogService((content, _) => + { + cut = context.RenderComponent>(builder => + { + builder.Add(p => p.Value, testDialogService!.LastInstance!); + builder.AddChildContent(childBuilder => + { + childBuilder.Add(contentParameter, Assert.IsType(content)); + }); + }); + return Task.CompletedTask; + }); + context.Services.RemoveAll(); + context.Services.AddSingleton(testDialogService); + + dialogService = new DashboardDialogService( + testDialogService, + new TestStringLocalizer(), + context.Services.GetRequiredService()); + return () => cut ?? throw new InvalidOperationException("The dialog was not rendered."); + } +} diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 5b37c1a7a83..abb63a40b0f 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -582,6 +582,115 @@ public async Task WatchInteractions_NoExplicitLabel_LabelIsName() await CancelTokenAndAwaitTask(cts, task).DefaultTimeout(); } + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task WatchInteractions_PromptTerminalAsync_DedicatedPayloadAndCompletion(bool? result) + { + await using var terminals = TestTerminalService.Create(); + using var services = new ServiceCollection().AddSingleton(terminals).BuildServiceProvider(); + var interactionService = new InteractionService( + NullLogger.Instance, new DistributedApplicationOptions(), services, + new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore()); + using var data = CreateDashboardServiceData(interactionService: interactionService); + var dashboard = CreateDashboardService(data, terminalService: terminals); + await using var terminal = terminals.CreateTerminal(new TerminalLaunchOptions + { + Title = "Shell", Executable = "must-not-be-started", Placement = TerminalPlacement.Dialog + }); + using var cts = new CancellationTokenSource(); + var context = TestServerCallContext.Create(cancellationToken: cts.Token); + var writer = new TestServerStreamWriter(context); + var reader = new TestAsyncStreamReader(context); + var watch = dashboard.WatchInteractions(reader, writer, context); + var prompt = interactionService.PromptTerminalAsync("Message", terminal, + new TerminalInteractionOptions { Title = "Dialog", PrimaryButtonText = "Cancel" }, cts.Token); + + var update = await writer.ReadNextAsync().DefaultTimeout(); + Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.PromptTerminal, update.KindCase); + Assert.Equal("Dialog", update.Title); + Assert.Equal("Message", update.Message); + Assert.Equal("Cancel", update.PrimaryButtonText); + Assert.Equal(terminal.Id, update.PromptTerminal.TerminalId); + Assert.False(update.PromptTerminal.HasResult); + Assert.False(prompt.IsCompleted); + + var response = new WatchInteractionsRequestUpdate { InteractionId = update.InteractionId }; + if (result is { } value) + { + response.PromptTerminal = new InteractionPromptTerminal { TerminalId = terminal.Id, Result = value }; + } + else + { + response.Complete = new InteractionComplete(); + } + reader.AddMessage(response); + var promptResult = await prompt.DefaultTimeout(); + Assert.Equal(result != true, promptResult.Canceled); + Assert.Equal(result == true, promptResult.Data); + var complete = await writer.ReadNextAsync().DefaultTimeout(); + Assert.Equal(update.InteractionId, complete.InteractionId); + Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.Complete, complete.KindCase); + Assert.Empty(interactionService.GetCurrentInteractions()); + Assert.True(terminals.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + await CancelTokenAndAwaitTask(cts, watch).DefaultTimeout(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SendInteractionRequestAsync_TerminalResponseMustMatchInteraction(bool wrongKind) + { + await using var terminals = TestTerminalService.Create(); + using var services = new ServiceCollection().AddSingleton(terminals).BuildServiceProvider(); + var interactions = new InteractionService( + NullLogger.Instance, new DistributedApplicationOptions(), services, + new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore()); + using var data = CreateDashboardServiceData(interactionService: interactions); + await using var terminal = terminals.CreateTerminal(new TerminalLaunchOptions + { + Title = "Shell", Executable = "must-not-be-started", Placement = TerminalPlacement.Dialog + }); + using var cts = new CancellationTokenSource(); + var prompt = wrongKind + ? interactions.PromptMessageBoxAsync("Title", "Message", cancellationToken: cts.Token) + : interactions.PromptTerminalAsync("Message", terminal, cancellationToken: cts.Token); + var interaction = Assert.Single(interactions.GetCurrentInteractions()); + var response = new WatchInteractionsRequestUpdate + { + InteractionId = interaction.InteractionId, + PromptTerminal = new InteractionPromptTerminal + { + TerminalId = wrongKind ? terminal.Id : "different-terminal", + Result = false + } + }; + + var ex = await Assert.ThrowsAsync(() => data.SendInteractionRequestAsync(response, CancellationToken.None)); + Assert.Equal("The terminal response must match the interaction's terminal.", ex.Message); + Assert.Same(interaction, Assert.Single(interactions.GetCurrentInteractions())); + Assert.False(prompt.IsCompleted); + cts.Cancel(); + Assert.True((await prompt.DefaultTimeout()).Canceled); + } + + [Fact] + public void TerminalInteractionProtocol_UsesDedicatedFieldsAndReservesRemovedInputIdentifiers() + { + Assert.Equal(8, WatchInteractionsRequestUpdate.Descriptor.FindFieldByName("prompt_terminal").FieldNumber); + Assert.Equal(21, WatchInteractionsResponseUpdate.Descriptor.FindFieldByName("prompt_terminal").FieldNumber); + var input = Aspire.DashboardService.Proto.V1.InteractionInput.Descriptor.ToProto(); + Assert.Contains("terminal_id", input.ReservedName); + Assert.Contains(input.ReservedRange, range => range.Start == 19 && range.End == 20); + Assert.Null(Aspire.DashboardService.Proto.V1.InteractionInput.Descriptor.FindFieldByName("terminal_id")); + var inputType = DashboardServiceReflection.Descriptor.EnumTypes.Single(type => type.Name == "InputType"); + Assert.Contains("INPUT_TYPE_TERMINAL", inputType.ToProto().ReservedName); + Assert.Contains(inputType.ToProto().ReservedRange, range => range.Start == 7 && range.End == 7); + Assert.Null(inputType.FindValueByNumber(7)); + } + [Fact] public async Task WatchInteractions_PromptInputAsync_CompleteOnCancelResponse() { diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index f3e131b878b..b819a424ebc 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Threading.Channels; -using Aspire.Hosting.Terminals; using Aspire.Hosting.Testing; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -12,7 +11,6 @@ namespace Aspire.Hosting.Tests; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. [Trait("Partition", "2")] public class ResourceCommandServiceTests(ITestOutputHelper testOutputHelper) @@ -1317,11 +1315,9 @@ public async Task ExecuteCommandAsync_InteractiveWithoutArguments_PromptsForArgu [InlineData(false, false)] [InlineData(true, false)] [InlineData(false, true)] - public async Task ExecuteCommandAsync_TerminalArguments_ReuseSessionAcrossInteractions(bool dismissFirst, bool cancelFirst) + public async Task ExecuteCommandAsync_Arguments_IsolateInputStateAcrossInteractions(bool dismissFirst, bool cancelFirst) { using var builder = CreateBuilder(); - await using var terminalService = TestTerminalService.Create(); - builder.Services.AddSingleton(terminalService); // Exercise the real interaction lifecycle with prompting enabled, without starting a dashboard in the test. builder.Services.AddSingleton(services => new InteractionService( @@ -1331,17 +1327,10 @@ public async Task ExecuteCommandAsync_TerminalArguments_ReuseSessionAcrossIntera builder.Configuration, services.GetRequiredService())); - await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions + var textDefinition = new InteractionInput { - Title = "Shell", - Executable = "bash", - Placement = TerminalPlacement.Dialog - }); - var terminalDefinition = new InteractionInput - { - Name = "shell", - InputType = InputType.Terminal, - Terminal = terminal + Name = "text", + InputType = InputType.Text }; var messageDefinition = new InteractionInput { @@ -1361,7 +1350,7 @@ public async Task ExecuteCommandAsync_TerminalArguments_ReuseSessionAcrossIntera executionCount++; return Task.FromResult(CommandResults.Success()); }, - commandOptions: new CommandOptions { Arguments = [terminalDefinition, messageDefinition] }); + commandOptions: new CommandOptions { Arguments = [textDefinition, messageDefinition] }); await using var app = builder.Build(); await app.StartAsync().DefaultTimeout(); @@ -1380,11 +1369,9 @@ public async Task ExecuteCommandAsync_TerminalArguments_ReuseSessionAcrossIntera var interaction = Assert.Single(interactionService.GetCurrentInteractions()); var inputs = Assert.IsType(interaction.InteractionInfo).Inputs; - var input = inputs["shell"]; - Assert.NotSame(terminalDefinition, input); + var input = inputs["text"]; + Assert.NotSame(textDefinition, input); Assert.NotSame(previousInput, input); - Assert.Same(terminal, input.Terminal); - Assert.Equal(terminal.Id, input.TerminalId); Assert.False(input.Disabled); Assert.Equal("default", inputs.GetString("message")); @@ -1413,16 +1400,12 @@ await interactionService.ProcessInteractionFromClientAsync( else { Assert.NotNull(capturedArguments); - Assert.Same(input, capturedArguments["shell"]); - Assert.Same(terminal, capturedArguments["shell"].Terminal); + Assert.Same(input, capturedArguments["text"]); Assert.Equal($"invocation-{invocation}", capturedArguments.GetString("message")); } Assert.Empty(interactionService.GetCurrentInteractions()); - Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); - Assert.Same(terminal, registered); - Assert.False(terminalDefinition.Disabled); - Assert.Null(terminalDefinition.TerminalId); + Assert.False(textDefinition.Disabled); Assert.Equal("default", messageDefinition.Value); previousInput = input; } diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index 84a8f51151e..d512511cfc7 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -1,402 +1,456 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Pipelines; using Aspire.Hosting.Terminals; +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; +using Hex1b; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. +#pragma warning disable ASPIREINTERACTION001 // Regression coverage for the shared progress lifecycle. #pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; -/// -/// Guards how validates terminal-typed inputs and, above all, that it keeps its -/// hands off the terminal's lifetime. The caller creates the terminal and the caller disposes it, so the dialog is -/// only ever a view onto a terminal that already exists. -/// [Trait("Partition", "2")] public class InteractionServiceTerminalTests { - [Theory] - [InlineData(false, null)] - [InlineData(false, "supplied-value")] - [InlineData(true, null)] - [InlineData(true, "supplied-value")] - public async Task PromptInputsAsync_RequiredTerminalInput_ThrowsBeforePublishing(bool singleInput, string? value) - { - var (interactionService, terminalService) = CreateInteractionService(); - await using var serviceOwner = terminalService; - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - var input = new InteractionInput - { - Name = "shell", - InputType = InputType.Terminal, - Terminal = terminal, - Required = true, - Value = value - }; - - Func prompt = singleInput - ? () => interactionService.PromptInputAsync("Title", "Message", input) - : () => interactionService.PromptInputsAsync("Title", "Message", [input]); - var ex = await Assert.ThrowsAsync(prompt).DefaultTimeout(); - - Assert.Equal("The input 'shell' has Required set to true, but Terminal inputs do not produce a value and cannot be required.", ex.Message); - Assert.Empty(interactionService.GetCurrentInteractions()); - Assert.Null(input.TerminalId); - Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); - Assert.Same(terminal, registered); - } - [Fact] - public async Task PromptInputsAsync_OptionalTerminalInput_SubmitsWithoutAValue() + public async Task PromptTerminalAsync_NullArguments_ThrowsBeforePublishing() { - var (interactionService, terminalService) = CreateInteractionService(); - await using var serviceOwner = terminalService; - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - var input = new InteractionInput - { - Name = "shell", - InputType = InputType.Terminal, - Terminal = terminal, - Required = false - }; - - var prompt = interactionService.PromptInputsAsync("Title", "Message", [input]); - var interaction = Assert.Single(interactionService.GetCurrentInteractions()); - await interactionService.ProcessInteractionFromClientAsync( - interaction.InteractionId, - (_, _, _) => new InteractionCompletionState { Complete = true, State = new[] { input } }, - CancellationToken.None).DefaultTimeout(); - var result = await prompt.DefaultTimeout(); + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); - Assert.False(result.Canceled); - Assert.Same(input, Assert.Single(result.Data)); - Assert.Null(input.Value); - Assert.Empty(interactionService.GetCurrentInteractions()); - Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); - Assert.Same(terminal, registered); + await Assert.ThrowsAsync("message", () => service.PromptTerminalAsync(null!, terminal)); + await Assert.ThrowsAsync("terminal", () => service.PromptTerminalAsync("Message", null!)); + Assert.Empty(service.GetCurrentInteractions()); } [Fact] - public async Task PromptInputsAsync_TerminalInputWithoutATerminal_Throws() + public async Task PromptTerminalAsync_Unavailable_ThrowsBeforePublishing() { - var (interactionService, _) = CreateInteractionService(); - - var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal }; + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); + using var scope = InteractionService.StartNonInteractiveScope(); - var ex = await Assert.ThrowsAsync( - () => interactionService.PromptInputsAsync("Title", "Message", [input])).DefaultTimeout(); + await Assert.ThrowsAsync(() => service.PromptTerminalAsync("Message", terminal)); + Assert.Empty(service.GetCurrentInteractions()); + } - Assert.Contains(nameof(InteractionInput.Terminal), ex.Message, StringComparison.Ordinal); + [Theory] + [InlineData(TerminalPlacement.Dock)] + [InlineData(TerminalPlacement.None)] + public async Task PromptTerminalAsync_NonDialogPlacement_ThrowsBeforePublishing(TerminalPlacement placement) + { + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals, placement); + + var ex = await Assert.ThrowsAsync(() => service.PromptTerminalAsync("Message", terminal)); + Assert.Equal($"Terminals shown by an interaction must be created with TerminalPlacement.Dialog; the supplied terminal has placement {placement}.", ex.Message); + Assert.Empty(service.GetCurrentInteractions()); + AssertRegistered(terminals, terminal); } [Fact] - public async Task PromptInputsAsync_TerminalFromAnotherService_ThrowsBeforePublishing() + public async Task PromptTerminalAsync_TerminalFromAnotherService_ThrowsBeforePublishing() { - var (interactionService, terminalService) = CreateInteractionService(); - await using var serviceOwner = terminalService; + await using var terminals = TestTerminalService.Create(); await using var otherService = TestTerminalService.Create(); - await using var terminal = CreateTerminal(otherService, TerminalPlacement.Dialog); - var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, Terminal = terminal }; - - await AssertTerminalRejectedAsync(interactionService, [input], input.Name); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(otherService); - Assert.True(otherService.TryGetTerminal(terminal.Id, out var registered)); - Assert.Same(terminal, registered); - Assert.False(terminalService.TryGetTerminal(terminal.Id, out _)); + await AssertTerminalRejectedAsync(service, terminal); + AssertRegistered(otherService, terminal); + Assert.False(terminals.TryGetTerminal(terminal.Id, out _)); } [Fact] - public async Task PromptInputsAsync_DisposedTerminal_ThrowsBeforePublishing() + public async Task PromptTerminalAsync_DisposedTerminal_ThrowsBeforePublishing() { - var (interactionService, terminalService) = CreateInteractionService(); - await using var serviceOwner = terminalService; - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); await terminal.DisposeAsync(); - var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, Terminal = terminal }; - - await AssertTerminalRejectedAsync(interactionService, [input], input.Name); - Assert.False(terminalService.TryGetTerminal(terminal.Id, out _)); + await AssertTerminalRejectedAsync(service, terminal); + Assert.False(terminals.TryGetTerminal(terminal.Id, out _)); } [Theory] [InlineData(false)] [InlineData(true)] - public async Task PromptInputsAsync_UnregisteredTerminal_ThrowsBeforePublishing(bool useRegisteredId) + public async Task PromptTerminalAsync_UnregisteredInstance_ThrowsBeforePublishing(bool useRegisteredId) { - var (interactionService, terminalService) = CreateInteractionService(); - await using var serviceOwner = terminalService; - await using var registeredTerminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var registeredTerminal = CreateTerminal(terminals); var backend = new TestTerminalBackend(useRegisteredId ? registeredTerminal.Id : "unregistered"); await using var unregisteredTerminal = new AspireTerminal(backend); - var validInput = new InteractionInput { Name = "valid", InputType = InputType.Terminal, Terminal = registeredTerminal }; - var invalidInput = new InteractionInput { Name = "invalid", InputType = InputType.Terminal, Terminal = unregisteredTerminal }; Assert.Equal(useRegisteredId, backend.Equals(registeredTerminal.Backend)); Assert.NotEqual(registeredTerminal, unregisteredTerminal); - await AssertTerminalRejectedAsync(interactionService, [validInput, invalidInput], invalidInput.Name); - + await AssertTerminalRejectedAsync(service, unregisteredTerminal); Assert.False(backend.IsDisposed); - Assert.True(terminalService.TryGetTerminal(registeredTerminal.Id, out var registered)); - Assert.Same(registeredTerminal, registered); + AssertRegistered(terminals, registeredTerminal); } [Fact] - public async Task PromptInputsAsync_NoTerminalService_ThrowsBeforePublishing() + public async Task PromptTerminalAsync_NoTerminalService_ThrowsBeforePublishing() { - var (interactionService, terminalService) = CreateInteractionService(registerTerminalService: false); - await using var serviceOwner = terminalService; - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, Terminal = terminal }; + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(null); + await using var terminal = CreateTerminal(terminals); - await AssertTerminalRejectedAsync(interactionService, [input], input.Name); - - Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); - Assert.Same(terminal, registered); + await AssertTerminalRejectedAsync(service, terminal); + AssertRegistered(terminals, terminal); } [Fact] public async Task PromptInputsAsync_TextInput_DoesNotRequireTerminalService() { - var (interactionService, terminalService) = CreateInteractionService(registerTerminalService: false); - await using var serviceOwner = terminalService; - var input = new InteractionInput { Name = "text", InputType = InputType.Text }; + var service = CreateInteractionService(null); + var input = new InteractionInput { Name = "text", InputType = InputType.Text, Required = true, Value = "value" }; + var prompt = service.PromptInputsAsync("Title", "Message", [input]); + var interaction = Assert.Single(service.GetCurrentInteractions()); + await CompleteInteractionAsync(service, interaction.InteractionId, new[] { input }); - var prompt = interactionService.PromptInputsAsync("Title", "Message", [input]); - var interaction = Assert.Single(interactionService.GetCurrentInteractions()); - await CancelInteractionAsync(interactionService, interaction.InteractionId).DefaultTimeout(); var result = await prompt.DefaultTimeout(); - - Assert.True(result.Canceled); - Assert.Null(input.TerminalId); - Assert.Empty(interactionService.GetCurrentInteractions()); + Assert.False(result.Canceled); + Assert.Same(input, Assert.Single(result.Data)); + Assert.Empty(service.GetCurrentInteractions()); } [Fact] - public async Task PromptInputsAsync_TerminalDisposedAfterPublishing_AttachmentStillRejectsIt() + public async Task PromptTerminalAsync_DisposedAfterPublishing_AttachmentStillRejectsIt() { - var (interactionService, terminalService) = CreateInteractionService(); - await using var serviceOwner = terminalService; - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - var input = new InteractionInput { Name = "shell", InputType = InputType.Terminal, Terminal = terminal }; + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); using var cts = new CancellationTokenSource(); - var prompt = interactionService.PromptInputsAsync("Title", "Message", [input], cancellationToken: cts.Token); - Assert.Single(interactionService.GetCurrentInteractions()); + var prompt = service.PromptTerminalAsync("Message", terminal, cancellationToken: cts.Token); + Assert.Single(service.GetCurrentInteractions()); await terminal.DisposeAsync(); var ex = await Assert.ThrowsAsync( - () => terminalService.AttachAsync(terminal.Id, Stream.Null, _ => Task.CompletedTask, CancellationToken.None)).DefaultTimeout(); + () => terminals.AttachAsync(terminal.Id, Stream.Null, _ => Task.CompletedTask, CancellationToken.None)).DefaultTimeout(); Assert.Equal($"There is no terminal with id '{terminal.Id}'.", ex.Message); + Assert.False(prompt.IsCompleted); cts.Cancel(); - var result = await prompt.DefaultTimeout(); - Assert.True(result.Canceled); - Assert.Empty(interactionService.GetCurrentInteractions()); + Assert.True((await prompt.DefaultTimeout()).Canceled); + Assert.Empty(service.GetCurrentInteractions()); } - [Fact] - public async Task PromptInputsAsync_TerminalOnTheDockSurface_Throws() + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task PromptTerminalAsync_WithoutWork_CompletesAndCanBeReused(bool? completion) { - var (interactionService, terminalService) = CreateInteractionService(); - - // A dock terminal is already presented as a dock tab, so showing it in a dialog as well would render one - // terminal through two competing presentations. - await using var dockTerminal = CreateTerminal(terminalService, TerminalPlacement.Dock); - var input = new InteractionInput + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + // An invalid executable proves that raising/completing a prompt does not start the terminal. + await using var terminal = CreateTerminal(terminals); + for (var i = 0; i < 2; i++) { - Name = "shell", - InputType = InputType.Terminal, - Terminal = dockTerminal - }; - - var ex = await Assert.ThrowsAsync( - () => interactionService.PromptInputsAsync("Title", "Message", [input])).DefaultTimeout(); - - Assert.Contains(nameof(TerminalPlacement.Dock), ex.Message, StringComparison.Ordinal); - - // The rejected prompt must not take the caller's dock tab with it. - Assert.True(terminalService.TryGetTerminal(dockTerminal.Id, out _)); + var options = new TerminalInteractionOptions { Title = "Title", PrimaryButtonText = "Cancel" }; + var prompt = service.PromptTerminalAsync("Message", terminal, options); + var interaction = Assert.Single(service.GetCurrentInteractions()); + Assert.Equal(terminal.Id, Assert.IsType(interaction.InteractionInfo).TerminalId); + Assert.Equal("Title", interaction.Title); + Assert.Equal("Message", interaction.Message); + Assert.Same(options, interaction.Options); + Assert.False(prompt.IsCompleted); + + await CompleteInteractionAsync(service, interaction.InteractionId, completion); + var result = await prompt.DefaultTimeout(); + Assert.Equal(completion != true, result.Canceled); + Assert.Equal(completion == true, result.Data); + Assert.Empty(service.GetCurrentInteractions()); + AssertRegistered(terminals, terminal); + } } - [Fact] - public async Task PromptInputsAsync_TerminalInput_CarriesTheCallersTerminalIdIntoTheDialog() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PromptTerminalAsync_PreCanceled_DoesNotPublishOrRunWork(bool withWork) { - var (interactionService, terminalService) = CreateInteractionService(); - - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - var input = new InteractionInput - { - Name = "shell", - Label = "Shell", - InputType = InputType.Terminal, - Terminal = terminal - }; - - var resultTask = interactionService.PromptInputsAsync("Title", "Message", [input]); - - // The dashboard addresses terminals by id, so the id of the caller's terminal is what the dialog has to - // carry -- the interaction does not stand up a terminal of its own. - Assert.Equal(terminal.Id, input.TerminalId); - - var interaction = Assert.Single(interactionService.GetCurrentInteractions()); - await CancelInteractionAsync(interactionService, interaction.InteractionId); - - await resultTask.DefaultTimeout(); + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); + var workCalled = false; + var options = withWork ? new TerminalInteractionOptions { Work = _ => { workCalled = true; return Task.CompletedTask; } } : null; + + await Assert.ThrowsAnyAsync(() => + service.PromptTerminalAsync("Message", terminal, options, new CancellationToken(canceled: true))); + + Assert.False(workCalled); + Assert.Empty(service.GetCurrentInteractions()); + AssertRegistered(terminals, terminal); } [Fact] - public async Task PromptInputsAsync_Cancelled_LeavesTheCallersTerminalAlone() + public async Task PromptTerminalAsync_WithoutWork_ExternalCancellation() { - var (interactionService, terminalService) = CreateInteractionService(); - - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - var input = new InteractionInput - { - Name = "shell", - InputType = InputType.Terminal, - Terminal = terminal - }; - - var resultTask = interactionService.PromptInputsAsync("Title", "Message", [input]); - - var interaction = Assert.Single(interactionService.GetCurrentInteractions()); - await CancelInteractionAsync(interactionService, interaction.InteractionId); - - var result = await resultTask.DefaultTimeout(); + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); + using var cts = new CancellationTokenSource(); + var prompt = service.PromptTerminalAsync("Message", terminal, cancellationToken: cts.Token); + var interaction = Assert.Single(service.GetCurrentInteractions()); + Assert.Equal(string.Empty, interaction.Title); + Assert.Null(interaction.Options.PrimaryButtonText); + Assert.False(prompt.IsCompleted); - // The terminal outlives the dialog. A caller may show the same terminal in a second prompt, or keep - // driving it through the automation API after the user dismisses this one, so a dismissed dialog must not - // stop the workload. - Assert.True(result.Canceled); - Assert.True(terminalService.TryGetTerminal(terminal.Id, out _)); + cts.Cancel(); + Assert.True((await prompt.DefaultTimeout()).Canceled); + Assert.Empty(service.GetCurrentInteractions()); + AssertRegistered(terminals, terminal); } - [Fact] - public async Task PromptInputsAsync_CallerTokenCancelled_LeavesTheCallersTerminalAlone() + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task PromptTerminalAsync_WorkCancellation_SignalsTokenAndJoinsWork(bool external, bool handleCancellation) { - var (interactionService, terminalService) = CreateInteractionService(); + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); + using var cts = new CancellationTokenSource(); + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finishWork = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var prompt = service.PromptTerminalAsync("Message", terminal, new TerminalInteractionOptions + { + Work = async context => + { + using var registration = context.CancellationToken.Register(() => canceled.TrySetResult()); + await finishWork.Task; + if (!handleCancellation) + { + context.CancellationToken.ThrowIfCancellationRequested(); + } + } + }, cts.Token); + var interaction = Assert.Single(service.GetCurrentInteractions()); + if (external) + { + cts.Cancel(); + } + else + { + await CompleteInteractionAsync(service, interaction.InteractionId, false); + } - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - var terminalInput = new InteractionInput + try { - Name = "shell", - InputType = InputType.Terminal, - Terminal = terminal - }; + await canceled.Task.DefaultTimeout(); + Assert.False(prompt.IsCompleted); + Assert.Empty(service.GetCurrentInteractions()); + AssertRegistered(terminals, terminal); + } + finally + { + finishWork.TrySetResult(); + } + Assert.True((await prompt.DefaultTimeout()).Canceled); + } + [Theory] + [InlineData(false, "success")] + [InlineData(false, "fault")] + [InlineData(false, "cancel")] + [InlineData(false, "handled-cancel")] + [InlineData(true, "success")] + [InlineData(true, "fault")] + [InlineData(true, "cancel")] + [InlineData(true, "handled-cancel")] + public async Task PromptWorkAsync_CompletesExactlyOnce(bool progress, string outcome) + { + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); using var cts = new CancellationTokenSource(); - var resultTask = interactionService.PromptInputsAsync("Title", "Message", [terminalInput], cancellationToken: cts.Token); - - // Cancelling the caller's token unwinds the prompt through OnInteractionCancellation rather than through a - // dashboard-driven completion. Both routes end in CompleteInteractionCore, so this covers the second of the - // two paths that used to tear the terminal down. + await using var updates = service.SubscribeInteractionUpdates(cts.Token).GetAsyncEnumerator(); + var firstUpdate = updates.MoveNextAsync().AsTask(); + var finishWork = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var failure = new InvalidOperationException("work failed"); + Task Work(CancellationToken _) => finishWork.Task; + var prompt = progress + ? service.PromptProgressAsync("Message", new ProgressInteractionOptions { Work = context => Work(context.CancellationToken) }, cts.Token) + : service.PromptTerminalAsync("Message", terminal, new TerminalInteractionOptions { Work = context => Work(context.CancellationToken) }, cts.Token); + Assert.True(await firstUpdate.DefaultTimeout()); + var firstId = updates.Current.InteractionId; + Assert.Equal(Interaction.InteractionState.InProgress, updates.Current.State); + + if (outcome == "fault") + { + finishWork.SetException(failure); + Assert.Same(failure, await Assert.ThrowsAsync(() => prompt).DefaultTimeout()); + } + else if (outcome == "success") + { + finishWork.SetResult(); + Assert.True((await prompt.DefaultTimeout()).Data); + } + else + { + await CompleteInteractionAsync(service, firstId, false); + // Awaiting the signal avoids relying on when CompletionTcs's asynchronous continuation runs. + var interaction = updates.Current; + var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = interaction.CancellationToken.Register(() => cancellation.TrySetResult()); + await cancellation.Task.DefaultTimeout(); + if (outcome == "cancel") + { + finishWork.SetCanceled(interaction.CancellationToken); + } + else + { + finishWork.SetResult(); + } + Assert.True((await prompt.DefaultTimeout()).Canceled); + } + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Equal(firstId, updates.Current.InteractionId); + Assert.Equal(Interaction.InteractionState.Complete, updates.Current.State); + Assert.Empty(service.GetCurrentInteractions()); + AssertRegistered(terminals, terminal); + + // Late client cancellation must not emit a second removal. A new prompt is a deterministic stream barrier. + await CompleteInteractionAsync(service, firstId, false); + var second = service.PromptTerminalAsync("Again", terminal, cancellationToken: cts.Token); + Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.NotEqual(firstId, updates.Current.InteractionId); + Assert.Equal(Interaction.InteractionState.InProgress, updates.Current.State); cts.Cancel(); + await second.DefaultTimeout(); + } - var result = await resultTask.DefaultTimeout(); - - Assert.True(result.Canceled); - Assert.True(terminalService.TryGetTerminal(terminal.Id, out _)); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PromptTerminalAsync_WorkThrowsUnrelatedCancellation_Propagates(bool progress) + { + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + await using var terminal = CreateTerminal(terminals); + var failure = new OperationCanceledException("not the interaction token"); + var prompt = progress + ? service.PromptProgressAsync("Message", new ProgressInteractionOptions { Work = _ => throw failure }) + : service.PromptTerminalAsync("Message", terminal, new TerminalInteractionOptions { Work = _ => throw failure }); + + Assert.Same(failure, await Assert.ThrowsAsync(() => prompt)); + Assert.Empty(service.GetCurrentInteractions()); + AssertRegistered(terminals, terminal); } [Fact] - public async Task PromptInputsAsync_TerminalShownTwice_Succeeds() + public async Task PromptTerminalAsync_CompletionAndViewerDisconnect_LeaveAutomationUsable() { - var (interactionService, terminalService) = CreateInteractionService(); - - // Caller-owned lifetime is what makes this legal: the terminal survives the first dialog, so the same - // session can be surfaced again rather than the caller having to start a second workload. - await using var terminal = CreateTerminal(terminalService, TerminalPlacement.Dialog); - - for (var i = 0; i < 2; i++) + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var terminal = terminals.CreateTerminal("Reusable", TerminalPlacement.Dialog, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null))); + terminal.Start(); + + foreach (var cancel in new[] { false, true }) { - var input = new InteractionInput + var prompt = service.PromptTerminalAsync("Message", terminal); + var interaction = Assert.Single(service.GetCurrentInteractions()); + await using (var viewer = await TestAppHostTerminalViewer.ConnectAsync(terminals, terminal.Id)) { - Name = "shell", - InputType = InputType.Terminal, - Terminal = terminal - }; - - var resultTask = interactionService.PromptInputsAsync("Title", "Message", [input]); - - Assert.Equal(terminal.Id, input.TerminalId); - - var interaction = Assert.Single(interactionService.GetCurrentInteractions()); - await CancelInteractionAsync(interactionService, interaction.InteractionId); - - await resultTask.DefaultTimeout(); + await outputWriter.WriteAsync("viewer-ready\r\n"u8.ToArray()); + await viewer.WaitForTextAsync("viewer-ready").DefaultTimeout(); + } + Assert.False(prompt.IsCompleted); + await CompleteInteractionAsync(service, interaction.InteractionId, !cancel); + Assert.Equal(cancel, (await prompt.DefaultTimeout()).Canceled); + + var marker = cancel ? "after-cancel" : "after-complete"; + await outputWriter.WriteAsync(System.Text.Encoding.UTF8.GetBytes(marker + "\r\n")); + await terminal.WaitForTextAsync(marker).DefaultTimeout(); + await terminal.SendTextAsync("still usable"); + AssertRegistered(terminals, terminal); } + } - Assert.True(terminalService.TryGetTerminal(terminal.Id, out _)); + [Fact] + public async Task PromptTerminalAsync_WorkloadExit_DoesNotCompleteDialog() + { + await using var terminals = TestTerminalService.Create(); + var service = CreateInteractionService(terminals); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + var workload = new StreamWorkloadAdapter(outputReader, Stream.Null); + await using var terminal = terminals.CreateTerminal("Ending", TerminalPlacement.Dialog, + Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + terminal.Start(); + var prompt = service.PromptTerminalAsync("Message", terminal); + var interaction = Assert.Single(service.GetCurrentInteractions()); + await outputWriter.WriteAsync("ready\r\n"u8.ToArray()); + await terminal.WaitForTextAsync("ready").DefaultTimeout(); + // Stream-backed workloads signal exit separately from EOF. Observe startup before signaling exit. + workload.SignalDisconnected(); + await Assert.IsType(terminal.Backend).WorkloadEnded.DefaultTimeout(); + + Assert.False(prompt.IsCompleted); + Assert.Same(interaction, Assert.Single(service.GetCurrentInteractions())); + await CompleteInteractionAsync(service, interaction.InteractionId, false); + Assert.True((await prompt.DefaultTimeout()).Canceled); } - /// - /// Dismisses the dialog the way the dashboard does when the user closes it without submitting. - /// - /// - /// - /// Complete = true with a null State is the dismiss signal, not Complete = false: - /// "not complete" means the dialog stays open, which is how a validation failure is reported. - /// PromptInputsAsync maps a completion whose state is not an input list onto a cancelled result. - /// - /// - /// The callback returns the state directly instead of routing through - /// DashboardServiceData.ProcessInputs. These tests are about the terminal's lifetime rather than - /// input marshalling. - /// - /// - private static Task CancelInteractionAsync(InteractionService interactionService, int interactionId) - => interactionService.ProcessInteractionFromClientAsync( - interactionId, - (_, _, _) => new InteractionCompletionState { Complete = true }, - CancellationToken.None); - - private static AspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement) - => service.CreateTerminal(new TerminalLaunchOptions - { - Title = "Terminal", - Executable = "bash", - Placement = placement - }); + private static Task CompleteInteractionAsync(InteractionService service, int interactionId, object? state) + => service.ProcessInteractionFromClientAsync(interactionId, + (_, _, _) => new InteractionCompletionState { Complete = true, State = state }, CancellationToken.None); + + private static AspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement = TerminalPlacement.Dialog) + => service.CreateTerminal(new TerminalLaunchOptions { Title = "Terminal", Executable = "must-not-be-started", Placement = placement }); - private static async Task AssertTerminalRejectedAsync(InteractionService interactionService, IReadOnlyList inputs, string invalidInputName) + private static async Task AssertTerminalRejectedAsync(InteractionService service, AspireTerminal terminal) { using var cts = new CancellationTokenSource(); - var prompt = interactionService.PromptInputsAsync("Title", "Message", inputs, cancellationToken: cts.Token); try { - Assert.Empty(interactionService.GetCurrentInteractions()); + var prompt = service.PromptTerminalAsync("Message", terminal, cancellationToken: cts.Token); + Assert.Empty(service.GetCurrentInteractions()); var ex = await Assert.ThrowsAsync(() => prompt).DefaultTimeout(); - Assert.Equal($"The input '{invalidInputName}' must reference the terminal instance registered with this AppHost's TerminalService.", ex.Message); - Assert.All(inputs, input => Assert.Null(input.TerminalId)); + Assert.Equal("The terminal must be the instance registered with this AppHost's TerminalService.", ex.Message); } finally { - // Unwind any incorrectly published prompt too, so a regression cannot leave an interaction pending. cts.Cancel(); } } - private static (InteractionService InteractionService, TerminalService TerminalService) CreateInteractionService(bool registerTerminalService = true) + private static void AssertRegistered(TerminalService service, AspireTerminal terminal) + { + Assert.True(service.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + } + + private static InteractionService CreateInteractionService(TerminalService? terminals) { - var terminalService = TestTerminalService.Create(); var services = new ServiceCollection(); - if (registerTerminalService) + if (terminals is not null) { - services.AddSingleton(terminalService); + services.AddSingleton(terminals); } - var interactionService = new InteractionService( - NullLogger.Instance, - new DistributedApplicationOptions(), - services.BuildServiceProvider(), - new ConfigurationBuilder().Build(), - new TestInteractionFileUploadStore()); - - return (interactionService, terminalService); + return new InteractionService( + NullLogger.Instance, new DistributedApplicationOptions(), services.BuildServiceProvider(), + new ConfigurationBuilder().Build(), new TestInteractionFileUploadStore()); } } diff --git a/tests/Shared/TestInteractionService.cs b/tests/Shared/TestInteractionService.cs index cbdc0ff9069..7041657ae59 100644 --- a/tests/Shared/TestInteractionService.cs +++ b/tests/Shared/TestInteractionService.cs @@ -2,10 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Threading.Channels; +using Aspire.Hosting.Terminals; namespace Aspire.Hosting.Tests; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. +#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. internal enum InteractionType { @@ -13,10 +15,14 @@ internal enum InteractionType Inputs, MessageBox, Notification, - Progress + Progress, + Terminal } -internal sealed record InteractionData(InteractionType Type, string Title, string? Message, InteractionInputCollection Inputs, InteractionOptions? Options, CancellationToken CancellationToken, TaskCompletionSource CompletionTcs); +internal sealed record InteractionData(InteractionType Type, string Title, string? Message, InteractionInputCollection Inputs, InteractionOptions? Options, CancellationToken CancellationToken, TaskCompletionSource CompletionTcs) +{ + public AspireTerminal? Terminal { get; init; } +} internal sealed class TestInteractionService : IInteractionService { @@ -77,16 +83,36 @@ public async Task> PromptProgressAsync(string message, P { PromptProgressCalled = true; - if (options?.Work is { } work) + return await PromptWorkAsync( + InteractionType.Progress, options?.Title, message, options, terminal: null, + options?.Work is { } work ? token => work(new ProgressContext { CancellationToken = token }) : null, + cancellationToken).ConfigureAwait(false); + } + + public Task> PromptTerminalAsync(string message, AspireTerminal terminal, TerminalInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + return PromptWorkAsync( + InteractionType.Terminal, options?.Title, message, options, terminal, + options?.Work is { } work ? token => work(new TerminalContext { CancellationToken = token }) : null, + cancellationToken); + } + + private async Task> PromptWorkAsync( + InteractionType type, string? title, string message, InteractionOptions? options, AspireTerminal? terminal, + Func? work, CancellationToken cancellationToken) + { + if (work is not null) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var progressContext = new ProgressContext { CancellationToken = cts.Token }; - var data = new InteractionData(InteractionType.Progress, options.Title ?? string.Empty, message, new InteractionInputCollection([]), options, cancellationToken, new TaskCompletionSource()); + var data = new InteractionData(type, title ?? string.Empty, message, new InteractionInputCollection([]), options, cancellationToken, new TaskCompletionSource()) + { + Terminal = terminal + }; Interactions.Writer.TryWrite(data); // Run the work and handle button clicks (CompletionTcs) canceling the work. - var workTask = work(progressContext); + var workTask = work(cts.Token); var completionTask = data.CompletionTcs.Task; var finished = await Task.WhenAny(workTask, completionTask).ConfigureAwait(false); From 5fcc4f325d82eb503e1d35593da052c2078a6206 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 15 Sep 2026 21:30:44 +1000 Subject: [PATCH 077/106] Align terminal window button with footer controls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css index fcc1c1cfb08..33476163035 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css @@ -78,6 +78,7 @@ flex: 0 0 30px; min-width: 0; padding: 0 14px; + padding-inline-end: 8px; box-sizing: border-box; background: linear-gradient(180deg, #1a2029 0%, #161b22 100%); border-bottom: 1px solid #30363d; From 997dba00b63cf94dbc5c7ad376b77b9c3b057f51 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 16 Sep 2026 19:42:30 +1000 Subject: [PATCH 078/106] Unify terminal experimental APIs under ASPIRETERMINAL001 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 9 +++-- .../TerminalInteractionCommands.cs | 2 +- .../ApplicationModel/TerminalAnnotation.cs | 3 +- .../AuxiliaryBackchannelRpcTarget.cs | 2 +- .../Dashboard/DashboardService.cs | 2 +- .../Dashboard/DashboardServiceHost.cs | 2 +- .../DistributedApplicationBuilder.cs | 2 +- src/Aspire.Hosting/IInteractionService.cs | 6 ++-- src/Aspire.Hosting/InteractionService.cs | 2 +- .../TerminalResourceBuilderExtensions.cs | 5 ++- .../Terminals/AspireTerminal.cs | 2 +- .../Terminals/AspireTerminalKey.cs | 4 +-- .../Terminals/Hex1bAspireTerminal.cs | 2 +- .../Terminals/ITerminalBackend.cs | 2 +- .../Terminals/ResourceAspireTerminal.cs | 2 +- .../Terminals/ResourceTerminalCatalog.cs | 2 +- .../Terminals/TerminalAutomation.cs | 2 +- .../Terminals/TerminalDiagnostics.cs | 12 ++----- .../Terminals/TerminalLaunchOptions.cs | 2 +- src/Aspire.Hosting/Terminals/TerminalOwner.cs | 2 +- .../Terminals/TerminalPlacement.cs | 2 +- .../Terminals/TerminalService.cs | 4 +-- .../Dashboard/DashboardServiceTests.cs | 2 +- .../AspireTerminalKeySequencesTests.cs | 2 +- .../Terminals/AspireTerminalTests.cs | 2 +- .../Terminals/Hex1bAspireTerminalTests.cs | 2 +- .../InteractionServiceTerminalTests.cs | 2 +- .../Terminals/ResourceAspireTerminalTests.cs | 2 +- .../Terminals/ResourceTerminalCatalogTests.cs | 2 +- .../Terminals/TerminalLaunchOptionsTests.cs | 2 +- .../Terminals/TerminalServiceTests.cs | 2 +- .../Utils/TestAppHostTerminalViewer.cs | 2 +- .../Utils/TestTerminalBackend.cs | 2 +- .../Aspire.Hosting.Tests/WithTerminalTests.cs | 33 +++++++++++++++++-- tests/Shared/TestInteractionService.cs | 2 +- tests/Shared/TestTerminalService.cs | 2 +- 36 files changed, 78 insertions(+), 54 deletions(-) diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 68be6d9cfe1..20d6fa713e2 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -18,10 +18,13 @@ builder.AddProject("agent") The dashboard then renders a Hex1b web terminal per replica, and the CLI exposes the same session as `aspire terminal agent --replica 0`. +All terminal APIs share the experimental diagnostic `ASPIRETERMINAL001`, +including `WithTerminal()`, AppHost-owned terminals, and terminal interactions. + ## AppHost-owned terminals For processes that the AppHost launches directly rather than as resources, use -the experimental `TerminalService` API (`ASPIRETERMINAL002`). +the experimental `TerminalService` API (`ASPIRETERMINAL001`). `TerminalLaunchOptions` holds the executable, arguments, working directory, environment variables, initial grid dimensions, title, and dashboard placement: @@ -29,7 +32,7 @@ environment variables, initial grid dimensions, title, and dashboard placement: using Aspire.Hosting.Terminals; using Microsoft.Extensions.DependencyInjection; -#pragma warning disable ASPIRETERMINAL002 +#pragma warning disable ASPIRETERMINAL001 var terminalService = app.Services.GetRequiredService(); var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions @@ -66,7 +69,7 @@ closing the interaction alone does not dispose the terminal. `IInteractionService.PromptTerminalAsync` displays one caller-owned terminal in a dedicated dialog, following progress-interaction completion and cancellation -semantics. It is experimental under the same `ASPIRETERMINAL002` diagnostic. +semantics. It is experimental under the same `ASPIRETERMINAL001` diagnostic. The public API uses only Aspire types, not Hex1b types. ```csharp diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 937d096b8ab..9d97b0513b2 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; // AppHost-owned terminals and terminal interactions are experimental. -#pragma warning disable ASPIRETERMINAL002 +#pragma warning disable ASPIRETERMINAL001 namespace Terminals.AppHost; diff --git a/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs index 2e575f18a0f..6ad744add2a 100644 --- a/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.Terminals; namespace Aspire.Hosting.ApplicationModel; @@ -93,7 +94,7 @@ internal void Initialize(IReadOnlyList terminalHosts) /// /// Options for configuring a terminal session. /// -[Experimental("ASPIRETERMINAL001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public sealed class TerminalOptions { private int _columns = 132; diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 559626d49b8..553566ab7a3 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -20,7 +20,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Backchannel; diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 4699fbe4c88..803abebc863 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging; using static Aspire.Hosting.Interaction; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. // Aspire.Hosting.Terminals cannot be imported wholesale: it declares TerminalDescriptor and TerminalChangeType, // which collide with the identically named proto types this file converts them into. Alias the individual types diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index 8897ea89097..c1b1cae2b7b 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -19,7 +19,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Dashboard; diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 5bfa0e3b98e..36c214d7e5d 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -48,7 +48,7 @@ using OpenTelemetry.Resources; using OpenTelemetry.Trace; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting; diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index 0738640a13b..fb1f49277c8 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -184,7 +184,7 @@ public interface IInteractionService /// }, cancellationToken); /// /// - [Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] + [Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] Task> PromptTerminalAsync(string message, AspireTerminal terminal, TerminalInteractionOptions? options = null, CancellationToken cancellationToken = default); } @@ -1018,7 +1018,7 @@ public sealed class ProgressContext /// Set to show a cancel button; by default there is no button. /// Secondary and dismiss buttons are not shown. The terminal's lifetime is independent of these options. /// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public class TerminalInteractionOptions : InteractionOptions { /// @@ -1042,7 +1042,7 @@ public class TerminalInteractionOptions : InteractionOptions /// /// Provides cancellation to the work callback of a terminal interaction. /// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public sealed class TerminalContext { /// diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index df370105516..e035a33ee90 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting; diff --git a/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs b/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs index bb8fcecb25b..9cebc0b209a 100644 --- a/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; +using Aspire.Hosting.Terminals; using Aspire.Shared; using Aspire.Shared.TerminalHost; using Microsoft.Extensions.Configuration; @@ -21,8 +22,6 @@ namespace Aspire.Hosting; /// public static class TerminalResourceBuilderExtensions { - private const string TerminalExperimentalDiagnosticId = "ASPIRETERMINAL001"; - /// /// Configures a resource to expose an interactive terminal session. /// @@ -65,7 +64,7 @@ public static class TerminalResourceBuilderExtensions /// }); /// /// - [Experimental(TerminalExperimentalDiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] [AspireExportIgnore(Reason = "Polyglot AppHosts use the parameterless withTerminal dispatcher export.")] public static IResourceBuilder WithTerminal(this IResourceBuilder builder, Action? configure = null) where T : IResource diff --git a/src/Aspire.Hosting/Terminals/AspireTerminal.cs b/src/Aspire.Hosting/Terminals/AspireTerminal.cs index 3138a004cf5..b34e83ace70 100644 --- a/src/Aspire.Hosting/Terminals/AspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/AspireTerminal.cs @@ -27,7 +27,7 @@ namespace Aspire.Hosting.Terminals; /// accepts input or automation. Reopening an ended terminal displays its ended state rather than replaying output. /// /// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public sealed class AspireTerminal : IAsyncDisposable { internal AspireTerminal(ITerminalBackend backend) diff --git a/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs b/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs index 15ce1bb86a5..231df0dfcb2 100644 --- a/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs +++ b/src/Aspire.Hosting/Terminals/AspireTerminalKey.cs @@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Terminals; @@ -16,7 +16,7 @@ namespace Aspire.Hosting.Terminals; /// which keeps the mapping under Aspire's control and avoids leaking a third-party enum through /// . /// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public enum AspireTerminalKey { /// The Enter key — sends a carriage return. diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index 79b7c953894..e58a980c135 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -7,7 +7,7 @@ using Hex1b.Reflow; using Microsoft.Extensions.Logging; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Terminals; diff --git a/src/Aspire.Hosting/Terminals/ITerminalBackend.cs b/src/Aspire.Hosting/Terminals/ITerminalBackend.cs index e3b6e19442a..1550135342a 100644 --- a/src/Aspire.Hosting/Terminals/ITerminalBackend.cs +++ b/src/Aspire.Hosting/Terminals/ITerminalBackend.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Terminals; diff --git a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs index aa8b038c438..68212555ae0 100644 --- a/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/ResourceAspireTerminal.cs @@ -6,7 +6,7 @@ using Hex1b.Reflow; using Microsoft.Extensions.Logging; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Terminals; diff --git a/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs b/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs index 06702339b07..a838b01ce6f 100644 --- a/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs +++ b/src/Aspire.Hosting/Terminals/ResourceTerminalCatalog.cs @@ -5,7 +5,7 @@ using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Terminals; diff --git a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs index e819527ba38..34bea4253c6 100644 --- a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs +++ b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs @@ -5,7 +5,7 @@ using Hex1b; using Hex1b.Automation; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Terminals; diff --git a/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs b/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs index 8a2c1a87bcd..bd58d1b59b5 100644 --- a/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs +++ b/src/Aspire.Hosting/Terminals/TerminalDiagnostics.cs @@ -4,20 +4,14 @@ namespace Aspire.Hosting.Terminals; /// -/// Diagnostic ids for the experimental AppHost-owned terminal API. +/// Diagnostic metadata for the experimental terminal APIs. /// internal static class TerminalDiagnostics { /// - /// Terminals owned by the AppHost process — , - /// and the types they take. + /// Shared diagnostic ID for resource terminals, AppHost-owned terminals, and terminal interactions. /// - /// - /// Distinct from ASPIRETERMINAL001, which covers WithTerminal — terminals for DCP-owned - /// resource processes. The two are separate features with separate lifetimes and separate transports, so - /// suppressing one should not silently opt into the other. - /// - public const string AppHostTerminals = "ASPIRETERMINAL002"; + public const string DiagnosticId = "ASPIRETERMINAL001"; /// /// The documentation link format shared by Aspire's experimental diagnostics. diff --git a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs index 3374c36b43f..a42f6171f5f 100644 --- a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs +++ b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs @@ -18,7 +18,7 @@ namespace Aspire.Hosting.Terminals; /// }; /// /// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public sealed class TerminalLaunchOptions { // Start wider than 80x24 so output is not wrapped before a viewer negotiates its size. diff --git a/src/Aspire.Hosting/Terminals/TerminalOwner.cs b/src/Aspire.Hosting/Terminals/TerminalOwner.cs index 9bac3788db9..6f8be82ea52 100644 --- a/src/Aspire.Hosting/Terminals/TerminalOwner.cs +++ b/src/Aspire.Hosting/Terminals/TerminalOwner.cs @@ -13,7 +13,7 @@ namespace Aspire.Hosting.Terminals; /// , which describes where the terminal is currently displayed and can change /// over the terminal's life. /// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public enum TerminalOwner { /// diff --git a/src/Aspire.Hosting/Terminals/TerminalPlacement.cs b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs index 6607a1609c4..cea0f5aee38 100644 --- a/src/Aspire.Hosting/Terminals/TerminalPlacement.cs +++ b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs @@ -13,7 +13,7 @@ namespace Aspire.Hosting.Terminals; /// values can share a placement, and a terminal can in principle move between /// placements without its workload being affected. /// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public enum TerminalPlacement { /// diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index cdc86ff1502..9d105f91837 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -#pragma warning disable ASPIRETERMINAL002 // Internal consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Terminals; @@ -40,7 +40,7 @@ namespace Aspire.Hosting.Terminals; /// snapshot while preserving the latest pending request to show the dock. /// /// -[Experimental(TerminalDiagnostics.AppHostTerminals, UrlFormat = TerminalDiagnostics.UrlFormat)] +[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public sealed class TerminalService : IAsyncDisposable { internal const int DefaultDockUpdateBufferCapacity = 64; diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index abb63a40b0f..0d7155338dc 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -31,7 +31,7 @@ using Resource = Aspire.Hosting.ApplicationModel.Resource; using WriteContext = Microsoft.Extensions.Logging.Testing.WriteContext; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Dashboard; diff --git a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalKeySequencesTests.cs b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalKeySequencesTests.cs index eb3cebba7ac..0381e07b674 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalKeySequencesTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalKeySequencesTests.cs @@ -3,7 +3,7 @@ using Aspire.Hosting.Terminals; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; diff --git a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs index d532e909efd..67cdf37265f 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/AspireTerminalTests.cs @@ -5,7 +5,7 @@ using Aspire.Hosting.Terminals; using Aspire.Hosting.Utils; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs index 97d06a80477..90b772b5886 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -12,7 +12,7 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. #pragma warning disable ASPIREFILESYSTEM001 // Use the hosting temporary directory abstraction. namespace Aspire.Hosting.Tests.Terminals; diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index d512511cfc7..3974b693503 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging.Abstractions; #pragma warning disable ASPIREINTERACTION001 // Regression coverage for the shared progress lifecycle. -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs index d2b5fc95816..a272d0d86d7 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceAspireTerminalTests.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging.Abstractions; using System.Net.Sockets; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; diff --git a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs index 360e83198e6..98155eb8422 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/ResourceTerminalCatalogTests.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs index 2723c78f011..1a5002ea885 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs @@ -3,7 +3,7 @@ using Aspire.Hosting.Terminals; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 7071e0a1046..3de48b5b9ae 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; diff --git a/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs b/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs index b982a213c2c..5226e720a5f 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestAppHostTerminalViewer.cs @@ -7,7 +7,7 @@ using Hex1b.Reflow; using Microsoft.AspNetCore.InternalTesting; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Utils; diff --git a/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs b/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs index aab8f6b8389..5ea3d04f1e1 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestTerminalBackend.cs @@ -3,7 +3,7 @@ using Aspire.Hosting.Terminals; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Utils; diff --git a/tests/Aspire.Hosting.Tests/WithTerminalTests.cs b/tests/Aspire.Hosting.Tests/WithTerminalTests.cs index 4bc183fd59c..126a33e1020 100644 --- a/tests/Aspire.Hosting.Tests/WithTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/WithTerminalTests.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Text.Json; using Aspire.Hosting.Testing; +using Aspire.Hosting.Terminals; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Utils; @@ -25,15 +26,41 @@ public void TerminalImplementationTypesAreInternal() Assert.True(typeof(TerminalHostLayout).IsNotPublic); } - [Fact] - public void TerminalOptionsIsExperimental() + [Theory] + [InlineData(typeof(TerminalOptions))] + [InlineData(typeof(TerminalService))] + [InlineData(typeof(AspireTerminal))] + [InlineData(typeof(AspireTerminalKey))] + [InlineData(typeof(TerminalLaunchOptions))] + [InlineData(typeof(TerminalOwner))] + [InlineData(typeof(TerminalPlacement))] + [InlineData(typeof(TerminalInteractionOptions))] + [InlineData(typeof(TerminalContext))] + public void TerminalTypesUseSharedExperimentalDiagnostic(Type terminalType) { - var attribute = Assert.Single(typeof(TerminalOptions).GetCustomAttributes()); + var attribute = Assert.Single(terminalType.GetCustomAttributes()); Assert.Equal("ASPIRETERMINAL001", attribute.DiagnosticId); Assert.Equal("https://aka.ms/aspire/diagnostics/{0}", attribute.UrlFormat); } + [Theory] + [InlineData(typeof(TerminalResourceBuilderExtensions), nameof(TerminalResourceBuilderExtensions.WithTerminal))] + [InlineData(typeof(IInteractionService), nameof(IInteractionService.PromptTerminalAsync))] + public void TerminalMethodsUseSharedExperimentalDiagnostic(Type declaringType, string methodName) + { + var methods = declaringType.GetMethods().Where(method => method.Name == methodName).ToArray(); + Assert.NotEmpty(methods); + + foreach (var method in methods) + { + var attribute = Assert.Single(method.GetCustomAttributes()); + + Assert.Equal("ASPIRETERMINAL001", attribute.DiagnosticId); + Assert.Equal("https://aka.ms/aspire/diagnostics/{0}", attribute.UrlFormat); + } + } + [Fact] public async Task WithTerminalAddsTerminalAnnotation() { diff --git a/tests/Shared/TestInteractionService.cs b/tests/Shared/TestInteractionService.cs index 7041657ae59..27b97bb76c7 100644 --- a/tests/Shared/TestInteractionService.cs +++ b/tests/Shared/TestInteractionService.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Tests; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. internal enum InteractionType { diff --git a/tests/Shared/TestTerminalService.cs b/tests/Shared/TestTerminalService.cs index 0bc163f5c4d..2231b526491 100644 --- a/tests/Shared/TestTerminalService.cs +++ b/tests/Shared/TestTerminalService.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; -#pragma warning disable ASPIRETERMINAL002 // Test consumer of the experimental AppHost terminal API. +#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Utils; From 7ef2f8b2d80173a70cac9944fa7a7f7da27ec7a9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 16 Sep 2026 20:28:19 +1000 Subject: [PATCH 079/106] Suppress compatibility warning for terminal interactions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- src/Aspire.Hosting/CompatibilitySuppressions.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index 73f0fbf7966..65539523776 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -1,4 +1,4 @@ - + @@ -15,4 +15,11 @@ lib/net8.0/Aspire.Hosting.dll true + + CP0006 + M:Aspire.Hosting.IInteractionService.PromptTerminalAsync(System.String,Aspire.Hosting.Terminals.AspireTerminal,Aspire.Hosting.TerminalInteractionOptions,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + \ No newline at end of file From b1fa9876060c4a95304e6746c96069de625be903 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 17 Sep 2026 10:01:28 +1000 Subject: [PATCH 080/106] Update paired Hex1b terminal packages to 0.168.0 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- Directory.Packages.props | 2 +- docs/specs/with-terminal.md | 2 +- src/Aspire.Dashboard/package-lock.json | 8 ++++---- src/Aspire.Dashboard/package.json | 2 +- src/Aspire.Dashboard/wwwroot/js/README.md | 4 ++-- .../wwwroot/js/hex1b-web-terminal/package.json | 2 +- .../JavaScript/TerminalView.test.mjs | 4 ++-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index c057fbefad9..8626a932960 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,7 +117,7 @@ - + diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 20d6fa713e2..84ab5ba5d2f 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -273,7 +273,7 @@ it does not lock the terminal, its creator's automation, or other viewers. ### Browser requirements and package pairing The dashboard uses `@hex1b/web-terminal` and the `Hex1b` NuGet package at -exactly `0.168.0-alpha.1585.1.1de7974`. HWT1 is experimental state transfer +exactly `0.168.0`. HWT1 is experimental state transfer between these paired packages, not a stable wire contract implemented by Aspire. Upgrade both together. The full npm `dist` tree is vendored, including module workers, relative imports, fonts and licenses. diff --git a/src/Aspire.Dashboard/package-lock.json b/src/Aspire.Dashboard/package-lock.json index 925395bdea5..02849df0d25 100644 --- a/src/Aspire.Dashboard/package-lock.json +++ b/src/Aspire.Dashboard/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "aspire-dashboard-assets", "dependencies": { - "@hex1b/web-terminal": "0.168.0-alpha.1585.1.1de7974" + "@hex1b/web-terminal": "0.168.0" } }, "node_modules/@hex1b/web-terminal": { - "version": "0.168.0-alpha.1585.1.1de7974", - "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.168.0-alpha.1585.1.1de7974.tgz", - "integrity": "sha512-9mBe3106v9d3a+ewrSNjKI+z9dhQ99vYJSh2PNovAgp75BTzRUj9o96f1SlsG4go/xAK41uAVhqe6qKKRqgaGg==", + "version": "0.168.0", + "resolved": "https://registry.npmjs.org/@hex1b/web-terminal/-/web-terminal-0.168.0.tgz", + "integrity": "sha512-NojZVe3O74TvCcua6YMY18JdV5xZbKFGRkyWuZYau8bUbgxkXSe9UzpRPeDOmTyLcPlGkO69Kc36PxMbbFbGSw==", "license": "MIT", "engines": { "node": ">=22" diff --git a/src/Aspire.Dashboard/package.json b/src/Aspire.Dashboard/package.json index f23d5c265e6..f8bbf01d2b1 100644 --- a/src/Aspire.Dashboard/package.json +++ b/src/Aspire.Dashboard/package.json @@ -8,6 +8,6 @@ "test": "node --test ../../tests/Aspire.Dashboard.Components.Tests/JavaScript/*.test.mjs" }, "dependencies": { - "@hex1b/web-terminal": "0.168.0-alpha.1585.1.1de7974" + "@hex1b/web-terminal": "0.168.0" } } diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index 7dd2460fd1e..ff3eb1f7a85 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -18,8 +18,8 @@ If we ever want to show more chart types than those, we'll need to change the bu ## Hex1b web terminal -`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.168.0-alpha.1585.1.1de7974**, -paired with the Hex1b NuGet package **0.168.0-alpha.1585.1.1de7974**. The client and server use the evolving +`hex1b-web-terminal/` vendors `@hex1b/web-terminal` **0.168.0**, +paired with the Hex1b NuGet package **0.168.0**. The client and server use the evolving HWT1 presentation transport and must be updated together. Do not substitute a different client based only on a similar version number. diff --git a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json index b7a57129231..1a85e685509 100644 --- a/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json +++ b/src/Aspire.Dashboard/wwwroot/js/hex1b-web-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@hex1b/web-terminal", - "version": "0.168.0-alpha.1585.1.1de7974", + "version": "0.168.0", "description": "First-party WebGPU and WebGL2 browser terminal for Hex1b", "type": "module", "main": "./dist/index.js", diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 3b1eba18b65..73508fbc841 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -1055,13 +1055,13 @@ test("frontend manifest, lockfile, vendored package and backend use the exact pa const lockfile = JSON.parse(await readFile(new URL("package-lock.json", dashboard), "utf8")); const vendored = JSON.parse(await readFile(new URL("package.json", assets), "utf8")); const version = manifest.dependencies["@hex1b/web-terminal"]; - assert.equal(version, "0.168.0-alpha.1585.1.1de7974"); + assert.equal(version, "0.168.0"); assert.equal(vendored.version, version); assert.equal(lockfile.packages[""].dependencies["@hex1b/web-terminal"], version); assert.equal(lockfile.packages["node_modules/@hex1b/web-terminal"].version, version); // Central package rows have the form: - // + // // Match the exact Include value, not Hex1b.Tool or Hex1b.McpServer; // whitespace, attribute order and either XML quote style are allowed. const packages = await readFile(new URL("../../Directory.Packages.props", dashboard), "utf8"); From 953a73f0e657dc2f228896ffc550d60360f55dd4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 17 Sep 2026 11:41:25 +1000 Subject: [PATCH 081/106] Improve terminal focus and diagnostics with bundled ConPTY Discover the bundled Windows native provider in repository build outputs. Focus interactive terminals on activation and after mouse-driven footer actions while preserving keyboard control focus. Log local input and clipboard failures without showing the dashboard error banner. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Components/Controls/TerminalView.razor | 9 +- .../Components/Controls/TerminalView.razor.cs | 4 +- .../Components/Controls/TerminalView.razor.js | 110 +++++- .../Resources/ConsoleLogs.Designer.cs | 6 + .../Resources/ConsoleLogs.resx | 4 + .../Resources/xlf/ConsoleLogs.cs.xlf | 5 + .../Resources/xlf/ConsoleLogs.de.xlf | 5 + .../Resources/xlf/ConsoleLogs.es.xlf | 5 + .../Resources/xlf/ConsoleLogs.fr.xlf | 5 + .../Resources/xlf/ConsoleLogs.it.xlf | 5 + .../Resources/xlf/ConsoleLogs.ja.xlf | 5 + .../Resources/xlf/ConsoleLogs.ko.xlf | 5 + .../Resources/xlf/ConsoleLogs.pl.xlf | 5 + .../Resources/xlf/ConsoleLogs.pt-BR.xlf | 5 + .../Resources/xlf/ConsoleLogs.ru.xlf | 5 + .../Resources/xlf/ConsoleLogs.tr.xlf | 5 + .../Resources/xlf/ConsoleLogs.zh-Hans.xlf | 5 + .../Resources/xlf/ConsoleLogs.zh-Hant.xlf | 5 + src/Aspire.Dashboard/wwwroot/js/README.md | 17 + src/Aspire.Hosting/Dcp/DcpHost.cs | 48 ++- .../Controls/TerminalViewTests.cs | 30 +- .../JavaScript/TerminalView.test.mjs | 353 +++++++++++++++++- .../Shared/TerminalSetupHelpers.cs | 1 + .../Dcp/DcpHostNotificationTests.cs | 45 ++- 24 files changed, 645 insertions(+), 47 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index aba2318f612..614b2a9c163 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -33,7 +33,14 @@ {
@GetErrorMessage() - @Loc[nameof(Resources.ConsoleLogs.TerminalRetry)] + @if (_terminalError is "input-failed" or "sizing-failed") + { + @Loc[nameof(Resources.ConsoleLogs.TerminalDismissError)] + } + else + { + @Loc[nameof(Resources.ConsoleLogs.TerminalRetry)] + }
} diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 2ffce9b061a..5ca6a3e8b43 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -388,7 +388,7 @@ public async Task> GetSizePresetsAsync() /// Requests a fresh state notification even if the state has not changed. public Task RefreshToolbarStateAsync() => InvokeTerminalAsync("refreshToolbarState"); - /// Starts a deferred mount or refreshes a view that became visible without reconnecting. + /// Starts or refreshes a view that became visible and focuses its input without reconnecting. public Task RefreshLayoutAsync() => InvokeTerminalAsync("refreshLayout"); private async Task InvokeTerminalAsync(string method, params object?[] arguments) @@ -423,6 +423,8 @@ private string BuildWebSocketUrl(string pathAndQuery) _ => nameof(Resources.ConsoleLogs.TerminalMountFailed) }]; + private Task DismissErrorAsync() => InvokeTerminalAsync("dismissError"); + private async Task RetryAsync() { if (_reconciling || _disposed) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 56ef9704130..b9cd271c516 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -28,6 +28,28 @@ function isVisible(state) { return state.element.clientWidth > 0 && state.element.clientHeight > 0; } +function requestFocus(state) { + state.focusPending = !state.readOnly && !state.ended; + state.focusOrigin = document.activeElement; +} + +function applyPendingFocus(state) { + // Inactive dock panes retain their dimensions for rendering, but must not take keyboard focus. + if (!state.focusPending || !state.client?.connected || state.readOnly || state.ended || + !isVisible(state) || state.element.closest("[inert]") || + getComputedStyle(state.element).visibility !== "visible") { + return; + } + state.focusPending = false; + const activeElement = document.activeElement; + // Mounting can take time. Honor a user who moved to another control while awaiting the first frame. + if (!activeElement || activeElement === document.body || activeElement === state.focusOrigin || + state.element.contains(activeElement)) { + state.client.focus(); + } + state.focusOrigin = null; +} + function notifyToolbar(state) { if (state.disposed || state.toolbarFrame !== null) { return; @@ -67,7 +89,9 @@ function cancelReconnect(state) { } function releaseClient(state) { - state.restoreFocus ||= !!state.client?.element.contains(document.activeElement); + if (state.client?.element.contains(document.activeElement)) { + requestFocus(state); + } const controller = state.controller; const client = state.client; state.controller = null; @@ -133,9 +157,48 @@ function connectionClosed(state, generation, details) { } function inputFailed(state, error) { - console.warn("Dashboard terminal input failed.", error); - state.error = "input-failed"; - notifyToolbar(state); + // Selection resolution, clipboard permissions and focus changes can all reject a local action + // without breaking the terminal. Record context, never clipboard/selection text or a reconnect banner. + const policy = document.permissionsPolicy ?? document.featurePolicy; + console.log("Dashboard terminal input failed.", error, { + selectionStatus: state.client?.selection?.status ?? null, + viewportPending: state.client?.viewport?.pending ?? null, + secureContext: window.isSecureContext, + documentFocused: document.hasFocus(), + visibilityState: document.visibilityState, + userActivation: navigator.userActivation?.isActive ?? null, + clipboardReadAvailable: typeof navigator.clipboard?.readText === "function", + clipboardWriteAvailable: typeof navigator.clipboard?.write === "function", + clipboardReadAllowedByPolicy: policy?.allowsFeature("clipboard-read") ?? null, + clipboardWriteAllowedByPolicy: policy?.allowsFeature("clipboard-write") ?? null, + }); +} + +function focusAfterMouseControl(state, event) { + // Keyboard/assistive activation has detail 0; keep focus for repeated keyboard adjustments. + // https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#usage_notes + if (event.detail === 0 || event.button !== 0 || (event.pointerType && event.pointerType !== "mouse")) { + return; + } + const path = event.composedPath(); + const control = path.find(element => element.matches?.( + ".terminal-font-minus, .terminal-font-plus, .terminal-fit, .terminal-size-select")); + if (!control || control.disabled || + (control.matches(".terminal-size-select") && !path.some(element => element.matches?.("fluent-option")))) { + return; + } + const generation = state.generation; + const focusOrigin = document.activeElement; + // Let Fluent finish updating focus after selection. A picker trigger click alone never reaches here. + requestAnimationFrame(() => { + if (!isCurrent(state, generation) || + (document.activeElement !== focusOrigin && document.activeElement !== document.body && + !control.contains(document.activeElement))) { + return; + } + requestFocus(state); + applyPendingFocus(state); + }); } function selectionCopyPosition(rects, canvasSize, width, height) { @@ -208,10 +271,6 @@ function createSelectionUI(state, current) { actions.hidden = true; state.client.clearSelection(); state.client.focus(); - if (state.error === "input-failed") { - state.error = null; - notifyToolbar(state); - } }).catch(error => { if (current() && !signal.aborted && detail.selection.requestId === requestId) { inputFailed(state, error); @@ -331,7 +390,9 @@ async function mountClient(state, generation, controller) { return; } if (state.client?.connected) { - inputFailed(state, message); + console.warn("Dashboard terminal status error.", message); + state.error = "input-failed"; + notifyToolbar(state); } else { connectionFailed(state, generation, message); } @@ -380,11 +441,7 @@ async function mountClient(state, generation, controller) { state.sizing = client.sizing; state.error = null; state.attempts = 0; - if (state.restoreFocus && isVisible(state) && - (!document.activeElement || document.activeElement === document.body || state.element.contains(document.activeElement))) { - client.focus(); - } - state.restoreFocus = false; + applyPendingFocus(state); applyAutoFit(state); applyPendingSizing(state); notifyToolbar(state); @@ -466,10 +523,13 @@ export function initTerminal(element, wsUrl, dotNetRef, options, selectionTempla toolbarFrame: null, lastToolbarJson: null, waitingForVisibility: false, - restoreFocus: false, + focusPending: !options.readOnly, + focusOrigin: document.activeElement, failurePending: false, listeners: new AbortController(), }; + footer.addEventListener("click", event => focusAfterMouseControl(state, event), + { signal: state.listeners.signal }); footer.addEventListener("keydown", event => { if (event.key === "F6" && !event.ctrlKey && !event.altKey && !event.metaKey) { event.preventDefault(); @@ -482,6 +542,7 @@ export function initTerminal(element, wsUrl, dotNetRef, options, selectionTempla connectClient(state); } else if (!state.disposed) { applyAutoFit(state); + applyPendingFocus(state); } }); state.observer.observe(element); @@ -499,6 +560,7 @@ export function reconnectTerminal(id, wsUrl) { state.ended = false; state.attempts = 0; state.error = null; + requestFocus(state); connectClient(state); return state.generation; } @@ -550,8 +612,24 @@ export function setAutoFit(id, autoFit) { state.autoFitPending = autoFit; if (!autoFit) { state.pendingSizing = null; + state.focusPending = false; + } else { + requestFocus(state); } applyAutoFit(state); + applyPendingFocus(state); +} + +export function dismissError(id) { + const state = terminals.get(id); + if (!state || (state.error !== "input-failed" && state.error !== "sizing-failed")) { + return; + } + // Clipboard/input and sizing failures are local actions, not transport failures. + state.error = null; + requestFocus(state); + applyPendingFocus(state); + notifyToolbar(state); } export function fitToContainer(id) { @@ -662,11 +740,13 @@ export function refreshLayout(id) { if (!state || state.ended || !isVisible(state)) { return; } + requestFocus(state); if (state.waitingForVisibility) { connectClient(state); } else { applyAutoFit(state); // The package observes this container; revealing a view must not reconnect or discard its history. state.client?.refreshSelectionUI(); + applyPendingFocus(state); } } diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index a04a32475d6..c7931f60a9a 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -86,6 +86,12 @@ public static string TerminalRetry { return ResourceManager.GetString("TerminalRetry", resourceCulture); } } + + public static string TerminalDismissError { + get { + return ResourceManager.GetString("TerminalDismissError", resourceCulture); + } + } public static string ConsoleLogsSelectResourceToolbar { get { diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index 2aacf4c17dc..d2612711654 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -217,6 +217,10 @@ Reconnect terminal + + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + Increase font size diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf index 67c1b89671b..553113e3427 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf index 5664523841d..140bcef36c3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf index e857b218c41..1f2f00dbe96 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf index ce7615a67c8..689c284f30d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf index 1d76fa6641c..6e456d2b612 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf index 1bc8e0e171d..8dfcce0d693 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf index 511c030b5d4..976a7727311 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf index 48a95ab3b14..d76833f1fb5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf index 20844306ef3..644f1aa9a1e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf index a59c8bd45f3..e030e6cd77b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf index d4de1bd65ba..518746a3da7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf index 9a68e4c27d6..1689ffc3841 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf index 8ac8694835c..4acfebca5a8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf @@ -142,6 +142,11 @@ The terminal disconnected and automatic retries have stopped. Check that the resource is running, then retry. + + Dismiss + Dismiss + Dismisses a terminal input, clipboard, or sizing error without reconnecting. + F6: Focus terminal controls F6: Focus terminal controls diff --git a/src/Aspire.Dashboard/wwwroot/js/README.md b/src/Aspire.Dashboard/wwwroot/js/README.md index ff3eb1f7a85..bc85e81a8e8 100644 --- a/src/Aspire.Dashboard/wwwroot/js/README.md +++ b/src/Aspire.Dashboard/wwwroot/js/README.md @@ -84,6 +84,23 @@ only this view, never the server-side producer. Sizing changes explicitly reques primary when necessary and wait for role confirmation; normal input does not take resize ownership. Public font-size limits are 8–32 pixels. +Opening an interactive terminal or activating its view focuses its keyboard input +once it is ready. Inactive dock panes do not take focus, and an asynchronous mount +does not take focus back from a control the user selected while it was loading. +Mouse clicks on the font stepper, Fit button, or a dimensions option return focus +to terminal input; keyboard activation keeps focus on the control for repeated +adjustments. Opening the dimensions picker keeps focus until an option is chosen. +Input and clipboard action failures are logged with `console.log` without an Aspire +banner. Diagnostics include the exception, selection status, document focus, and +clipboard permissions policy, never clipboard or selected text. These failures can +include pending selection resolution before the browser clipboard API is called; +they do not necessarily mean clipboard permission was denied. +Hex1b 0.168.0 also displays its own inspection status inside its shadow root. +Its public API does not currently expose an option to suppress that native message. +Other terminal status and sizing errors offer **Dismiss**, which clears the local +error and returns focus without reconnecting or discarding terminal history. +Only connection/initialization failures offer **Reconnect terminal**. + Native `onClose` reports transport closure even before mounting completes. Aspire reserves WebSocket close code `4000` for authoritative AppHost producer completion; normal closure, abnormal disconnects, close reasons and `wasClean` diff --git a/src/Aspire.Hosting/Dcp/DcpHost.cs b/src/Aspire.Hosting/Dcp/DcpHost.cs index 79425ec8f88..8519f255430 100644 --- a/src/Aspire.Hosting/Dcp/DcpHost.cs +++ b/src/Aspire.Hosting/Dcp/DcpHost.cs @@ -387,7 +387,11 @@ private void ConfigureBundledConPty(IDictionary environmentVaria return; } - if (TryGetBundledConPtyPath(_dcpOptions.TerminalHostPath, RuntimeInformation.OSArchitecture, out var conPtyPath)) + if (TryGetBundledConPtyPath( + _dcpOptions.TerminalHostPath, + RuntimeInformation.ProcessArchitecture, + RuntimeInformation.OSArchitecture, + out var conPtyPath)) { environmentVariables[DcpConPtyPathEnvironmentVariable] = conPtyPath; _logger.LogDebug("Configured DCP to use the bundled ConPTY provider at '{ConPtyPath}'.", conPtyPath); @@ -401,7 +405,11 @@ private void ConfigureBundledConPty(IDictionary environmentVaria } } - internal static bool TryGetBundledConPtyPath(string? terminalHostPath, Architecture osArchitecture, [NotNullWhen(true)] out string? conPtyPath) + internal static bool TryGetBundledConPtyPath( + string? terminalHostPath, + Architecture processArchitecture, + Architecture osArchitecture, + [NotNullWhen(true)] out string? conPtyPath) { conPtyPath = null; if (string.IsNullOrWhiteSpace(terminalHostPath) || @@ -410,22 +418,46 @@ internal static bool TryGetBundledConPtyPath(string? terminalHostPath, Architect return false; } - var architectureDirectory = osArchitecture switch + var nativeHostDirectory = osArchitecture switch { Architecture.X64 => "x64", Architecture.Arm64 => "arm64", _ => null }; - if (architectureDirectory is null || - !File.Exists(Path.Combine(directory, "conpty.dll")) || - !File.Exists(Path.Combine(directory, architectureDirectory, "OpenConsole.exe"))) + var runtimeIdentifier = processArchitecture switch + { + Architecture.X64 => "win-x64", + Architecture.Arm64 => "win-arm64", + _ => null + }; + + if (nativeHostDirectory is null || runtimeIdentifier is null) { return false; } - conPtyPath = directory; - return true; + // Shipped CLI bundles flatten the selected RID's native assets into managed/. Repo-local portable builds + // keep every RID under runtimes//native. In both layouts conpty.dll must match the DCP/AppHost process + // architecture, while OpenConsole.exe must match the native Windows architecture (for example, an x64 + // process running under emulation on ARM64 Windows uses win-x64/conpty.dll with arm64/OpenConsole.exe). + string[] candidates = + [ + directory, + Path.Combine(directory, "runtimes", runtimeIdentifier, "native") + ]; + + foreach (var candidate in candidates) + { + if (File.Exists(Path.Combine(candidate, "conpty.dll")) && + File.Exists(Path.Combine(candidate, nativeHostDirectory, "OpenConsole.exe"))) + { + conPtyPath = candidate; + return true; + } + } + + return false; } private void SetDcpProfilingEnvironment(IDictionary environmentVariables) diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 6db9dd98d78..c2255c97c7e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -214,7 +214,35 @@ await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalTool })); var loc = Services.GetRequiredService>(); Assert.Equal(loc[resourceKey].Value, cut.Find("[role=alert]").TextContent); - Assert.Single(cut.FindAll(".terminal-error fluent-button")); + var button = Assert.Single(cut.FindAll(".terminal-error fluent-button")); + Assert.Equal(error is "input-failed" or "sizing-failed" + ? Resources.ConsoleLogs.TerminalDismissError + : Resources.ConsoleLogs.TerminalRetry, button.TextContent.Trim()); + } + + [Theory] + [InlineData("input-failed")] + [InlineData("sizing-failed")] + public async Task DismissActionError_PreservesTheConnection(string error) + { + var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); + module.Setup("initTerminal", _ => true).SetResult(1); + var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "shell")); + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Connected = true, Error = error + })); + + cut.Find(".terminal-error fluent-button").Click(); + + Assert.Equal(new object?[] { 1 }, Assert.Single(module.Invocations, i => i.Identifier == "dismissError").Arguments); + Assert.Equal(["initTerminal", "getSizePresets", "dismissError"], module.Invocations.Select(i => i.Identifier)); + + await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState + { + TerminalId = 1, Generation = 1, Connected = true + })); + Assert.Empty(cut.FindAll(".terminal-error")); } [Theory] diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 73508fbc841..30c1414c1fb 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -32,6 +32,7 @@ function setGlobal(name, value) { beforeEach(() => { mock.method(console, "warn", () => {}); + mock.method(console, "log", () => {}); attempts = []; observers = []; timers = new Map(); @@ -41,7 +42,8 @@ beforeEach(() => { serial = 0; setGlobal("window", { isSecureContext: true }); setGlobal("navigator", { gpu: {} }); - setGlobal("document", { activeElement: null, body: {} }); + setGlobal("document", { activeElement: null, body: {}, hasFocus: () => true, visibilityState: "visible" }); + setGlobal("getComputedStyle", element => ({ visibility: element.visibility ?? "visible" })); setGlobal("requestAnimationFrame", callback => { frames.set(++serial, callback); return serial; @@ -65,7 +67,7 @@ beforeEach(() => { WebTerminal.mount = (element, options) => { const ready = Promise.withResolvers(); const client = { - element: { contains: value => value === client.element }, + element: { parentElement: element, contains: value => value === client.element }, connected: true, peer: { id: "browser-1", primaryId: "cli-1", isPrimary: false }, geometry: { columns: 100, rows: 30 }, @@ -174,7 +176,8 @@ function mount({ visible = true, dotNetRef, options = {} } = {}) { const element = { clientWidth: visible ? 800 : 0, clientHeight: visible ? 600 : 0, - contains: value => value === element, + contains: value => value === element || value?.parentElement === element, + closest: () => null, }; const controls = []; const template = { firstElementChild: { cloneNode() { @@ -182,8 +185,10 @@ function mount({ visible = true, dotNetRef, options = {} } = {}) { controls.push(control); return control.actions; } } }; - const footerControls = [0, 1, 2].map(() => ({ + const footerControls = ["terminal-font-minus", "terminal-font-plus", "terminal-fit", "terminal-size-select"].map(className => ({ disabled: false, tabIndex: 0, + matches: selector => selector.split(", ").includes(`.${className}`), + contains(element) { return element === this; }, focus() { document.activeElement = this; }, })); const footer = Object.assign(new EventTarget(), { @@ -277,7 +282,7 @@ test("selection controls update in place and hide when no selected text is visib }, }); selectionEvent(attempts[0], { selection: { status: "none" } }); - assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(attempts[0].client.focusCalls, 2); assert.equal(controls.length, 1); }); @@ -301,7 +306,7 @@ test("copy dismisses the copied selection and returns focus for immediate termin assert.equal(button.attributes.get("aria-busy"), "true"); assert.equal(attempts[0].client.primaryRequests, 0); assert.equal(attempts[0].client.selectionClears, 0); - assert.equal(attempts[0].client.focusCalls, 0); + assert.equal(attempts[0].client.focusCalls, 1); assert.equal(actions.hidden, false); copy.resolve(""); await settle(); @@ -310,7 +315,7 @@ test("copy dismisses the copied selection and returns focus for immediate termin assert.equal(button.attributes.get("aria-busy"), "false"); assert.equal(actions.hidden, true); assert.equal(attempts[0].client.selectionClears, 1); - assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(attempts[0].client.focusCalls, 2); assert.equal(document.activeElement, attempts[0].client.element); selectionEvent(attempts[0], { selection: { requestId: 2 } }); assert.equal(actions.hidden, false); @@ -331,18 +336,20 @@ test("selection controls clamp within a small canvas and follow updated CSS-pixe assert.equal(controls.length, 1); }); -test("copy failures remain local and a successful retry clears the error", async () => { +test("copy failures are console-only and leave the selection available for retry", async () => { const { id, controls } = mount(); attempts[0].resolve(); await settle(); - selectionEvent(attempts[0], { runAction: () => Promise.reject(new Error("Clipboard denied")) }); + const error = new Error("Clipboard unavailable"); + selectionEvent(attempts[0], { runAction: () => Promise.reject(error) }); controls[0].button.dispatchEvent(new Event("click")); await settle(); - assert.equal(terminal.getToolbarState(id).error, "input-failed"); + assert.equal(terminal.getToolbarState(id).error, null); + assert.equal(console.log.mock.calls.at(-1).arguments[1], error); assert.equal(controls[0].button.disabled, false); assert.equal(controls[0].actions.hidden, false); assert.equal(attempts[0].client.selectionClears, 0); - assert.equal(attempts[0].client.focusCalls, 0); + assert.equal(attempts[0].client.focusCalls, 1); assert.equal(attempts.length, 1); assert.equal(timers.size, 0); selectionEvent(attempts[0]); @@ -351,7 +358,7 @@ test("copy failures remain local and a successful retry clears the error", async assert.equal(terminal.getToolbarState(id).error, null); assert.equal(controls[0].actions.hidden, true); assert.equal(attempts[0].client.selectionClears, 1); - assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(attempts[0].client.focusCalls, 2); }); test("changing selection while copying does not dismiss the new selection or steal focus", async () => { @@ -415,6 +422,100 @@ test("init returns an id while mount waits for its first connected frame", async assert.equal(attempts[0].options.readOnly, false); }); +test("opening a terminal focuses input after the first frame without taking primary", async () => { + document.activeElement = { tagName: "BUTTON" }; + mount(); + assert.equal(attempts[0].client.focusCalls, 0); + attempts[0].resolve(); + await settle(); + assert.equal(document.activeElement, attempts[0].client.element); + assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(attempts[0].client.primaryRequests, 0); + + const otherControl = { tagName: "INPUT" }; + document.activeElement = otherControl; + observers[0].callback(); + attempts[0].role(true); + await settle(); + assert.equal(document.activeElement, otherControl); + assert.equal(attempts[0].client.focusCalls, 1); +}); + +test("a delayed mount does not steal focus from a newly selected control", async () => { + document.activeElement = { tagName: "BUTTON" }; + mount(); + const otherControl = { tagName: "INPUT" }; + document.activeElement = otherControl; + attempts[0].resolve(); + await settle(); + observers[0].callback(); + assert.equal(document.activeElement, otherControl); + assert.equal(attempts[0].client.focusCalls, 0); +}); + +test("a mount becoming ready after another terminal does not steal its focus", async () => { + mount(); + mount(); + attempts[1].resolve(); + await settle(); + attempts[0].resolve(); + await settle(); + assert.equal(document.activeElement, attempts[1].client.element); + assert.equal(attempts[0].client.focusCalls, 0); +}); + +test("inactive dock panes wait for activation before focusing and do not remount", async () => { + const { id, element } = mount({ options: { showDimensions: false } }); + const pane = {}; + element.closest = selector => selector === "[inert]" ? pane : null; + attempts[0].resolve(); + await settle(); + assert.equal(attempts[0].client.focusCalls, 0); + + element.closest = () => null; + document.activeElement = { tagName: "BUTTON" }; + terminal.setAutoFit(id, true); + assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(document.activeElement, attempts[0].client.element); + terminal.setAutoFit(id, true); + observers[0].callback(); + assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(attempts.length, 1); +}); + +test("hidden and read-only terminals do not take focus", async () => { + const hidden = mount(); + hidden.element.visibility = "hidden"; + const readOnly = mount({ options: { readOnly: true } }); + attempts[0].resolve(); + attempts[1].resolve(); + await settle(); + assert.equal(attempts[0].client.focusCalls, 0); + assert.equal(attempts[1].client.focusCalls, 0); + terminal.refreshLayout(readOnly.id); + assert.equal(attempts[1].client.focusCalls, 0); + + hidden.element.visibility = "visible"; + terminal.refreshLayout(hidden.id); + assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(attempts.length, 2); +}); + +test("returning to the terminal view restores focus without replacing its client", async () => { + const { id, element } = mount(); + attempts[0].resolve(); + await settle(); + element.clientWidth = 0; + document.activeElement = { tagName: "BUTTON" }; + terminal.refreshLayout(id); + assert.equal(attempts[0].client.focusCalls, 1); + element.clientWidth = 800; + terminal.refreshLayout(id); + assert.equal(document.activeElement, attempts[0].client.element); + assert.equal(attempts[0].client.focusCalls, 2); + assert.equal(attempts.length, 1); +}); + test("missing WebGPU and ordinary HTTP leave renderer selection to the package", async () => { navigator.gpu = undefined; const first = mount(); @@ -530,17 +631,148 @@ test("sizing requests primary, waits for confirmation, and clamps to the public assert.equal(terminal.getToolbarState(id).fontControlsEnabled, false); }); -test("clipboard errors remain visible without discarding the mounted history", async () => { +for (const error of [ + new DOMException("Read permission denied.", "NotAllowedError"), + new Error("Resolving selection\u2026"), + new Error("Timed out resolving selection. Copy again."), + new Error("Clipboard unavailable"), + new Error("Terminal input, selection, focus, or buffer changed while reading the clipboard. Paste again."), +]) { + test(`input failure is console-only: ${error.message}`, async () => { + const { element } = mount(); + attempts[0].resolve(); + await settle(); + const client = attempts[0].client; + client.selection = { status: "pending", text: "Private selected text" }; + client.viewport = { pending: false }; + const before = terminal.getTerminalSnapshot(element); + attempts[0].options.onInputError(error); + await settle(); + assert.deepEqual(terminal.getTerminalSnapshot(element), before); + assert.equal(snapshots.at(-1).error, null); + assert.deepEqual(console.log.mock.calls.at(-1).arguments, ["Dashboard terminal input failed.", error, { + selectionStatus: "pending", + viewportPending: false, + secureContext: true, + documentFocused: true, + visibilityState: "visible", + userActivation: null, + clipboardReadAvailable: false, + clipboardWriteAvailable: false, + clipboardReadAllowedByPolicy: null, + clipboardWriteAllowedByPolicy: null, + }]); + assert.equal(client.disposed, false); + assert.equal(client.focusCalls, 1); + assert.equal(client.selectionClears, 0); + assert.equal(client.primaryRequests, 0); + assert.equal(timers.size, 0); + }); +} + +test("input diagnostics distinguish browser policy and focus without reading the clipboard", async () => { const { id } = mount(); attempts[0].resolve(); await settle(); - attempts[0].options.onInputError(new Error("Clipboard denied")); + document.hasFocus = () => false; + document.visibilityState = "hidden"; + document.featurePolicy = { allowsFeature: feature => feature === "clipboard-write" }; + navigator.userActivation = { isActive: false }; + navigator.clipboard = { + readText() { assert.fail("Diagnostics must not read the clipboard"); }, + write() { assert.fail("Diagnostics must not change the clipboard"); }, + }; + attempts[0].options.onInputError(new DOMException("Read permission denied.", "NotAllowedError")); + assert.equal(terminal.getToolbarState(id).error, null); + assert.deepEqual(console.log.mock.calls.at(-1).arguments[2], { + selectionStatus: null, + viewportPending: null, + secureContext: true, + documentFocused: false, + visibilityState: "hidden", + userActivation: false, + clipboardReadAvailable: true, + clipboardWriteAvailable: true, + clipboardReadAllowedByPolicy: false, + clipboardWriteAllowedByPolicy: true, + }); +}); + +test("selection copy permission denial is logged without covering the terminal", async () => { + const { controls, element } = mount(); + attempts[0].resolve(); await settle(); - assert.equal(terminal.getToolbarState(id).error, "input-failed"); - assert.equal(attempts[0].client.disposed, false); + const error = new DOMException("Write permission denied.", "NotAllowedError"); + selectionEvent(attempts[0], { runAction: () => Promise.reject(error) }); + controls[0].button.dispatchEvent(new Event("click")); + await settle(); + assert.equal(terminal.getTerminalSnapshot(element).error, null); + assert.equal(controls[0].actions.hidden, false); + assert.equal(attempts[0].client.selectionClears, 0); + assert.deepEqual(console.log.mock.calls.at(-1).arguments.slice(0, 2), ["Dashboard terminal input failed.", error]); +}); + +test("clipboard permission denial does not clear an existing sizing error", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + attempts[0].client.requestPrimary = () => { throw new Error("Resize failed"); }; + terminal.fitToContainer(id); + attempts[0].options.onInputError(new DOMException("Read permission denied.", "NotAllowedError")); + assert.equal(terminal.getToolbarState(id).error, "sizing-failed"); +}); + +test("terminal status errors remain visible and dismiss without replacing the client", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + const client = attempts[0].client; + client.screenText = "Retained terminal output"; + attempts[0].options.onStatus("Selection UI failed: invalid control", "error"); + await settle(); + const before = terminal.getTerminalSnapshot(attempts[0].element); + assert.equal(before.error, "input-failed"); + document.activeElement = { tagName: "BUTTON" }; + + terminal.dismissError(id); + await settle(); + + assert.deepEqual(terminal.getTerminalSnapshot(attempts[0].element), { ...before, error: null }); + assert.equal(snapshots.at(-1).error, null); + assert.equal(document.activeElement, client.element); + assert.equal(client.disposed, false); + assert.equal(client.selectionClears, 0); + assert.equal(client.primaryRequests, 0); + assert.equal(attempts.length, 1); assert.equal(timers.size, 0); }); +test("dismissing a sizing error keeps the existing connection", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + attempts[0].client.requestPrimary = () => { throw new Error("Resize failed"); }; + terminal.fitToContainer(id); + assert.equal(terminal.getToolbarState(id).error, "sizing-failed"); + terminal.dismissError(id); + assert.equal(terminal.getToolbarState(id).error, null); + assert.equal(attempts[0].client.disposed, false); + assert.equal(attempts.length, 1); +}); + +test("a delayed dismiss cannot hide a connection failure or cancel its retry", async () => { + const { id } = mount(); + attempts[0].resolve(); + await settle(); + attempts[0].options.onInputError(new Error("Clipboard unavailable")); + attempts[0].close(1006); + await settle(); + terminal.dismissError(id); + assert.equal(terminal.getToolbarState(id).error, "mount-failed"); + assert.equal(timers.size, 1); + assert.equal(attempts[0].client.disposed, true); +}); + test("remote role changes authoritatively switch primary, viewer and unclaimed states", async () => { const { id } = mount(); attempts[0].resolve(); @@ -620,6 +852,92 @@ test("automatic retries are bounded and explicit reconnect resets the exhausted assert.equal(retry(), 500); }); +function clickFooter(footer, control, { selectOption = false, ...options } = {}) { + const option = { matches: selector => selector === "fluent-option" }; + const event = Object.assign(new Event("click"), { + button: 0, detail: 1, pointerType: "mouse", + composedPath: () => selectOption ? [option, control, footer] : [control, footer], + ...options, + }); + footer.dispatchEvent(event); +} + +for (const [name, index] of [["font decrease", 0], ["font increase", 1], ["Fit", 2], ["dimensions", 3]]) { + test(`mouse activation of ${name} returns focus while keyboard activation leaves it in place`, async () => { + const { footer, footerControls } = mount(); + attempts[0].resolve(); + await settle(); + const control = footerControls[index]; + control.focus(); + clickFooter(footer, control, { selectOption: index === 3 }); + await settle(); + assert.equal(document.activeElement, attempts[0].client.element); + assert.equal(attempts[0].client.focusCalls, 2); + + for (let i = 0; i < 2; i++) { + control.focus(); + clickFooter(footer, control, { selectOption: index === 3, detail: 0, pointerType: "" }); + await settle(); + assert.equal(document.activeElement, control); + } + assert.equal(attempts[0].client.focusCalls, 2); + }); +} + +test("the dimensions picker keeps focus while open and returns it after mouse selection", async () => { + const { footer, footerControls } = mount(); + attempts[0].resolve(); + await settle(); + const select = footerControls[3]; + select.focus(); + clickFooter(footer, select); + await settle(); + assert.equal(document.activeElement, select); + clickFooter(footer, select, { selectOption: true }); + await settle(); + assert.equal(document.activeElement, attempts[0].client.element); +}); + +test("mouse focus restoration respects read-only, inactive and disabled controls", async () => { + const { id, footer, footerControls, element } = mount(); + attempts[0].resolve(); + await settle(); + const control = footerControls[0]; + control.focus(); + control.disabled = true; + clickFooter(footer, control); + await settle(); + assert.equal(document.activeElement, control); + control.disabled = false; + terminal.setReadOnly(id, true); + clickFooter(footer, control); + await settle(); + assert.equal(document.activeElement, control); + terminal.setReadOnly(id, false); + element.closest = () => ({ inert: true }); + clickFooter(footer, control); + await settle(); + assert.equal(document.activeElement, control); +}); + +test("a mouse click cannot steal focus after another control is selected or the view is disposed", async () => { + const { id, footer, footerControls } = mount(); + attempts[0].resolve(); + await settle(); + footerControls[0].focus(); + clickFooter(footer, footerControls[0]); + footerControls[1].focus(); + await settle(); + assert.equal(document.activeElement, footerControls[1]); + clickFooter(footer, footerControls[1]); + terminal.disposeTerminal(id); + await settle(); + assert.equal(document.activeElement, footerControls[1]); + clickFooter(footer, footerControls[1]); + await settle(); + assert.equal(document.activeElement, footerControls[1]); +}); + test("F6 focuses the footer and Shift+F6 focuses the preceding dashboard control", async () => { const { element, footer, footerControls } = mount(); const previous = { @@ -632,7 +950,6 @@ test("F6 focuses the footer and Shift+F6 focuses the preceding dashboard control element.closest = () => null; document.querySelectorAll = () => [previous]; setGlobal("Node", { DOCUMENT_POSITION_FOLLOWING: 4 }); - setGlobal("getComputedStyle", () => ({ visibility: "visible" })); attempts[0].resolve(); await settle(); const onInput = attempts[0].options.onInput; @@ -669,7 +986,7 @@ test("disposing unregisters the footer focus listener", async () => { const event = Object.assign(new Event("keydown", { cancelable: true }), { key: "F6" }); footer.dispatchEvent(event); assert.equal(event.defaultPrevented, false); - assert.equal(attempts[0].client.focusCalls, 0); + assert.equal(attempts[0].client.focusCalls, 1); }); test("font preference follows its surface across remounts but not another surface", async () => { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 793b6cf149c..3ced8c3fe26 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -41,6 +41,7 @@ public static BunitJSModuleInterop SetupTerminalViewModule(TestContext context, var module = context.JSInterop.SetupModule(modulePath); module.Setup("reconnectTerminal", _ => true).SetResult(2); module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + module.SetupVoid("dismissError", _ => true).SetVoidResult(); module.SetupVoid("refreshLayout", _ => true).SetVoidResult(); module.SetupVoid("setAutoFit", _ => true).SetVoidResult(); module.SetupVoid("fitToContainer", _ => true).SetVoidResult(); diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs index 5a5d0f6b0ff..7e9f8c06fff 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs @@ -574,7 +574,7 @@ public void CreateDcpProcessSpec_WithContainerRuntime_IncludesContainerRuntimeAr [Theory] [InlineData(Architecture.X64, "x64")] [InlineData(Architecture.Arm64, "arm64")] - public void TryGetBundledConPtyPath_WithCompletePayload_ReturnsTerminalHostDirectory(Architecture architecture, string architectureDirectory) + public void TryGetBundledConPtyPath_WithCompleteBundlePayload_ReturnsTerminalHostDirectory(Architecture architecture, string architectureDirectory) { var directory = Directory.CreateTempSubdirectory(); try @@ -585,7 +585,7 @@ public void TryGetBundledConPtyPath_WithCompletePayload_ReturnsTerminalHostDirec Directory.CreateDirectory(Path.Combine(directory.FullName, architectureDirectory)); File.WriteAllText(Path.Combine(directory.FullName, architectureDirectory, "OpenConsole.exe"), ""); - var found = DcpHost.TryGetBundledConPtyPath(terminalHostPath, architecture, out var conPtyPath); + var found = DcpHost.TryGetBundledConPtyPath(terminalHostPath, architecture, architecture, out var conPtyPath); Assert.True(found); Assert.Equal(directory.FullName, conPtyPath); @@ -596,6 +596,41 @@ public void TryGetBundledConPtyPath_WithCompletePayload_ReturnsTerminalHostDirec } } + [Theory] + [InlineData(Architecture.X64, Architecture.X64, "win-x64", "x64")] + [InlineData(Architecture.X64, Architecture.Arm64, "win-x64", "arm64")] + [InlineData(Architecture.Arm64, Architecture.Arm64, "win-arm64", "arm64")] + public void TryGetBundledConPtyPath_WithCompleteRepoPayload_ReturnsRuntimeNativeDirectory( + Architecture processArchitecture, + Architecture osArchitecture, + string runtimeIdentifier, + string nativeHostDirectory) + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var terminalHostPath = Path.Combine(directory.FullName, "aspire-managed.exe"); + File.WriteAllText(terminalHostPath, ""); + var nativeDirectory = Path.Combine(directory.FullName, "runtimes", runtimeIdentifier, "native"); + Directory.CreateDirectory(Path.Combine(nativeDirectory, nativeHostDirectory)); + File.WriteAllText(Path.Combine(nativeDirectory, "conpty.dll"), ""); + File.WriteAllText(Path.Combine(nativeDirectory, nativeHostDirectory, "OpenConsole.exe"), ""); + + var found = DcpHost.TryGetBundledConPtyPath( + terminalHostPath, + processArchitecture, + osArchitecture, + out var conPtyPath); + + Assert.True(found); + Assert.Equal(nativeDirectory, conPtyPath); + } + finally + { + directory.Delete(recursive: true); + } + } + [Theory] [InlineData(false, true)] [InlineData(true, false)] @@ -618,7 +653,11 @@ public void TryGetBundledConPtyPath_WithIncompletePayload_ReturnsFalse(bool incl File.WriteAllText(Path.Combine(directory.FullName, architectureDirectory, "OpenConsole.exe"), ""); } - var found = DcpHost.TryGetBundledConPtyPath(terminalHostPath, RuntimeInformation.OSArchitecture, out var conPtyPath); + var found = DcpHost.TryGetBundledConPtyPath( + terminalHostPath, + RuntimeInformation.ProcessArchitecture, + RuntimeInformation.OSArchitecture, + out var conPtyPath); Assert.False(found); Assert.Null(conPtyPath); From 56da25d587a1dee6f275353b678d7b14e77af440 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 17 Sep 2026 15:33:34 +1000 Subject: [PATCH 082/106] Honor terminal dimensions and address review feedback Preserve requested initial terminal dimensions with an 80x24 default and retain viewer-driven resizing. Own HMP client attachment cleanup and shutdown coordination, with regression coverage.\n\nUse the unmodified backtick dock shortcut, sign the Windows PTY helper, refresh polyglot snapshots, and correct terminal ownership and placement documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 12 +- eng/Signing.props | 1 + .../Terminals/Terminals.AppHost/AppHost.cs | 2 +- .../TerminalInteractionCommands.cs | 2 +- .../Components/Layout/TerminalDock.razor.cs | 6 +- .../Resources/Layout.Designer.cs | 6 +- src/Aspire.Dashboard/Resources/Layout.resx | 6 +- .../Resources/xlf/Layout.cs.xlf | 12 +- .../Resources/xlf/Layout.de.xlf | 12 +- .../Resources/xlf/Layout.es.xlf | 12 +- .../Resources/xlf/Layout.fr.xlf | 12 +- .../Resources/xlf/Layout.it.xlf | 12 +- .../Resources/xlf/Layout.ja.xlf | 12 +- .../Resources/xlf/Layout.ko.xlf | 12 +- .../Resources/xlf/Layout.pl.xlf | 12 +- .../Resources/xlf/Layout.pt-BR.xlf | 12 +- .../Resources/xlf/Layout.ru.xlf | 12 +- .../Resources/xlf/Layout.tr.xlf | 12 +- .../Resources/xlf/Layout.zh-Hans.xlf | 12 +- .../Resources/xlf/Layout.zh-Hant.xlf | 12 +- src/Aspire.Dashboard/wwwroot/js/app.js | 16 +- .../Backchannel/BackchannelDataTypes.cs | 6 +- .../Terminals/Hex1bAspireTerminal.cs | 114 +++++++--- .../Terminals/TerminalLaunchOptions.cs | 14 +- src/Aspire.Hosting/Terminals/TerminalOwner.cs | 5 +- .../Terminals/TerminalPlacement.cs | 5 +- .../Terminals/TerminalService.cs | 9 +- .../JavaScript/KeyboardShortcuts.test.mjs | 33 ++- .../Layout/TerminalDockTests.cs | 4 +- .../Playwright/DashboardInteractionsTests.cs | 27 ++- .../Playwright/TerminalDockTests.cs | 2 +- ...TwoPassScanningGeneratedAspire.verified.go | 3 +- ...oPassScanningGeneratedAspire.verified.java | 5 +- ...TwoPassScanningGeneratedAspire.verified.py | 4 +- ...TwoPassScanningGeneratedAspire.verified.rs | 5 +- .../Dashboard/DashboardServiceTests.cs | 6 +- .../Terminals/Hex1bAspireTerminalTests.cs | 208 +++++++++++++++++- .../InteractionServiceTerminalTests.cs | 4 +- .../Terminals/TerminalLaunchOptionsTests.cs | 6 +- .../Terminals/TerminalServiceTests.cs | 6 +- 40 files changed, 477 insertions(+), 196 deletions(-) diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 84ab5ba5d2f..759f6778a3e 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -54,9 +54,10 @@ terminal.Show(); ``` Here, `app` is the built `DistributedApplication`. Environment entries add to or -override the AppHost's inherited environment. Requested dimensions default to 120 -columns and 32 rows. The current HMP server overrides those initial dimensions to -80 columns and 24 rows; viewer-driven resizing still applies after attachment. +override the AppHost's inherited environment. Initial dimensions default to 80 +columns and 24 rows. The process starts at the requested size, which headless +terminals retain. Dock and interaction dialog viewers resize the grid to fit +their available space when shown. Placement defaults to the dock; use `Dialog` for terminal interactions or `None` for automation-only terminals. @@ -363,6 +364,11 @@ changing the grid. The bottom-left footer hint advertises F6, which m keyboard focus from terminal input to the footer controls; Shift+F6 moves focus to the preceding dashboard control. +Press the backtick key (`), without Shift, to show or hide the terminal +dock. The shortcut is suppressed while a terminal or text input has focus so it +does not consume typed input. Press F6 first to move from terminal input +to its footer controls before toggling the dock. + Dock panes, interaction dialogs and detached windows automatically fit when opened. A detached window takes primary once, carrying the originating view's selected font size rather than its grid dimensions. Its font preference can diff --git a/eng/Signing.props b/eng/Signing.props index 86cbd8076cd..c705407709e 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -32,6 +32,7 @@ + diff --git a/playground/Terminals/Terminals.AppHost/AppHost.cs b/playground/Terminals/Terminals.AppHost/AppHost.cs index a8a5b92b50f..3fda6aec877 100644 --- a/playground/Terminals/Terminals.AppHost/AppHost.cs +++ b/playground/Terminals/Terminals.AppHost/AppHost.cs @@ -38,7 +38,7 @@ .WithContainerName("terminals-playground-shellbox") .WithArgs("sleep", "infinity") .WithContainerShellCommand() - // Same shell, but delivered as a tab in the dashboard's terminal dock (Shift+`) rather than a modal dialog. + // Same shell, but delivered as a tab in the dashboard's terminal dock (backtick shortcut) rather than a modal dialog. .WithDockShellCommand(); // Latest Node.js image, kept alive so the "Node REPL" interaction command can exec into it. The Node REPL is a diff --git a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs index 9d97b0513b2..0f0a91ebb80 100644 --- a/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs +++ b/playground/Terminals/Terminals.AppHost/TerminalInteractionCommands.cs @@ -156,7 +156,7 @@ private static async Task ExecIntoContainerAsync( ///
/// /// This is the counterpart to the terminal interaction commands above. Instead of a modal dialog bound to a single - /// dialog lifetime, the terminal becomes a tab in the dashboard's terminal dock (Shift+`) that outlives the command + /// dialog lifetime, the terminal becomes a tab in the dashboard's terminal dock (backtick shortcut) that outlives the command /// that created it. It also exercises AspireTerminal's automation surface — send input, wait for output, /// read the screen — which is how AppHost code can script a terminal it owns. /// diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs index 944b338e259..2c1afbf73a1 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs @@ -15,7 +15,7 @@ namespace Aspire.Dashboard.Components.Layout; /// -/// A collapsible, tabbed dock of terminals owned by the AppHost process, toggled with Shift+`. +/// A collapsible, tabbed dock of terminals owned by the AppHost process, toggled with `. /// /// /// @@ -112,8 +112,8 @@ public Task OnPageKeyDownAsync(AspireKeyboardShortcut shortcut) /// Shows the dock, or hides it if it is already showing. ///
/// - /// Public so the header button can drive the dock. The keyboard chord alone is not enough: Shift+` is - /// suppressed whenever focus is in a terminal or any other text input, because it types ~ there, so the + /// Public so the header button can drive the dock. The keyboard shortcut alone is not enough: ` is + /// suppressed whenever focus is in a terminal or any other text input, because it types ` there, so the /// dock needs an affordance that works regardless of where focus happens to be. /// public Task ToggleAsync() => InvokeAsync(() => diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index bf2e17d0920..d046db1a9c5 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -160,7 +160,7 @@ public static string TerminalDockPanelHeading { } /// - /// Looks up a localized string similar to Press Shift+` to hide this panel.. + /// Looks up a localized string similar to Press ` to hide this panel.. /// public static string TerminalDockPanelHint { get { @@ -214,7 +214,7 @@ public static string TerminalDockFocusWindow { } /// - /// Looks up a localized string similar to Hide terminal panel (Shift+`). + /// Looks up a localized string similar to Hide terminal panel (`). /// public static string TerminalDockHide { get { @@ -295,7 +295,7 @@ public static string MainLayoutAspireRepoLink { } /// - /// Looks up a localized string similar to Toggle terminal (Shift+`). + /// Looks up a localized string similar to Toggle terminal (`). /// public static string MainLayoutToggleTerminalDock { get { diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index de469fe9b2a..26a5d0ffe4c 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -124,7 +124,7 @@ Help - Toggle terminal (Shift+`) + Toggle terminal (`) Settings @@ -195,7 +195,7 @@ Focus window - Hide terminal panel (Shift+`) + Hide terminal panel (`) {0} pixels high @@ -214,7 +214,7 @@ No terminals - Press Shift+` to hide this panel. + Press ` to hide this panel. More information diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index 9ce0c00e981..9e6e9d04ef8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index ae9f2c60061..da8f9437324 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index a78875720d8..40c7cfa737c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index dda1e041764..b64eef177a5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 1906639aa72..ea1dafaff0b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index 718ab843b66..3a6b50b1250 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 1eeac6f5ec6..1bca9d4b50c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 64d880558fb..0c66862a1df 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index bebd190ac3a..86e8d211943 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 8801940fb15..95f008883da 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 50171a41a3e..5ef664cb74e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index cfbfded9c9b..2a3c3e71811 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index a20adcb5dea..880d093b878 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -78,8 +78,8 @@ - Toggle terminal (Shift+`) - Toggle terminal (Shift+`) + Toggle terminal (`) + Toggle terminal (`) @@ -193,8 +193,8 @@ {0} is the terminal dock height in CSS pixels. - Hide terminal panel (Shift+`) - Hide terminal panel (Shift+`) + Hide terminal panel (`) + Hide terminal panel (`) @@ -213,8 +213,8 @@ - Press Shift+` to hide this panel. - Press Shift+` to hide this panel. + Press ` to hide this panel. + Press ` to hide this panel. diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index 0c9634d8b02..9bd82f70891 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -348,14 +348,6 @@ window.registerGlobalKeydownListener = function (shortcutManager) { function calculateShortcut(e) { if (modifierKeysExceptShiftNotPressed(e)) { - // Match the physical Shift+Backquote gesture across keyboard layouts, not the produced character. - // The focused-input guard runs before this, so terminal and text inputs still receive their keys. - // To toggle from terminal input, press F6 first to focus its controls. - // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code - if (e.shiftKey && e.code === "Backquote") { - return 400; - } - /* general shortcuts */ switch (e.key) { case "?": // help @@ -379,6 +371,14 @@ window.registerGlobalKeydownListener = function (shortcutManager) { } if (hasNoModifiers(e)) { + // Match the unmodified physical Backquote key across keyboard layouts, not the produced character. + // The focused-input guard runs before this, so terminal and text inputs still receive their keys. + // To toggle from terminal input, press F6 first to focus its controls. + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code + if (e.code === "Backquote") { + return 400; + } + switch (e.key) { case "r": // go to resources return 200; diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index e357ae604d6..f696de95df0 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -1818,11 +1818,11 @@ internal sealed class ListTerminalsResponse } /// -/// One terminal whose workload runs in the AppHost process. +/// One terminal whose workload is owned by the AppHost. /// /// -/// These have no replicas and no terminal host: the workload runs in-process and reaches the dashboard over -/// the gRPC tunnel, so there is no liveness to report beyond the terminal's presence in this list. +/// These terminals have no resource replicas or separate terminal host. The AppHost owns the workload +/// and tunnels terminal I/O to the dashboard over gRPC. This summary does not report workload liveness. /// internal sealed class AppHostTerminalSummary { diff --git a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs index e58a980c135..7bbe5b67d10 100644 --- a/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Threading.Channels; using Hex1b; using Hex1b.Automation; using Hex1b.Reflow; @@ -15,16 +14,12 @@ namespace Aspire.Hosting.Terminals; /// The Hex1b-backed implementation of . ///
/// -/// Clients are handed to Hex1b's HMP1 server through a channel, which lets a single terminal serve several -/// attached viewers (for example two dashboard browser tabs, or a dock tab reopened after being closed) -/// using HMP1's multi-head support. The AppHost owns the workload, so terminal state survives a viewer -/// disconnecting entirely. +/// Each viewer is attached to the same HMP1 presentation adapter, allowing several dashboard views to +/// share one terminal. The AppHost owns the workload, so terminal state survives a viewer disconnecting. /// internal sealed class Hex1bAspireTerminal : ITerminalBackend { - // Unbounded because the producer is a viewer attaching; the queue depth is realistically 0 or 1 and - // dropping or blocking an attach would strand the RPC that is waiting to be served. - private readonly Channel _clients = Channel.CreateUnbounded(); + private readonly HashSet _clientTasks = []; // Cancellation requests a stop; workload completion updates viewers; session completion reports that // teardown has finished. Keeping these separate lets viewers display an ended state without allowing @@ -38,18 +33,23 @@ internal sealed class Hex1bAspireTerminal : ITerminalBackend private readonly TerminalService _owner; private readonly Hex1bTerminalBuilder _builder; + private readonly int _columns; + private readonly int _rows; private readonly ILogger _logger; + private Hmp1PresentationAdapter? _presentation; private Hex1bTerminal? _terminal; private Hex1bTerminalAutomator? _automator; private Task? _runTask; private Task? _stopTask; private bool _stopped; - public Hex1bAspireTerminal(TerminalService owner, string id, string title, TerminalPlacement placement, Hex1bTerminalBuilder builder, ILogger logger) + public Hex1bAspireTerminal(TerminalService owner, string id, string title, TerminalPlacement placement, Hex1bTerminalBuilder builder, int columns, int rows, ILogger logger) { _owner = owner; _builder = builder; + _columns = columns; + _rows = rows; _logger = logger; Id = id; Title = title; @@ -116,28 +116,77 @@ public async Task AttachAsync(Stream clientStream, Func // I/O and waits for outstanding accesses, even when Hex1b's other pump is still winding down. var attachment = new TerminalClientStream(clientStream); await using var _ = attachment.ConfigureAwait(false); + using var clientCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled); + var lifetimeEnded = Task.WhenAny(_workloadEnded.Task, attachment.Released, cancelled.Task); + var clientTask = Task.CompletedTask; lock (_gate) { if (!_workloadEnded.Task.IsCompleted) { EnsureStarted(); - if (!_clients.Writer.TryWrite(attachment)) + clientTask = RunClientAsync(_presentation!, attachment, lifetimeEnded, clientCts.Token, _workloadCts.Token); + _clientTasks.Add(clientTask); + } + } + + try + { + await lifetimeEnded.ConfigureAwait(false); + if (_workloadEnded.Task.IsCompleted) + { + cancellationToken.ThrowIfCancellationRequested(); + await onEnded(cancellationToken).ConfigureAwait(false); + } + + await Task.WhenAny(_sessionEnded.Task, attachment.Released, cancelled.Task).ConfigureAwait(false); + } + finally + { + try + { + await clientCts.CancelAsync().ConfigureAwait(false); + await clientTask.ConfigureAwait(false); + } + finally + { + lock (_gate) { - throw new InvalidOperationException($"Terminal '{Id}' is no longer accepting clients."); + _clientTasks.Remove(clientTask); } } } + } - var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled); - await Task.WhenAny(_workloadEnded.Task, attachment.Released, cancelled.Task).ConfigureAwait(false); - if (_workloadEnded.Task.IsCompleted) + private async Task RunClientAsync( + Hmp1PresentationAdapter presentation, + TerminalClientStream attachment, + Task lifetimeEnded, + CancellationToken clientCancellationToken, + CancellationToken workloadCancellationToken) + { + // Keep handshake I/O off the thread holding _gate. Task registration and shutdown's snapshot + // share that lock, so a stalled viewer neither blocks other viewers nor escapes cleanup. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(clientCancellationToken, workloadCancellationToken); + await Task.Yield(); + + try { - cancellationToken.ThrowIfCancellationRequested(); - await onEnded(cancellationToken).ConfigureAwait(false); + var client = await presentation.AddClient(attachment, cts.Token).ConfigureAwait(false); + await using var _ = client.ConfigureAwait(false); + await lifetimeEnded.ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or InvalidOperationException) + { + _logger.LogDebug(ex, "Terminal {TerminalId} viewer connection ended.", Id); + } + finally + { + // AddClient can fail before Hex1b owns the stream (for example before ClientHello). Always + // release our wrapper, but leave the underlying gRPC transport with its caller. + await attachment.DisposeAsync().ConfigureAwait(false); } - - await Task.WhenAny(_sessionEnded.Task, attachment.Released, cancelled.Task).ConfigureAwait(false); } /// @@ -162,11 +211,13 @@ private Hex1bTerminal EnsureStarted() return _terminal; } - // Aspire owns the transport: the caller configures only the workload, and the HMP1 server is - // attached here so the terminal is reachable over the dashboard gRPC tunnel rather than a Unix - // domain socket. + // WithHmp1Server creates an 80x24 adapter regardless of WithDimensions. Supply the adapter + // directly so the PTY starts at the requested size, before any viewer can resize it. + // Revisit the manual client wiring when https://github.com/mitchdenny/hex1b/issues/548 is fixed. + _presentation = new Hmp1PresentationAdapter(_columns, _rows); _terminal = _builder - .WithHmp1Server(_clients.Reader.ReadAllAsync) + .WithDimensions(_columns, _rows) + .WithPresentation(_presentation) .WithReflow(GhosttyReflowStrategy.Instance) .WithScrollback(10000) .Build(); @@ -202,21 +253,32 @@ private async Task RunTerminalAsync(Hex1bTerminal terminal) } finally { + Task[] clients; lock (_gate) { // Hex1b cannot serve completion to later HMP clients. Keep Aspire's registry entry (and dock tab), // but report completion ourselves rather than attaching to the disposed terminal. // Replace the separate notification when native ended-session support is available: // https://github.com/mitchdenny/hex1b/issues/483. - _clients.Writer.TryComplete(); _workloadEnded.TrySetResult(); + clients = [.. _clientTasks]; } // Complete _sessionEnded only after disposal. Unlike _workloadEnded's UI notification, this signal // releases attached clients; Hex1b may still write to their transports during teardown. try { - await terminal.DisposeAsync().ConfigureAwait(false); + try + { + // Natural process exit must also cancel handshakes that have not sent ClientHello; + // those streams are not yet owned by the presentation adapter. + await _workloadCts.CancelAsync().ConfigureAwait(false); + await Task.WhenAll(clients).ConfigureAwait(false); + } + finally + { + await terminal.DisposeAsync().ConfigureAwait(false); + } _sessionEnded.TrySetResult(); } catch (Exception ex) @@ -275,8 +337,6 @@ public Task StopAsync() } _stopped = true; - _clients.Writer.TryComplete(); - if (_runTask is null) { // Registered but never started, so there is nothing to wind down. diff --git a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs index a42f6171f5f..ff1daba594d 100644 --- a/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs +++ b/src/Aspire.Hosting/Terminals/TerminalLaunchOptions.cs @@ -21,9 +21,8 @@ namespace Aspire.Hosting.Terminals; [Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public sealed class TerminalLaunchOptions { - // Start wider than 80x24 so output is not wrapped before a viewer negotiates its size. - private const int DefaultColumns = 120; - private const int DefaultRows = 32; + private const int DefaultColumns = 80; + private const int DefaultRows = 24; /// /// Gets or sets the title shown on the terminal's dock tab, and in the title bar when the terminal is @@ -80,12 +79,11 @@ public IList Arguments public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); /// - /// Gets or sets the requested initial number of columns. Defaults to 120. + /// Gets or sets the initial number of columns. Defaults to 80. /// /// - /// This is only the initial grid. A viewer that attaches renegotiates the size to fit the space it has, - /// so this matters mainly for terminals driven by automation before anyone attaches. - /// The current HMP server initializes at 80 columns and 24 rows, overriding these requested dimensions. + /// The process starts with this grid and retains it while no viewer requests a resize. + /// Dock and interaction dialog viewers resize the grid to fit their available space when shown. /// /// is less than one. public int Columns @@ -99,7 +97,7 @@ public int Columns } = DefaultColumns; /// - /// Gets or sets the requested initial number of rows. Defaults to 32. + /// Gets or sets the initial number of rows. Defaults to 24. /// /// /// is less than one. diff --git a/src/Aspire.Hosting/Terminals/TerminalOwner.cs b/src/Aspire.Hosting/Terminals/TerminalOwner.cs index 6f8be82ea52..6fffa77ba35 100644 --- a/src/Aspire.Hosting/Terminals/TerminalOwner.cs +++ b/src/Aspire.Hosting/Terminals/TerminalOwner.cs @@ -9,9 +9,8 @@ namespace Aspire.Hosting.Terminals; /// Identifies whether the AppHost or an application resource controls a terminal's workload lifetime. /// /// -/// This is fixed when the terminal is created and never changes. It is distinct from -/// , which describes where the terminal is currently displayed and can change -/// over the terminal's life. +/// Ownership is fixed when a terminal is created. It is distinct from , +/// which describes where the terminal is displayed and is also fixed at creation. /// [Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public enum TerminalOwner diff --git a/src/Aspire.Hosting/Terminals/TerminalPlacement.cs b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs index cea0f5aee38..b50a1afc1d6 100644 --- a/src/Aspire.Hosting/Terminals/TerminalPlacement.cs +++ b/src/Aspire.Hosting/Terminals/TerminalPlacement.cs @@ -9,9 +9,8 @@ namespace Aspire.Hosting.Terminals; /// Identifies where a terminal is displayed in the dashboard. /// /// -/// Placement is a property of the view, not of the workload: terminals with different -/// values can share a placement, and a terminal can in principle move between -/// placements without its workload being affected. +/// Placement is fixed when a terminal is created. It describes where the terminal is displayed, +/// while identifies which component owns its workload. /// [Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)] public enum TerminalPlacement diff --git a/src/Aspire.Hosting/Terminals/TerminalService.cs b/src/Aspire.Hosting/Terminals/TerminalService.cs index 9d105f91837..ff8e9ebcddb 100644 --- a/src/Aspire.Hosting/Terminals/TerminalService.cs +++ b/src/Aspire.Hosting/Terminals/TerminalService.cs @@ -97,7 +97,7 @@ public AspireTerminal CreateTerminal(TerminalLaunchOptions options) { ArgumentNullException.ThrowIfNull(options); - return CreateTerminal(options.Title, options.Placement, CreateBuilder(options)); + return CreateTerminal(options.Title, options.Placement, CreateBuilder(options), options.Columns, options.Rows); } /// @@ -114,7 +114,6 @@ public AspireTerminal CreateTerminal(TerminalLaunchOptions options) private static Hex1bTerminalBuilder CreateBuilder(TerminalLaunchOptions options) { return Hex1bTerminal.CreateBuilder() - .WithDimensions(options.Columns, options.Rows) .WithPtyProcess(process => { process.FileName = options.Executable; @@ -136,10 +135,12 @@ private static Hex1bTerminalBuilder CreateBuilder(TerminalLaunchOptions options) /// cannot describe — notably the dock's built-in terminal, which runs an /// in-process Hex1b app rather than a child process. /// - internal AspireTerminal CreateTerminal(string title, TerminalPlacement placement, Hex1bTerminalBuilder builder) + internal AspireTerminal CreateTerminal(string title, TerminalPlacement placement, Hex1bTerminalBuilder builder, int columns, int rows) { ArgumentException.ThrowIfNullOrWhiteSpace(title); ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(columns); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(rows); // AppHost-owned terminals have no resource view. Validate both creation paths here before registration, // while retaining None for terminals driven only through automation. @@ -159,7 +160,7 @@ internal AspireTerminal CreateTerminal(string title, TerminalPlacement placement lock (_syncLock) { ObjectDisposedException.ThrowIf(_disposed != 0, this); - terminal = new Hex1bAspireTerminal(this, id, title, placement, builder, _logger); + terminal = new Hex1bAspireTerminal(this, id, title, placement, builder, columns, rows, _logger); _terminals[id] = terminal; if (terminal.Placement == TerminalPlacement.Dock) diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/KeyboardShortcuts.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/KeyboardShortcuts.test.mjs index 349b7cf6a1f..86de70c9da9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/KeyboardShortcuts.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/KeyboardShortcuts.test.mjs @@ -8,6 +8,13 @@ import { runInNewContext } from "node:vm"; const source = await readFile(new URL("../../../src/Aspire.Dashboard/wwwroot/js/app.js", import.meta.url), "utf8"); +const backquote = { key: "`", code: "Backquote", shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }; +const dashboardKeys = [ + ...["c", "r", "s", "t", "m", "?", "S", "+", "-"].map(key => ({ key })), + backquote, + { ...backquote, key: "~", shiftKey: true }, +]; + function element(tagName, activeElement, parentElement = null) { return { tagName, children: [], parentElement, @@ -23,7 +30,7 @@ function element(tagName, activeElement, parentElement = null) { }; } -function shortcutsFor(activeElement) { +function shortcutsFor(activeElement, events = dashboardKeys) { const listeners = new Map(); const shortcuts = []; const document = { @@ -45,8 +52,8 @@ function shortcutsFor(activeElement) { shortcuts.push(shortcut); }, }); - for (const key of ["c", "r", "s", "t", "m", "?", "S", "+", "-"]) { - listeners.get("keydown")({ key }); + for (const event of events) { + listeners.get("keydown")(event); } window.unregisterGlobalKeydownListener(registration); assert.equal(listeners.has("keydown"), false); @@ -83,6 +90,24 @@ test("Fluent dropdown controls and options suppress dashboard shortcuts", () => test("dashboard shortcuts remain available outside inputs", () => { for (const target of [element("BODY"), element("BUTTON"), element("DIV", element("BUTTON"))]) { - assert.deepEqual(shortcutsFor(target), [210, 200, 220, 230, 240, 100, 110, 330, 340]); + assert.deepEqual(shortcutsFor(target), [210, 200, 220, 230, 240, 100, 110, 330, 340, 400]); + } +}); + +test("the unmodified physical Backquote key toggles the dock across keyboard layouts", () => { + for (const key of ["`", "^", "Dead"]) { + assert.deepEqual(shortcutsFor(element("BUTTON"), [{ ...backquote, key }]), [400]); + } +}); + +test("modified Backquote keys and backticks from other physical keys do not toggle the dock", () => { + for (const event of [ + { ...backquote, key: "~", shiftKey: true }, + { ...backquote, altKey: true }, + { ...backquote, ctrlKey: true }, + { ...backquote, metaKey: true }, + { ...backquote, code: "BracketRight" }, + ]) { + assert.deepEqual(shortcutsFor(element("BUTTON"), [event]), []); } }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 6bef6f77097..2c56f4d3e8d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -123,7 +123,7 @@ public async Task WatchUpdates_ReplaceSnapshotAndSelectAppHostTerminals() Assert.Equal("https://aka.ms/aspire/dashboard-terminals", helpLink.GetAttribute("href")); Assert.Equal("_blank", helpLink.GetAttribute("target")); Assert.Equal("noopener noreferrer", helpLink.GetAttribute("rel")); - Assert.Equal(["Open terminal in a new window", "Hide terminal panel (Shift+`)"], + Assert.Equal(["Open terminal in a new window", "Hide terminal panel (`)"], cut.FindAll(".terminal-dock-tabstrip fluent-button").Select(button => button.GetAttribute("aria-label"))); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); cut.WaitForAssertion(() => Assert.Equal("first", cut.Find(".terminal-dock-tab.active").TextContent.Trim())); @@ -330,7 +330,7 @@ public async Task LastTabRemoved_EmptyDockCanReceiveAnotherAppHostTerminal() Assert.Empty(cut.FindAll("[role=tablist]")); Assert.Empty(cut.FindAll("[role=tabpanel]")); Assert.Equal("No terminals", cut.Find(".terminal-dock-panel-heading").TextContent); - Assert.Equal(["Open terminal in a new window", "Hide terminal panel (Shift+`)"], + Assert.Equal(["Open terminal in a new window", "Hide terminal panel (`)"], cut.FindAll(".terminal-dock-tabstrip fluent-button").Select(button => button.GetAttribute("aria-label"))); }); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs index b58e0dd0ba9..07cc27aa421 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs @@ -22,7 +22,7 @@ public DashboardInteractionsTests(InteractionsDashboardServerFixture dashboardSe [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] - public async Task TerminalDockShortcut_UsesPhysicalKeyAndPreservesInputGuard() + public async Task TerminalDockShortcut_UsesUnmodifiedPhysicalKeyAndPreservesInputGuard() { await RunTestAsync(async page => { @@ -46,19 +46,18 @@ await page.EvaluateAsync(""" var cases = new (string Key, string Code, bool Shift, bool Alt, bool Ctrl, bool Meta, string Target, int? Expected)[] { - ("~", "Backquote", true, false, false, false, "control", 400), - ("\u00b0", "Backquote", true, false, false, false, "control", 400), - ("Dead", "Backquote", true, false, false, false, "control", 400), - ("~", "BracketRight", true, false, false, false, "control", null), - ("`", "Backquote", false, false, false, false, "control", null), - ("~", "Backquote", false, false, false, false, "control", null), - ("~", "Backquote", true, true, false, false, "control", null), - ("~", "Backquote", true, false, true, false, "control", null), - ("~", "Backquote", true, false, false, true, "control", null), - ("~", "Backquote", true, false, false, false, "input", null), - ("\u00b0", "Backquote", true, false, false, false, "textarea", null), - ("~", "Backquote", true, false, false, false, "terminal", null), - ("\u00b0", "Backquote", true, false, false, false, "fluent", null), + ("`", "Backquote", false, false, false, false, "control", 400), + ("^", "Backquote", false, false, false, false, "control", 400), + ("Dead", "Backquote", false, false, false, false, "control", 400), + ("`", "BracketRight", false, false, false, false, "control", null), + ("~", "Backquote", true, false, false, false, "control", null), + ("`", "Backquote", false, true, false, false, "control", null), + ("`", "Backquote", false, false, true, false, "control", null), + ("`", "Backquote", false, false, false, true, "control", null), + ("`", "Backquote", false, false, false, false, "input", null), + ("^", "Backquote", false, false, false, false, "textarea", null), + ("`", "Backquote", false, false, false, false, "terminal", null), + ("^", "Backquote", false, false, false, false, "fluent", null), ("S", "KeyS", true, false, false, false, "control", 110), ("r", "KeyR", false, false, false, false, "control", 200) }; diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs index 06dbd7339c3..d1fa4be9875 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs @@ -316,7 +316,7 @@ await RunTestAsync(async page => if (hideDock) { await page.Locator(".terminal-dock-collapse").ClickAsync(); - focusTarget = page.GetByRole(AriaRole.Button, new() { Name = "Toggle terminal (Shift+`)", Exact = true }); + focusTarget = page.GetByRole(AriaRole.Button, new() { Name = "Toggle terminal (`)", Exact = true }); } else { diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 790d3db0a84..ab913f8c43b 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -1,4 +1,4 @@ -// aspire.go - Capability-based Aspire SDK +// aspire.go - Capability-based Aspire SDK // This SDK uses the ATS (Aspire Type System) capability API. // Capabilities are endpoints like 'Aspire.Hosting/createBuilder'. // @@ -187,7 +187,6 @@ const ( InputTypeBoolean InputType = "Boolean" InputTypeNumber InputType = "Number" InputTypeFile InputType = "File" - InputTypeTerminal InputType = "Terminal" ) // HealthStatus represents HealthStatus. diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 86af005bf3c..28bda02e993 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1,4 +1,4 @@ -// ===== aspire/AddContainerOptions.java ===== +// ===== aspire/AddContainerOptions.java ===== // AddContainerOptions.java - GENERATED CODE - DO NOT EDIT package aspire; @@ -17371,8 +17371,7 @@ public enum InputType implements WireValueEnum { CHOICE("Choice"), BOOLEAN("Boolean"), NUMBER("Number"), - FILE("File"), - TERMINAL("Terminal"); + FILE("File"); private final String value; diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index af21a34f1f7..126cadebdd8 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1,4 +1,4 @@ -# ------------------------------------------------------------- +# ------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See LICENSE in project root for information. # @@ -1555,7 +1555,7 @@ def _validate_dict_types(args: typing.Any, arg_types: typing.Any) -> bool: ImagePullPolicy = typing.Literal["Default", "Always", "Missing", "Never"] -InputType = typing.Literal["Text", "SecretText", "Choice", "Boolean", "Number", "File", "Terminal"] +InputType = typing.Literal["Text", "SecretText", "Choice", "Boolean", "Number", "File"] MessageIntent = typing.Literal["None", "Success", "Warning", "Error", "Information", "Confirmation"] diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 82136320fa7..6c27b24609f 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -1,4 +1,4 @@ -//! aspire.rs - Capability-based Aspire SDK +//! aspire.rs - Capability-based Aspire SDK //! GENERATED CODE - DO NOT EDIT use std::collections::HashMap; @@ -422,8 +422,6 @@ pub enum InputType { Number, #[serde(rename = "File")] File, - #[serde(rename = "Terminal")] - Terminal, } impl std::fmt::Display for InputType { @@ -435,7 +433,6 @@ impl std::fmt::Display for InputType { Self::Boolean => write!(f, "Boolean"), Self::Number => write!(f, "Number"), Self::File => write!(f, "File"), - Self::Terminal => write!(f, "Terminal"), } } } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 0d7155338dc..fdf3944f935 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -1502,7 +1502,7 @@ public async Task AttachTerminal_WorkloadEndedReportsStatusWithoutHmpHandshake(b await using var writer = output.Writer.AsStream(); var workload = new StreamWorkloadAdapter(reader, Stream.Null); await using var terminal = terminalService.CreateTerminal("Ended", TerminalPlacement.Dock, - Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24); terminal.Start(); await writer.WriteAsync("ready\r\n"u8.ToArray()); await terminal.WaitForTextAsync("ready").DefaultTimeout(); @@ -1564,7 +1564,7 @@ public async Task CloseTerminal_DisposesTerminal_AndRepeatedCloseSucceeds(bool s await using var reader = output.Reader.AsStream(); await using var writer = output.Writer.AsStream(); await using var terminal = terminalService.CreateTerminal("Close", TerminalPlacement.Dock, - Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(reader, Stream.Null))); + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(reader, Stream.Null)), 80, 24); if (started) { terminal.Start(); @@ -1591,7 +1591,7 @@ public async Task CloseTerminal_PendingTransportCleanup_TimeoutOrCancellationDoe await using var outputReader = output.Reader.AsStream(); await using var outputWriter = output.Writer.AsStream(); await using var terminal = terminalService.CreateTerminal("Closing", TerminalPlacement.Dock, - Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null))); + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24); var (serverStream, clientStream) = TestDuplexStream.CreatePair(); using var serverOwner = serverStream; using var clientOwner = clientStream; diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs index 90b772b5886..8ae457e09cb 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -20,6 +20,112 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class Hex1bAspireTerminalTests { + [Theory] + [InlineData(80, 24)] + [InlineData(82, 28)] + [InlineData(160, 48)] + [InlineData(40, 12)] + public async Task CreateTerminal_InitialScreenUsesRequestedDimensions(int columns, int rows) + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var terminal = service.CreateTerminal("Initial grid", TerminalPlacement.None, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), columns, rows); + terminal.Start(); + await outputWriter.WriteAsync("ready"u8.ToArray()); + await terminal.WaitForTextAsync("ready").DefaultTimeout(); + + var lines = terminal.GetScreenText().Split('\n'); + Assert.Equal(rows, lines.Length); + Assert.All(lines, line => Assert.Equal(columns, line.Length)); + } + + [Theory] + [InlineData(null, null)] + [InlineData(80, 24)] + [InlineData(82, 28)] + [InlineData(160, 48)] + [InlineData(40, 12)] + public async Task CreateTerminal_HeadlessProcessRetainsInitialDimensions(int? columns, int? rows) + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses POSIX stty."); + + var options = new TerminalLaunchOptions + { + Title = "Headless dimensions", + Executable = "/bin/sh", + Arguments = ["-c", ReportDimensionsScript], + Placement = TerminalPlacement.None + }; + if (columns is { } width) + { + options.Columns = width; + } + if (rows is { } height) + { + options.Rows = height; + } + + var expectedColumns = options.Columns; + var expectedRows = options.Rows; + await using var service = TestTerminalService.Create(); + await using var terminal = service.CreateTerminal(options); + // Creation captures the initial dimensions; later edits to the options must not alter startup. + options.Columns = 1; + options.Rows = 1; + terminal.Start(); + await terminal.WaitForTextAsync("initial-ready").DefaultTimeout(); + AssertReportedDimensions(terminal, "initial", expectedColumns, expectedRows); + + await terminal.SendTextAsync("automation\r").DefaultTimeout(); + await terminal.WaitForTextAsync("automation-ready").DefaultTimeout(); + AssertReportedDimensions(terminal, "automation", expectedColumns, expectedRows); + } + + [Theory] + [InlineData(TerminalPlacement.Dock)] + [InlineData(TerminalPlacement.Dialog)] + public async Task CreateTerminal_ViewerCanResizeInitiallySizedProcess(TerminalPlacement placement) + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses POSIX stty."); + + await using var service = TestTerminalService.Create(); + await using var terminal = service.CreateTerminal(new TerminalLaunchOptions + { + Title = "Viewer dimensions", + Executable = "/bin/sh", + Arguments = ["-c", ReportDimensionsScript], + Placement = placement, + Columns = 82, + Rows = 28 + }); + terminal.Start(); + await terminal.WaitForTextAsync("initial-ready").DefaultTimeout(); + AssertReportedDimensions(terminal, "initial", 82, 28); + + await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); + await terminal.SendTextAsync("attached\r").DefaultTimeout(); + await terminal.WaitForTextAsync("attached-ready").DefaultTimeout(); + AssertReportedDimensions(terminal, "attached", 82, 28); + + await viewer.ResizeAsync(100, 30); + await terminal.SendTextAsync("resized\r").DefaultTimeout(); + await terminal.WaitForTextAsync("resized-ready").DefaultTimeout(); + AssertReportedDimensions(terminal, "resized", 100, 30); + + await viewer.ResizeAsync(60, 18); + await terminal.SendTextAsync("narrowed\r").DefaultTimeout(); + await terminal.WaitForTextAsync("narrowed-ready").DefaultTimeout(); + AssertReportedDimensions(terminal, "narrowed", 60, 18); + + await viewer.DisconnectPeerAsync().DefaultTimeout(); + await terminal.SendTextAsync("detached\r").DefaultTimeout(); + await terminal.WaitForTextAsync("detached-ready").DefaultTimeout(); + AssertReportedDimensions(terminal, "detached", 60, 18); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -130,7 +236,7 @@ public async Task Resize_ReflowsMainScreenAndRetainsHistory(TerminalPlacement pl await using var outputReader = output.Reader.AsStream(); await using var outputWriter = output.Writer.AsStream(); await using var terminal = service.CreateTerminal("Reflow", placement, - Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null))); + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24); await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); var lines = Enumerable.Range(0, 7).Select(i => $"{i}:" + new string('x', 63) + "-END").ToArray(); var expected = string.Join('\n', lines.Select(line => line.PadRight(80)).Append("ready")); @@ -152,7 +258,7 @@ public async Task Resize_CropsAlternateScreenAndReflowsSavedMainScreen() await using var outputReader = output.Reader.AsStream(); await using var outputWriter = output.Writer.AsStream(); await using var terminal = service.CreateTerminal("Alternate", TerminalPlacement.Dock, - Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null))); + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24); await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); const string main = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-END"; await outputWriter.WriteAsync(Encoding.UTF8.GetBytes(main + "\r\nready")); @@ -182,7 +288,7 @@ public async Task WorkloadExit_EndsAutomationAndKeepsTabUntilDisposed(bool attac await using var outputWriter = output.Writer.AsStream(); var workload = new StreamWorkloadAdapter(outputReader, Stream.Null); await using var terminal = service.CreateTerminal("Ended", TerminalPlacement.Dock, - Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24); await using var viewer = attachViewer ? await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id) : null; terminal.Start(); await outputWriter.WriteAsync("ready\r\n"u8.ToArray()); @@ -233,7 +339,7 @@ public async Task AttachAsync_MultipleViewersCanDisconnectAndReconnectWithoutSto await using var inputWriter = input.Writer.AsStream(); var workload = new StreamWorkloadAdapter(outputReader, inputWriter); await using var terminal = service.CreateTerminal("Shared", TerminalPlacement.Dialog, - Hex1bTerminal.CreateBuilder().WithDimensions(80, 24).WithWorkload(workload)); + Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24); // Attach starts the previously idle workload. The other viewer must survive the first peer's EOF, // and a later viewer must receive the same terminal's existing screen rather than a fresh process. @@ -282,7 +388,7 @@ public async Task AttachAsync_CancellationDuringHandshakeWaitsForTheOutstandingW await using var outputWriter = output.Writer.AsStream(); var workload = new StreamWorkloadAdapter(outputReader, Stream.Null); await using var terminal = service.CreateTerminal("Handshake", TerminalPlacement.Dialog, - Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24); var (serverStream, clientStream) = TestDuplexStream.CreatePair(); using var serverOwner = serverStream; @@ -323,4 +429,96 @@ public async Task AttachAsync_CancellationDuringHandshakeWaitsForTheOutstandingW await outputWriter.WriteAsync("replacement-ready\r\n"u8.ToArray()); await replacement.WaitForTextAsync("replacement-ready").DefaultTimeout(); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AttachAsync_UnfinishedHandshakeDoesNotBlockOtherViewers(bool closePeer) + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var terminal = service.CreateTerminal("Concurrent handshakes", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24); + var (serverStream, clientStream) = TestDuplexStream.CreatePair(); + using var serverOwner = serverStream; + using var clientOwner = clientStream; + using var cts = new CancellationTokenSource(); + var attachment = service.AttachAsync(terminal.Id, serverStream, _ => Task.CompletedTask, cts.Token); + + try + { + // This peer never sends ClientHello. Another viewer must still complete its handshake. + await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id); + await outputWriter.WriteAsync("connected-ready\r\n"u8.ToArray()); + await viewer.WaitForTextAsync("connected-ready").DefaultTimeout(); + Assert.False(attachment.IsCompleted); + + if (closePeer) + { + clientStream.Dispose(); + } + else + { + await cts.CancelAsync(); + } + await attachment.DefaultTimeout(); + Assert.False(serverStream.Disposed); + + await outputWriter.WriteAsync("still-connected\r\n"u8.ToArray()); + await viewer.WaitForTextAsync("still-connected").DefaultTimeout(); + } + finally + { + await cts.CancelAsync(); + await attachment.DefaultTimeout(); + } + } + + [Fact] + public async Task DisposeAsync_CancelsUnfinishedHandshakeWithoutDisposingCallerTransport() + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var terminal = service.CreateTerminal("Pending handshake", TerminalPlacement.Dock, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24); + terminal.Start(); + await outputWriter.WriteAsync("ready\r\n"u8.ToArray()); + await terminal.WaitForTextAsync("ready").DefaultTimeout(); + + var (serverStream, clientStream) = TestDuplexStream.CreatePair(); + using var serverOwner = serverStream; + using var clientOwner = clientStream; + var attachment = service.AttachAsync(terminal.Id, serverStream, _ => Task.CompletedTask, CancellationToken.None); + Assert.False(attachment.IsCompleted); + + await terminal.DisposeAsync().AsTask().DefaultTimeout(); + await attachment.DefaultTimeout(); + Assert.False(serverStream.Disposed); + } + + private static void AssertReportedDimensions(AspireTerminal terminal, string marker, int columns, int rows) + { + var line = Assert.Single( + terminal.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), + line => line.StartsWith(marker + ":", StringComparison.Ordinal)); + Assert.Equal($"{marker}:{rows} {columns}", line); + } + + // stty reports the actual PTY as " ". Marker-prefixed output such as + // "initial:28 82" distinguishes measurements from input echoed by the shell. + private const string ReportDimensionsScript = """ + set -eu + printf 'initial:' + stty size + printf 'initial-ready\n' + while read -r marker; do + printf '%s:' "$marker" + stty size + printf '%s-ready\n' "$marker" + done + """; } diff --git a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs index 3974b693503..122da8ee290 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/InteractionServiceTerminalTests.cs @@ -363,7 +363,7 @@ public async Task PromptTerminalAsync_CompletionAndViewerDisconnect_LeaveAutomat await using var outputReader = output.Reader.AsStream(); await using var outputWriter = output.Writer.AsStream(); await using var terminal = terminals.CreateTerminal("Reusable", TerminalPlacement.Dialog, - Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null))); + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24); terminal.Start(); foreach (var cancel in new[] { false, true }) @@ -397,7 +397,7 @@ public async Task PromptTerminalAsync_WorkloadExit_DoesNotCompleteDialog() await using var outputWriter = output.Writer.AsStream(); var workload = new StreamWorkloadAdapter(outputReader, Stream.Null); await using var terminal = terminals.CreateTerminal("Ending", TerminalPlacement.Dialog, - Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24); terminal.Start(); var prompt = service.PromptTerminalAsync("Message", terminal); var interaction = Assert.Single(service.GetCurrentInteractions()); diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs index 1a5002ea885..a5a0fe7346f 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalLaunchOptionsTests.cs @@ -127,12 +127,12 @@ public void Rows_NotPositive_Throws(int value) } [Fact] - public void Dimensions_DefaultToAModernGrid() + public void Dimensions_DefaultTo80ColumnsAnd24Rows() { var options = new TerminalLaunchOptions { Title = "Shell", Executable = "bash" }; - Assert.Equal(120, options.Columns); - Assert.Equal(32, options.Rows); + Assert.Equal(80, options.Columns); + Assert.Equal(24, options.Rows); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index 3de48b5b9ae..4276c564f22 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -564,7 +564,7 @@ public async Task DisposeAsync_WaitsForAllWorkloadsAndRepeatedCalls() GatedTerminalWorkloadAdapter[] workloads = [new(), new()]; var terminals = workloads.Select((workload, index) => service.CreateTerminal($"Terminal {index}", TerminalPlacement.Dock, - Hex1bTerminal.CreateBuilder().WithWorkload(workload))).ToArray(); + Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24)).ToArray(); foreach (var terminal in terminals) { terminal.Start(); @@ -609,7 +609,7 @@ public async Task DisposeAsync_ObservesWorkloadDisposalFailure() var expected = new IOException("Workload disposal failed."); var workload = new GatedTerminalWorkloadAdapter { DisposalException = expected }; var terminal = service.CreateTerminal("Failure", TerminalPlacement.Dock, - Hex1bTerminal.CreateBuilder().WithWorkload(workload)); + Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24); terminal.Start(); var disposal = service.DisposeAsync().AsTask(); try @@ -813,7 +813,7 @@ public void ListAll_WithoutAResourceCatalogReturnsOnlyAppHostTerminals() private static AspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement, bool useBuilder, string title) => useBuilder - ? service.CreateTerminal(title, placement, Hex1bTerminal.CreateBuilder().WithPtyProcess("bash")) + ? service.CreateTerminal(title, placement, Hex1bTerminal.CreateBuilder().WithPtyProcess("bash"), 80, 24) : service.CreateTerminal(new TerminalLaunchOptions { Title = title, From 3e16358e0ae00e1a3846994304436be554837282 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 17 Sep 2026 14:03:03 +0800 Subject: [PATCH 083/106] Design improvements --- .../Components/Controls/TerminalView.razor | 10 +++++----- .../Components/Controls/TerminalView.razor.css | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index 614b2a9c163..b0518b32fc4 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -1,6 +1,6 @@ @namespace Aspire.Dashboard.Components.Controls -
+
public GetTerminalInfoResponse TerminalInfoResponse { get; set; } = new GetTerminalInfoResponse { IsAvailable = false }; + public Func>? GetTerminalInfoHandler { get; set; } + public Task GetTerminalInfoAsync(string resourceName, CancellationToken cancellationToken = default) { + if (GetTerminalInfoHandler is not null) + { + return GetTerminalInfoHandler(resourceName, cancellationToken); + } + return Task.FromResult(TerminalInfoResponse); } diff --git a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs index 40ed2da6a2d..0e1db06e6a3 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TerminalTestHost.cs @@ -53,6 +53,8 @@ public TerminalTestHost(ITestOutputHelper output, bool requireAuthentication, bo public Hmp1PresentationAdapter Presentation => _producer.Presentation; public int ConnectionCount => _producer.ConnectionCount; public int DisposedAttachments => Volatile.Read(ref _disposedAttachments); + public StatusCode? AttachmentFailureStatus { get; init; } + public bool FailAttachmentDuringHandshake { get; init; } private string Endpoint => _useGrpc ? "/api/apphost-terminal?terminalId=test" : "/api/terminal?resource=test&replica=0"; public Task StartAsync(CancellationToken cancellationToken) => _app.StartAsync(cancellationToken); @@ -119,9 +121,17 @@ await socket.ConnectAsync(new UriBuilder(frontend) private async Task AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) { Assert.Equal("test", terminalId); + var failure = AttachmentFailureStatus is { } status + ? new RpcException(new Status(status, "Terminal attachment failed.")) + : null; + if (failure is not null && !FailAttachmentDuringHandshake) + { + throw failure; + } + // A completed terminal reports Ended without attaching to the disposed // producer or returning any HMP handshake bytes. - var connection = Volatile.Read(ref _terminalEnded) != 0 + var connection = failure is not null || Volatile.Read(ref _terminalEnded) != 0 ? Stream.Null : (await ConnectAsync(terminalId, 0, cancellationToken))!; var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -129,7 +139,7 @@ private async Task AttachTerminalAsync(string terminalId, CancellationTo var call = new AsyncDuplexStreamingCall( new TerminalRequestWriter(connection), new TerminalResponseReader(connection, () => Volatile.Read(ref _terminalEnded) != 0, - () => Volatile.Read(ref _includeHmpExit) != 0, _endedObserved), + () => Volatile.Read(ref _includeHmpExit) != 0, _endedObserved, failure), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), @@ -158,7 +168,7 @@ public async ValueTask DisposeAsync() } private sealed class TerminalResponseReader(Stream stream, Func terminalEnded, Func includeHmpExit, - TaskCompletionSource endedObserved) : IAsyncStreamReader + TaskCompletionSource endedObserved, RpcException? failure) : IAsyncStreamReader { // Deliberately split HMP frames across small gRPC messages: transport boundaries // must not affect the HMP handshake, UTF-8 input, graphics, or terminal state. @@ -170,6 +180,12 @@ private sealed class TerminalResponseReader(Stream stream, Func terminalEn public async Task MoveNext(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + if (failure is not null) + { + throw failure; + } + var count = await stream.ReadAsync(_buffer, cancellationToken); if (count == 0) { diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketProxyEndpointTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketProxyEndpointTests.cs index 98e891781e5..5dff4f30f56 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketProxyEndpointTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketProxyEndpointTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net.WebSockets; using System.Security.Claims; using System.Text.Encodings.Web; using Aspire.Dashboard.Configuration; @@ -92,15 +93,22 @@ public async Task TerminalEndpoint_SameOrigin_ProceedsToResolver(bool useGrpc) req.Headers["Origin"] = $"{DashboardScheme}://{DashboardHost}"; }; - // Allowed-origin path will still fail to upgrade because the fake - // resolver returns null (resource not found) — the proxy responds 404, - // which TestHost's WebSocketClient surfaces as InvalidOperationException - // from ConnectAsync. The important assertion is that the resolver was - // reached at all. - await Assert.ThrowsAsync(async () => + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + if (useGrpc) { - await client.ConnectAsync(BuildTerminalUri(useGrpc), CancellationToken.None); - }); + using var socket = await client.ConnectAsync(BuildTerminalUri(useGrpc), timeout.Token); + var close = await socket.ReceiveAsync(new byte[64], timeout.Token); + Assert.Equal((WebSocketCloseStatus)4000, close.CloseStatus); + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token); + } + else + { + // A resource replica may become available later, unlike an AppHost terminal + // ID that the server has permanently rejected. + var exception = await Assert.ThrowsAsync(() => + client.ConnectAsync(BuildTerminalUri(useGrpc), timeout.Token)); + Assert.Contains("404", exception.Message); + } Assert.True(resolver.ResolveCalled, "Same-origin requests must proceed past the Origin gate to resource resolution."); } diff --git a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs index 0960ed4f264..bdf78372c74 100644 --- a/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs +++ b/tests/Aspire.Dashboard.Tests/Terminal/TerminalWebSocketTests.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using Aspire.Dashboard.Tests.Shared; +using Grpc.Core; using Hex1b.Input; using Xunit; @@ -526,6 +527,63 @@ public async Task BrowserView_NativeValidationRejectsInvalidCommandsEvenWhenRead await host.WaitForAttachmentsReleasedAsync(timeout.Token); } + [Theory] + [InlineData(StatusCode.NotFound, false)] + [InlineData(StatusCode.NotFound, true)] + [InlineData(StatusCode.FailedPrecondition, false)] + [InlineData(StatusCode.FailedPrecondition, true)] + public async Task BrowserView_MissingAppHostTerminalClosesWithoutHwtFrame(StatusCode status, bool duringHandshake) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc: true) + { + AttachmentFailureStatus = status, + FailAttachmentDuringHandshake = duringHandshake + }; + using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(startup.Token); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var session = host.CreateViewSession(readOnly: false); + using var browser = await host.ConnectBrowserAsync(session, timeout.Token); + + var result = await browser.ReceiveAsync(new byte[64], timeout.Token); + Assert.Equal(WebSocketMessageType.Close, result.MessageType); + Assert.Equal((WebSocketCloseStatus)4000, result.CloseStatus); + Assert.Equal("Terminal ended", result.CloseStatusDescription); + await session.Ended.WaitAsync(timeout.Token); + Assert.True(session.ReadOnly); + await browser.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", timeout.Token); + await host.WaitForDisposedAttachmentsAsync(timeout.Token); + Assert.Equal(0, host.ConnectionCount); + Assert.Equal(duringHandshake ? 1 : 0, host.DisposedAttachments); + } + + [Theory] + [InlineData(StatusCode.Unavailable, false)] + [InlineData(StatusCode.Unavailable, true)] + [InlineData(StatusCode.DeadlineExceeded, false)] + [InlineData(StatusCode.DeadlineExceeded, true)] + public async Task BrowserView_TransientAppHostAttachmentFailureRemainsRetryable(StatusCode status, bool duringHandshake) + { + await using var host = new TerminalTestHost(output, requireAuthentication: false, useGrpc: true) + { + AttachmentFailureStatus = status, + FailAttachmentDuringHandshake = duringHandshake + }; + using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await host.StartAsync(startup.Token); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var session = host.CreateViewSession(readOnly: false); + + var exception = await Assert.ThrowsAsync(() => host.ConnectBrowserAsync(session, timeout.Token)); + + Assert.Contains("503", exception.Message); + Assert.False(session.Ended.IsCompleted); + Assert.False(session.ReadOnly); + await host.WaitForDisposedAttachmentsAsync(timeout.Token); + Assert.Equal(0, host.ConnectionCount); + Assert.Equal(duringHandshake ? 1 : 0, host.DisposedAttachments); + } + [Fact] public async Task BrowserView_AppHostEndBeforeHandshakeClosesWithoutHwtFrame() { diff --git a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs index b841e5d7418..152c801b16d 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/TerminalServiceTests.cs @@ -213,6 +213,86 @@ public void CreateTerminal_TwoTerminals_GetDistinctIds() Assert.NotEqual(first.Id, second.Id); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CreateTerminal_SnapshotsLaunchOptionsBeforeLazyStartup(bool replaceArguments) + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses a POSIX shell."); + + var home = Environment.GetEnvironmentVariable("HOME"); + Assert.NotNull(home); + var firstDirectory = Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory); + var secondDirectory = Directory.GetParent(firstDirectory)!.FullName; + + // Pass paths and values as positional arguments rather than interpolating shell syntax. Compare + // directory identity to allow macOS symlinks, and keep reading so the screen survives the assertions. + const string script = """ + set -eu + printf '%s\n' "$1" + printf 'environment:[%s][%s][%s]\n' "$ASPIRE_TERMINAL_SNAPSHOT_SETTING" "${ASPIRE_TERMINAL_SNAPSHOT_REMOVED-}" "${ASPIRE_TERMINAL_SNAPSHOT_ADDED-}" + test "$HOME" = "$2" + printf 'inherited-environment\n' + test . -ef "$3" + printf 'working-directory\n' + printf 'ready\n' + read -r input + """; + List arguments = ["-c", script, "terminal-snapshot", "first argument with spaces", home, firstDirectory]; + var options = new TerminalLaunchOptions + { + Title = "First", + Placement = TerminalPlacement.None, + Executable = "/bin/sh", + Arguments = arguments, + WorkingDirectory = firstDirectory, + EnvironmentVariables = + { + ["ASPIRE_TERMINAL_SNAPSHOT_SETTING"] = "first value", + ["ASPIRE_TERMINAL_SNAPSHOT_REMOVED"] = "preserved", + ["ASPIRE_TERMINAL_SNAPSHOT_ADDED"] = string.Empty + } + }; + + await using var service = TestTerminalService.Create(); + await using var first = service.CreateTerminal(options); + Assert.Empty(first.GetScreenText()); + + options.Title = "Second"; + options.WorkingDirectory = secondDirectory; + if (replaceArguments) + { + options.Arguments = [.. arguments]; + } + options.Arguments[3] = "second argument with spaces"; + options.Arguments[5] = secondDirectory; + options.EnvironmentVariables.Clear(); + options.EnvironmentVariables["ASPIRE_TERMINAL_SNAPSHOT_SETTING"] = "second value"; + options.EnvironmentVariables["ASPIRE_TERMINAL_SNAPSHOT_ADDED"] = "added"; + await using var second = service.CreateTerminal(options); + Assert.Empty(second.GetScreenText()); + + options.Executable = "must-not-be-started"; + options.WorkingDirectory = Path.Combine(secondDirectory, "must-not-be-used"); + options.Arguments.Clear(); + arguments.Clear(); + options.EnvironmentVariables.Clear(); + options.EnvironmentVariables["ASPIRE_TERMINAL_SNAPSHOT_SETTING"] = "must-not-be-used"; + + first.Start(); + second.Start(); + await Task.WhenAll(first.WaitForTextAsync("ready"), second.WaitForTextAsync("ready")).DefaultTimeout(); + + Assert.Equal("First", first.Title); + Assert.Equal("Second", second.Title); + Assert.Equal( + ["first argument with spaces", "environment:[first value][preserved][]", "inherited-environment", "working-directory", "ready"], + first.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + Assert.Equal( + ["second argument with spaces", "environment:[second value][][added]", "inherited-environment", "working-directory", "ready"], + second.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + } + [Fact] public void TryGetTerminal_UnknownId_ReturnsFalse() { diff --git a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs index 0c84debc98e..0634d5955e7 100644 --- a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs +++ b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs @@ -630,9 +630,10 @@ public void ExtensionJavaE2eDiscoveryInputsAreNotGitIgnored() } [Theory] + [InlineData("src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js")] [InlineData("src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js")] [InlineData("tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs")] - public void DashboardTerminalWindowInputsSelectInfrastructureTests(string path) + public void DashboardTerminalScriptInputsSelectInfrastructureTests(string path) { var result = SelectWithRealMap(path); From 612c371535046d6de0e860fb0298e7ade526c8ab Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 18 Sep 2026 11:18:13 +1000 Subject: [PATCH 098/106] Restrict dashboard terminal close to AppHost ownership Reject resource-owned terminal handles without disposing their shared automation peer. Record the deferred public resource lookup API. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 6 ++++ .../Dashboard/DashboardService.cs | 6 ++++ .../Dashboard/DashboardServiceTests.cs | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 8827d19d8e7..dbcd5188321 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -76,6 +76,8 @@ it from discovery immediately, but the service retains teardown ownership until cleanup finishes. Dashboard close waits at most 10 seconds; a timeout or disconnect ends only that wait, not cleanup. Host-stop cancellation likewise bounds the wait, and subsequent AppHost disposal joins the same cleanup operation. +The dashboard's `CloseTerminal` RPC rejects resource-owned handles with +`InvalidArgument`; it must not disconnect their shared automation peer. ### Sending keys from AppHost code @@ -113,6 +115,10 @@ Windows workloads receive input through ConPTY and may interpret it differently. Numeric-keypad keys, extended modifiers, key-down/up events, and Kitty keyboard protocol are not part of this API. Typing text is not bracketed paste. +Public C# lookup currently requires a terminal ID. A resource-name/replica lookup +API that avoids constructing internal IDs is deferred to +[#20219](https://github.com/microsoft/aspire/issues/20219). + On a newly connected resource terminal, the first mode-dependent key can race initial state replay. Await expected screen text before mode-sensitive input when necessary; a replay-completion barrier is tracked in diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index daab264eed6..302f97d820f 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -742,6 +742,12 @@ public override async Task CloseTerminal( { if (terminalService.TryGetTerminal(request.TerminalId, out var terminal)) { + // Resource handles are shared automation peers, not workloads owned by the dock. + if (terminal.Owner != Aspire.Hosting.Terminals.TerminalOwner.AppHost) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Only AppHost-owned terminals can be closed from the dashboard.")); + } + await CloseTerminalAsync(terminal, context.CancellationToken).ConfigureAwait(false); } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index d107cd05ba5..646de39bdf2 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -1557,6 +1557,40 @@ public async Task CloseTerminal_UnknownId_Succeeds() Assert.NotNull(response); } + [Fact] + public async Task CloseTerminal_ResourceOwnedTerminal_RejectsWithoutDisposingSharedHandle() + { + using var fileSystem = new TestFileSystemService(); + using var directory = fileSystem.TempDirectory.CreateTempSubdirectory(); + var resource = new TestResource("myapp"); + var layout = new TerminalHostLayout( + replicaId: "test0000000", + parentReplicaIndex: 0, + producerUdsPath: Path.Combine(directory.Path, "producer.sock"), + consumerUdsPath: Path.Combine(directory.Path, "consumer.sock"), + controlUdsPath: Path.Combine(directory.Path, "control.sock"), + metadataPath: Path.Combine(directory.Path, "metadata.json")); + var annotation = new TerminalAnnotation(new TerminalOptions()); + annotation.Initialize([new TerminalHostResource("myapp-terminalhost-0", resource, layout)]); + resource.Annotations.Add(annotation); + + await using var catalog = new ResourceTerminalCatalog(new DistributedApplicationModel([resource]), NullLogger.Instance); + await using var terminalService = TestTerminalService.Create(); + terminalService.ResourceTerminals = catalog; + using var serviceData = CreateDashboardServiceData(); + var service = CreateDashboardService(serviceData, terminalService: terminalService); + Assert.True(terminalService.TryGetTerminal(ResourceTerminalCatalog.BuildId(resource.Name, 0), out var terminal)); + var backend = Assert.IsType(terminal.Backend); + + var exception = await Assert.ThrowsAsync(() => service.CloseTerminal( + new CloseTerminalRequest { TerminalId = terminal.Id }, TestServerCallContext.Create())).DefaultTimeout(); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.False(backend.IsDisposed); + Assert.True(terminalService.TryGetTerminal(terminal.Id, out var registered)); + Assert.Same(terminal, registered); + } + [Theory] [InlineData(false)] [InlineData(true)] From 883a67791f538211f4187cecf9e8884a631e815a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 18 Sep 2026 12:03:29 +1000 Subject: [PATCH 099/106] Preserve detached terminals across dashboard reloads Reconcile durable scoped window records before mounting dock viewers, rediscover live detached windows across document reloads, and revoke stale generations when returning to the dock. Preserve independent window lifetime and surface uncertain recovery without stealing resize control. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 16 +- .../Controls/TerminalWindowButton.razor | 1 + .../Controls/TerminalWindowButton.razor.cs | 105 +++- .../Components/Layout/TerminalDock.razor | 15 +- .../Components/Layout/TerminalDock.razor.cs | 57 +- .../Components/Pages/TerminalWindow.razor | 8 +- .../Components/Pages/TerminalWindow.razor.cs | 146 ++++- .../Model/TerminalWindowLauncher.cs | 37 +- .../Resources/TerminalStrings.Designer.cs | 18 + .../Resources/TerminalStrings.resx | 6 + .../Resources/xlf/TerminalStrings.cs.xlf | 10 + .../Resources/xlf/TerminalStrings.de.xlf | 10 + .../Resources/xlf/TerminalStrings.es.xlf | 10 + .../Resources/xlf/TerminalStrings.fr.xlf | 10 + .../Resources/xlf/TerminalStrings.it.xlf | 10 + .../Resources/xlf/TerminalStrings.ja.xlf | 10 + .../Resources/xlf/TerminalStrings.ko.xlf | 10 + .../Resources/xlf/TerminalStrings.pl.xlf | 10 + .../Resources/xlf/TerminalStrings.pt-BR.xlf | 10 + .../Resources/xlf/TerminalStrings.ru.xlf | 10 + .../Resources/xlf/TerminalStrings.tr.xlf | 10 + .../Resources/xlf/TerminalStrings.zh-Hans.xlf | 10 + .../Resources/xlf/TerminalStrings.zh-Hant.xlf | 10 + .../wwwroot/js/app-terminalwindow.js | 440 +++++++++++++-- .../Controls/TerminalWindowButtonTests.cs | 117 ++++ .../JavaScript/TerminalWindow.test.mjs | 501 +++++++++++++++++- .../Layout/TerminalDockTests.cs | 18 +- .../Layout/TerminalDockWindowTrackingTests.cs | 215 ++++++++ .../Pages/TerminalWindowTests.cs | 67 +++ .../Shared/TerminalSetupHelpers.cs | 4 + 30 files changed, 1818 insertions(+), 83 deletions(-) create mode 100644 tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index dbcd5188321..dd8edf9593a 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -412,6 +412,16 @@ Dock terminals show a detached placeholder until the window closes or the user chooses **Return to dock**. Disposing the opener leaves independent windows and their AppHost-owned producers running. +Detached tracking survives launcher/circuit replacement and full same-origin +opener reload. Before mounting any dock viewer, the dashboard reconciles durable +records scoped to the browsing context, dashboard base, terminal, and window +generation. Detached windows announce their presence so the opener can recover +live handles without opening another window or changing terminal dimensions. +An unanswered discovery request does not prove closure: a window closed before +handle recovery leaves an unconfirmed placeholder until **Focus window** or +**Return to dock** is used. Focus reuses the named window through user activation; +Return revokes its generation so delayed discovery cannot restore the old window. + The terminal frame keeps font decrease/increase buttons, the current font size, and the live columns-by-rows selector together in its bottom-right footer. A separate Fit button switches to container-sized rows and columns @@ -442,8 +452,10 @@ resource pages. Resource terminals remain separate from AppHost-owned dock tabs. Before the first opening, the dock watches only AppHost terminal metadata so `Show()` can reveal it remotely. Resource-link tracking and browser controls start on first opening; ordinary metadata updates do not render the unopened dock. -Window-launch listeners are registered only once the launch button has a usable -terminal target and font size, before the button is enabled. +Window-launch buttons are enabled only once their native click listener, usable +terminal target, and font size are ready. Detached-window +tracking is initialized earlier, before dock viewers mount, so recovering a +detached tab does not briefly create a competing auto-fit viewer. Dock panes, interaction dialogs and detached windows automatically fit when opened. A detached window takes primary once, carrying the originating view's diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor b/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor index 4311ef6043f..aa7c9950b6c 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor @@ -8,6 +8,7 @@ aria-label="@Label" Disabled="@(!_ready || Disabled || string.IsNullOrEmpty(TerminalKey) || LaunchUrl is null)" data-terminal-window-key="@TerminalKey" + data-terminal-window-focus-group="@FocusGroup" data-terminal-window-url="@LaunchUrl"> diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor.cs index 2f201db4afd..9b30b0f7c76 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalWindowButton.razor.cs @@ -17,6 +17,9 @@ public partial class TerminalWindowButton : ComponentBase, IAsyncDisposable private TerminalWindowLauncher? _launcher; private bool _ready; private bool _disposed; + private bool _adopting; + private bool _adoptionFailed; + private readonly HashSet _processedKeys = new(StringComparer.Ordinal); /// Gets or sets the terminal's stable window key. [Parameter] @@ -38,11 +41,15 @@ public partial class TerminalWindowButton : ComponentBase, IAsyncDisposable [Parameter] public string? Class { get; set; } + /// Gets or sets the group identifying native focus controls for this launcher's detached windows. + [Parameter] + public string? FocusGroup { get; set; } + /// Gets or sets whether this surface currently permits launching a window. [Parameter] public bool Disabled { get; set; } - /// Raised with the clicked key and outcome, even if the selected terminal has since changed. + /// Raised with the launched or adopted key and outcome, even if the selected terminal has since changed. [Parameter] public EventCallback<(string Key, TerminalWindowOpenResult Result)> OnWindowOpened { get; set; } @@ -50,6 +57,18 @@ public partial class TerminalWindowButton : ComponentBase, IAsyncDisposable [Parameter] public EventCallback OnWindowClosed { get; set; } + /// Gets or sets current terminal identities to check for surviving windows before mounting viewers. + [Parameter] + public string[] WindowKeysToAdopt { get; set; } = []; + + /// Raised with checked keys after their surviving windows have been reconciled through . + [Parameter] + public EventCallback OnWindowsAdopted { get; set; } + + /// Raised with unchecked keys when browser coordination fails, so callers can offer explicit recovery. + [Parameter] + public EventCallback OnWindowTrackingFailed { get; set; } + [Inject] public required IJSRuntime JS { get; init; } @@ -74,11 +93,81 @@ public partial class TerminalWindowButton : ComponentBase, IAsyncDisposable protected override async Task OnAfterRenderAsync(bool firstRender) { - if (_disposed || _launcher is not null || Disabled || string.IsNullOrEmpty(TerminalKey) || LaunchUrl is null) + if (_disposed) { return; } + // A replacement dock must discover surviving windows before it mounts a viewer, so its listener can + // register without a font or enabled launch button. Actual clicks still require complete metadata. + if (_launcher is null && (WindowKeysToAdopt.Length > 0 || + (!Disabled && !string.IsNullOrEmpty(TerminalKey) && LaunchUrl is not null))) + { + await RegisterAsync(); + } + + if (!_disposed && _adoptionFailed) + { + // A storage/registration failure applies to later metadata too. Give newly arriving terminals the + // same recovery controls instead of leaving their unchecked panes empty or retrying on every render. + var uncheckedKeys = WindowKeysToAdopt.Where(key => !_processedKeys.Contains(key)).ToArray(); + if (uncheckedKeys.Length > 0) + { + _processedKeys.UnionWith(uncheckedKeys); + await OnWindowTrackingFailed.InvokeAsync(uncheckedKeys); + } + return; + } + + if (!_disposed && _ready && !_adopting && _launcher is { } launcher) + { + var keys = WindowKeysToAdopt.Where(key => !_processedKeys.Contains(key)).ToArray(); + if (keys.Length == 0) + { + return; + } + + _adopting = true; + var adopted = false; + try + { + await launcher.AdoptAsync(keys); + if (!_disposed) + { + _processedKeys.UnionWith(keys); + adopted = true; + } + } + catch (JSDisconnectedException) + { + // The replacement circuit will reconcile its own windows before rendering. + _adoptionFailed = true; + } + catch (Exception ex) + { + // Do not mount an unchecked viewer on failure: it could take sizing control from a live window. + _adoptionFailed = true; + _processedKeys.UnionWith(keys); + Logger.LogWarning(ex, "Failed to adopt existing terminal windows."); + if (!_disposed) + { + await OnWindowTrackingFailed.InvokeAsync(keys); + await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalWindowTrackingFailed)]); + } + } + finally + { + _adopting = false; + } + if (adopted && !_disposed) + { + await OnWindowsAdopted.InvokeAsync(keys); + } + } + } + + private async Task RegisterAsync() + { // Assign before awaiting registration so later renders cannot register a second listener. _launcher = new TerminalWindowLauncher(JS, NavigationManager, OnOpenedAsync, key => InvokeAsync(() => _disposed ? Task.CompletedTask : OnWindowClosed.InvokeAsync(key))); @@ -100,7 +189,17 @@ protected override async Task OnAfterRenderAsync(bool firstRender) Logger.LogWarning(ex, "Failed to register the terminal window button."); if (!_disposed) { - await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalToolbarOpenInWindowFailed)]); + _adoptionFailed = true; + _processedKeys.UnionWith(WindowKeysToAdopt); + if (WindowKeysToAdopt.Length > 0) + { + await OnWindowTrackingFailed.InvokeAsync(WindowKeysToAdopt); + await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalWindowTrackingFailed)]); + } + else + { + await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalToolbarOpenInWindowFailed)]); + } } } } diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index b0b3cbcab2f..1f0d6d4a0e8 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -70,7 +70,11 @@ TerminalKey="@_activeTerminalId" Url="@ActiveTerminalWindowUrl" FontSize="@ActiveTerminalFontSize" + FocusGroup="@_elementIdPrefix" Disabled="@(!_isVisible || IsPanelVisible || _activeTerminalId is null || _detachedTerminalIds.Contains(_activeTerminalId))" + WindowKeysToAdopt="@(_terminals.Select(t => t.TerminalId).ToArray())" + OnWindowsAdopted="@OnWindowsAdopted" + OnWindowTrackingFailed="@OnWindowTrackingFailed" OnWindowOpened="@OnDetachedWindowOpenedAsync" OnWindowClosed="@OnDetachedWindowClosedAsync" /> - @Loc[nameof(Resources.TerminalStrings.TerminalDockDetached)] + @Loc[_recoveringWindowIds.Contains(terminal.TerminalId) + ? nameof(Resources.TerminalStrings.TerminalDockRecoveringWindow) + : nameof(Resources.TerminalStrings.TerminalDockDetached)]
+ data-terminal-window-focus-group="@_elementIdPrefix" + data-terminal-window-focus-key="@terminal.TerminalId"> @Loc[nameof(Resources.TerminalStrings.TerminalDockFocusWindow)]
} - else + else if (_windowTrackingReadyIds.Contains(terminal.TerminalId)) { + @* Check browser-owned handles before mounting, including after component replacement: + even a briefly mounted auto-fit viewer could resize a still-detached terminal. *@ private readonly HashSet _detachedTerminalIds = []; + private readonly HashSet _windowTrackingReadyIds = new(StringComparer.Ordinal); + private readonly HashSet _recoveringWindowIds = new(StringComparer.Ordinal); private TerminalWindowButton? _windowButton; private bool _popupBlocked; @@ -275,6 +277,28 @@ private void Activate(string terminalId) private void OnTerminalToolbarStateChanged(TerminalToolbarState state) => StateHasChanged(); + private void OnWindowsAdopted(string[] keys) + { + if (!_disposed) + { + _windowTrackingReadyIds.UnionWith(keys); + StateHasChanged(); + } + } + + private void OnWindowTrackingFailed(string[] keys) + { + if (!_disposed) + { + foreach (var key in keys.Where(key => _terminals.Any(terminal => terminal.TerminalId == key))) + { + _detachedTerminalIds.Add(key); + _recoveringWindowIds.Add(key); + } + StateHasChanged(); + } + } + private async Task OnDetachedWindowOpenedAsync((string Key, TerminalWindowOpenResult Result) launch) { if (_disposed) @@ -292,30 +316,21 @@ private async Task OnDetachedWindowOpenedAsync((string Key, TerminalWindowOpenRe } _popupBlocked = result == TerminalWindowOpenResult.Blocked; - if (result is TerminalWindowOpenResult.Opened or TerminalWindowOpenResult.Focused) + if (result is TerminalWindowOpenResult.Opened or TerminalWindowOpenResult.Focused or TerminalWindowOpenResult.Adopted or TerminalWindowOpenResult.Recovering) { _detachedTerminalIds.Add(terminalId); _terminalViews.Remove(terminalId); - } - - StateHasChanged(); - } - - private async Task FocusDetachedWindowAsync(string terminalId) - { - try - { - // A window the browser closed without us noticing yet would otherwise leave the pane stuck on the - // placeholder, so a failed focus reattaches instead. - if (_windowButton is null || !await _windowButton.FocusAsync(terminalId).ConfigureAwait(true)) + if (result is TerminalWindowOpenResult.Recovering) { - await OnDetachedWindowClosedAsync(terminalId).ConfigureAwait(true); + _recoveringWindowIds.Add(terminalId); + } + else + { + _recoveringWindowIds.Remove(terminalId); } } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Logger.LogWarning(ex, "Failed to focus the window for terminal {TerminalId}.", terminalId); - } + + StateHasChanged(); } private async Task ReturnToDockAsync(string terminalId) @@ -335,6 +350,9 @@ private async Task ReturnToDockAsync(string terminalId) } _detachedTerminalIds.Remove(terminalId); + _recoveringWindowIds.Remove(terminalId); + // Explicit return is also the escape hatch when unavailable/corrupt storage prevented passive recovery. + _windowTrackingReadyIds.Add(terminalId); StateHasChanged(); } @@ -346,6 +364,7 @@ private Task OnDetachedWindowClosedAsync(string terminalId) => InvokeAsync(() => { if (!_disposed && _detachedTerminalIds.Remove(terminalId)) { + _recoveringWindowIds.Remove(terminalId); StateHasChanged(); } }); @@ -425,6 +444,7 @@ await InvokeAsync(async () => // Recovery snapshots replace all prior state, including terminals removed while offline. endedTerminalIds.AddRange(_detachedTerminalIds.Where(id => !_terminals.Any(t => t.TerminalId == id))); _detachedTerminalIds.ExceptWith(endedTerminalIds); + _recoveringWindowIds.IntersectWith(_detachedTerminalIds); foreach (var id in _terminalViews.Keys.Where(id => !_terminals.Any(t => t.TerminalId == id)).ToArray()) { _terminalViews.Remove(id); @@ -565,6 +585,7 @@ private void UpdateResourceTerminalLinks() case TerminalChangeType.Removed: _terminalViews.Remove(descriptor.TerminalId); + _recoveringWindowIds.Remove(descriptor.TerminalId); if (index >= 0) { _terminals.RemoveAt(index); diff --git a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor index 05263d6c4fb..a334dded8d9 100644 --- a/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor +++ b/src/Aspire.Dashboard/Components/Pages/TerminalWindow.razor @@ -10,11 +10,15 @@ @* Without dashboard navigation or a page toolbar, the terminal fills the window and fits its available space. *@
- @if (_ended) + @if (_windowTrackingFailed) + { +
@Loc[nameof(Dashboard.Resources.TerminalStrings.TerminalWindowTrackingFailed)]
+ } + else if (_ended) {
@Loc[nameof(Dashboard.Resources.TerminalStrings.TerminalWindowEnded)]
} - else + else if (_windowReady) { ? _windowReference; + private Task? _windowRegistrationTask; + private string? _windowRegistrationId; + private bool _windowReady = true; + private bool _windowTrackingFailed; /// /// Gets or sets the id of an AppHost-owned dock terminal to attach to. @@ -55,6 +62,20 @@ public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable [SupplyParameterFromQuery(Name = "fontSize")] public int? FontSize { get; set; } + /// Gets or sets the opener identity carried by a coordinated dock window. + [SupplyParameterFromQuery(Name = "windowOwner")] + public string? WindowOwner { get; set; } + + /// Gets or sets the detachment generation carried by a coordinated dock window. + [SupplyParameterFromQuery(Name = "windowGeneration")] + public string? WindowGeneration { get; set; } + + [Inject] + public required IJSRuntime JS { get; init; } + + [Inject] + public required NavigationManager NavigationManager { get; init; } + [Inject] public required IDashboardClient DashboardClient { get; init; } @@ -69,7 +90,7 @@ protected override async Task OnParametersSetAsync() var terminalId = TerminalId is { Length: > 0 } ? TerminalId : null; var resourceName = terminalId is null && ResourceName is { Length: > 0 } ? ResourceName : null; var replicaIndex = resourceName is not null ? ReplicaIndex : 0; - var routeIdentity = (terminalId, resourceName, replicaIndex); + var routeIdentity = (terminalId, resourceName, replicaIndex, WindowOwner, WindowGeneration); if (_disposed || _routeIdentity == routeIdentity) { return; @@ -78,11 +99,19 @@ protected override async Task OnParametersSetAsync() _routeIdentity = routeIdentity; var generation = ++_watchGeneration; _ended = false; + _windowTrackingFailed = false; + _windowReady = terminalId is null || (WindowOwner is null && WindowGeneration is null); _endpoint = terminalId is not null ? $"api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}" : null; _title = terminalId ?? (resourceName is not null ? replicaIndex > 0 ? $"{resourceName} #{replicaIndex}" : resourceName : string.Empty); + await StopWindowTrackingAsync(release: true); + if (_disposed || generation != _watchGeneration) + { + return; + } + _windowRegistrationTask = null; await StopWatchingAsync(); if (_disposed || generation != _watchGeneration || terminalId is null) { @@ -96,6 +125,113 @@ protected override async Task OnParametersSetAsync() _watchTask = Task.Run(() => WatchTerminalsAsync(terminalId, generation, cancellationToken), cancellationToken); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_disposed) + { + return; + } + if (_ended) + { + await StopWindowTrackingAsync(release: true); + } + else if (!_windowReady && !_windowTrackingFailed && _windowRegistrationTask is null && TerminalId is { } terminalId) + { + _windowRegistrationTask = RegisterWindowAsync(terminalId, _watchGeneration); + await _windowRegistrationTask; + } + } + + private async Task RegisterWindowAsync(string terminalId, int generation) + { + try + { + var moduleUri = new Uri(new Uri(NavigationManager.BaseUri), "js/app-terminalwindow.js"); + _windowModule ??= await JS.InvokeAsync("import", moduleUri.PathAndQuery); + if (_disposed || generation != _watchGeneration) + { + return; + } + _windowReference ??= DotNetObjectReference.Create(this); + var id = _windowRegistrationId = Guid.NewGuid().ToString("N"); + var ready = await _windowModule.InvokeAsync("registerDetachedTerminalWindow", + id, terminalId, NavigationManager.BaseUri, _windowReference); + if (!_disposed && generation == _watchGeneration && !_windowTrackingFailed) + { + // A reload must check durable revocation before mounting an auto-fit viewer. Returning a window + // while this document was loading must not let it take sizing control again. + _windowReady = ready && !_ended; + _ended |= !ready; + StateHasChanged(); + } + } + catch (JSDisconnectedException) + { + // A new document will independently validate its generation. + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to coordinate the detached terminal window."); + if (!_disposed && generation == _watchGeneration) + { + _windowTrackingFailed = true; + StateHasChanged(); + } + } + } + + /// Stops rendering a detached viewer after its generation was explicitly returned or replaced. + /// The browser registration to revoke. + /// A task that completes after the viewer is removed. + [JSInvokable] + public Task OnDetachedTerminalWindowRevokedAsync(string id) => InvokeAsync(() => + { + if (!_disposed && _windowRegistrationId == id) + { + _ended = true; + _windowReady = false; + StateHasChanged(); + } + }); + + /// Reports a browser coordination failure without treating it as a successfully recovered window. + /// The affected browser registration. + /// A task that completes after the failure is displayed. + [JSInvokable] + public Task OnDetachedTerminalWindowTrackingFailedAsync(string id) => InvokeAsync(() => + { + if (!_disposed && _windowRegistrationId == id) + { + _windowTrackingFailed = true; + _windowReady = false; + StateHasChanged(); + } + }); + + private async Task StopWindowTrackingAsync(bool release) + { + if (_windowRegistrationTask is { } registration) + { + await registration; + } + if (_windowModule is { } module && _windowRegistrationId is { } id) + { + _windowRegistrationId = null; + try + { + await module.InvokeVoidAsync(release ? "releaseDetachedTerminalWindow" : "unregisterDetachedTerminalWindow", id); + } + catch (JSDisconnectedException) + { + // Disposal on document reload must not revoke the durable detachment. + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to release detached terminal window tracking."); + } + } + } + private async Task WatchTerminalsAsync(string terminalId, int generation, CancellationToken cancellationToken) { try @@ -220,5 +356,11 @@ public async ValueTask DisposeAsync() _disposed = true; await StopWatchingAsync().ConfigureAwait(false); + await StopWindowTrackingAsync(release: false).ConfigureAwait(false); + if (_windowModule is { } module) + { + await Utils.JSInteropHelpers.SafeDisposeAsync(module).ConfigureAwait(false); + } + _windowReference?.Dispose(); } } diff --git a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs index 8b3f3775391..bcbb3e22c97 100644 --- a/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs +++ b/src/Aspire.Dashboard/Model/TerminalWindowLauncher.cs @@ -30,7 +30,17 @@ public enum TerminalWindowOpenResult /// /// The browser could not open or focus the window. /// - Failed + Failed, + + /// + /// A surviving window was adopted without opening, focusing, or navigating it. + /// + Adopted, + + /// + /// A durable detachment record exists, but its window has not yet responded after a document reload. + /// + Recovering } /// @@ -66,7 +76,7 @@ public sealed class TerminalWindowLauncher : IAsyncDisposable /// /// The JS runtime for the owning component's circuit. /// The navigation manager providing the dashboard's base URI. - /// Invoked after a native click opens or focuses a window, with its captured key and outcome. + /// Invoked after opening, focusing, or adopting a window, with its captured key and outcome. /// /// Invoked with the terminal key when the user closes a detached window. Not raised for windows closed through /// , because the caller already knows about those. @@ -103,11 +113,24 @@ private async Task RegisterCoreAsync(string buttonId) _module = await _js.InvokeAsync("import", moduleUri.PathAndQuery).ConfigureAwait(false); if (!_disposed) { - await _module.InvokeVoidAsync("registerTerminalWindowButton", buttonId, _id, _selfRef).ConfigureAwait(false); + await _module.InvokeVoidAsync("registerTerminalWindowButton", buttonId, _id, _selfRef, _navigationManager.BaseUri).ConfigureAwait(false); + } + } + + /// + /// Adopts surviving windows or durable detachment records for the supplied terminal keys. + /// + /// The current terminal identities belonging to the component. + /// A task that completes after the component has reconciled the surviving windows. + public async Task AdoptAsync(string[] keys) + { + if (!_disposed && _module is { } module) + { + await module.InvokeVoidAsync("adoptTerminalWindows", _id, keys).ConfigureAwait(false); } } - /// Receives the captured terminal key and browser result after the synchronous native launch. + /// Receives the captured terminal key and browser result after a native launch or window adoption. /// The terminal key at the time of the click, not the current selection. /// The browser's launch outcome. /// A task that completes when the owning component has reconciled the outcome. @@ -117,6 +140,8 @@ public Task OnTerminalWindowOpenedAsync(string key, string result) { "opened" => TerminalWindowOpenResult.Opened, "focused" => TerminalWindowOpenResult.Focused, + "adopted" => TerminalWindowOpenResult.Adopted, + "recovering" => TerminalWindowOpenResult.Recovering, "blocked" => TerminalWindowOpenResult.Blocked, _ => TerminalWindowOpenResult.Failed }); @@ -174,8 +199,8 @@ public async ValueTask DisposeAsync() { try { - // Stop watching, but leave the windows open. They are independent viewers of an AppHost-owned - // terminal, so closing them because the opener navigated away would throw away live work. + // Release this callback, but retain the browser's handles for replacement components. The windows + // are independent viewers, so closing them because the opener navigated away would lose live work. await module.InvokeVoidAsync("unregisterTerminalWindowButton", _id).ConfigureAwait(false); await module.DisposeAsync().ConfigureAwait(false); } diff --git a/src/Aspire.Dashboard/Resources/TerminalStrings.Designer.cs b/src/Aspire.Dashboard/Resources/TerminalStrings.Designer.cs index f97537fc160..aa2bc6eabc6 100644 --- a/src/Aspire.Dashboard/Resources/TerminalStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/TerminalStrings.Designer.cs @@ -195,6 +195,24 @@ public static string TerminalDockDetached { } } + /// + /// Looks up a localized string similar to This terminal may still be open in a separate window. Focus it or return it to the panel to continue.. + /// + public static string TerminalDockRecoveringWindow { + get { + return ResourceManager.GetString("TerminalDockRecoveringWindow", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel.. + /// + public static string TerminalWindowTrackingFailed { + get { + return ResourceManager.GetString("TerminalWindowTrackingFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to The browser blocked the terminal window. Allow pop-ups for the dashboard and try again.. /// diff --git a/src/Aspire.Dashboard/Resources/TerminalStrings.resx b/src/Aspire.Dashboard/Resources/TerminalStrings.resx index 228a925ff3c..8574e3dfb3a 100644 --- a/src/Aspire.Dashboard/Resources/TerminalStrings.resx +++ b/src/Aspire.Dashboard/Resources/TerminalStrings.resx @@ -187,6 +187,12 @@ This terminal is running in a separate window. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + The browser blocked the terminal window. Allow pop-ups for the dashboard and try again. diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.cs.xlf index 4ed389ddb5c..f0a54380a69 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.cs.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.de.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.de.xlf index e93203e5937..83fdce17caa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.de.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.es.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.es.xlf index 42058cb6d9a..decbfadc3e9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.es.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.fr.xlf index 8f205853d70..2c6896a9146 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.fr.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.it.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.it.xlf index b4c869cdb5a..9b467f87c85 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.it.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ja.xlf index c9fa8b6ea23..d63444d61bf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ja.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ko.xlf index 07824ea0d1c..4fda386df5b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ko.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.pl.xlf index 0992c2f02d9..96f059136bb 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.pl.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.pt-BR.xlf index 7880ccfaaa5..87847edcafb 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.pt-BR.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ru.xlf index cc51553bb4f..69567a565f9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.ru.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.tr.xlf index f67faab45e2..4e540dc1529 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.tr.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.zh-Hans.xlf index 26138c4fcb1..ff3ec04ac92 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.zh-Hans.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.zh-Hant.xlf index ebb24da1853..3a2dd811a09 100644 --- a/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/TerminalStrings.zh-Hant.xlf @@ -82,6 +82,11 @@ Press the backtick key (`) to hide this panel. + + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + This terminal may still be open in a separate window. Focus it or return it to the panel to continue. + + Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. Use Up or Down to resize, Shift for larger steps, Home for minimum height, and End for maximum height. @@ -177,6 +182,11 @@ This terminal has ended. + + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + Unable to recover the terminal window state. Check browser storage permissions, reload, or explicitly return the terminal to the panel. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js index 0f684d07899..6f32a79851d 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js @@ -10,10 +10,19 @@ // // Keys are opaque strings chosen by the caller: a dock terminal id, or "resource::". They only have to // be stable and unique within the page. +// Dock windows also have a durable, generation-scoped record. After a document reload that record keeps the dock +// on its placeholder until the independent page supplies its WindowProxy through a same-origin message. +// A missing response is NOT proof of closure: background pages can be suspended indefinitely. const openWindows = new Map(); const launchers = new Map(); +const detachedPages = new Map(); +let openerContext = null; let pollHandle = null; +const RECORD_PREFIX = 'aspire-terminal-window:'; +const REQUEST_PREFIX = 'aspire-terminal-window-request:'; +const OWNER_PREFIX = 'aspire-terminal-window-owner:'; +const MESSAGE_TYPE = 'aspire-terminal-window-ready'; // The opener finds out about a closed popup by polling `closed` rather than by listening for a `pagehide` message // from the popup. `pagehide` does not fire when the tab crashes or is force-closed by the OS, and a terminal that is @@ -23,13 +32,13 @@ const POLL_INTERVAL_MS = 400; const DEFAULT_FEATURES = 'popup=yes,resizable=yes,scrollbars=no,menubar=no,toolbar=no,location=no,status=no'; -export function registerTerminalWindowButton(buttonId, id, owner) { +export function registerTerminalWindowButton(buttonId, id, owner, baseUri) { unregisterTerminalWindowButton(id); const button = document.getElementById(buttonId); if (!button) { throw new Error('The terminal window button is no longer available.'); } - const launcher = { button, owner, pending: Promise.resolve(), disposed: false }; + const launcher = { button, owner, baseUri, dockKeys: new Set(), pending: Promise.resolve(), disposed: false }; launcher.click = () => { if (launcher.disposed || !button.isConnected || button.disabled || button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true' || button.closest('[inert], [hidden]')) { @@ -55,19 +64,48 @@ export function registerTerminalWindowButton(buttonId, id, owner) { result = 'failed'; } - const entry = openWindows.get(key); - notify(launcher, () => { - // A queued result must not resurrect a window that closed or was returned to the dock meanwhile. - if (result === 'opened' || result === 'focused') { - if (openWindows.get(key) !== entry || entry.win.closed) { - return; - } + notifyLaunch(launcher, key, result); + }; + launcher.focusClick = event => { + const focusButton = event.target.closest?.('[data-terminal-window-focus-key]'); + const group = button.getAttribute('data-terminal-window-focus-group'); + if (!group || !focusButton || focusButton.getAttribute('data-terminal-window-focus-group') !== group || + launcher.disposed || !focusButton.isConnected || focusButton.disabled || + focusButton.hasAttribute('disabled') || focusButton.closest('[inert], [hidden]')) { + return; + } + const key = focusButton.getAttribute('data-terminal-window-focus-key'); + let result; + try { + const entry = openWindows.get(key); + if (!entry) { + throw new Error('The detached terminal window is no longer tracked.'); } - return owner.invokeMethodAsync('OnTerminalWindowOpenedAsync', key, result); - }); + // Recovery without a handle may need to reuse a named target. Only this native user click can do + // that; discovery, storage events, and polling must never call window.open. + result = openTerminalWindow(key, entry.record?.url, 960, 600, launcher); + } catch (error) { + console.error('Failed to focus the terminal window.', error); + result = 'failed'; + } + notifyLaunch(launcher, key, result); }; launchers.set(id, launcher); button.addEventListener('click', launcher.click); + document.addEventListener('click', launcher.focusClick); +} + +function notifyLaunch(launcher, key, result) { + const entry = openWindows.get(key); + const record = entry?.record; + notify(launcher, () => { + if (['opened', 'focused', 'adopted', 'recovering'].includes(result)) { + if (openWindows.get(key) !== entry || entry.owner !== launcher || entry.record !== record || entry.win?.closed) { + return; + } + } + return launcher.owner.invokeMethodAsync('OnTerminalWindowOpenedAsync', key, result); + }); } export function unregisterTerminalWindowButton(id) { @@ -77,23 +115,72 @@ export function unregisterTerminalWindowButton(id) { } launcher.disposed = true; launcher.button.removeEventListener('click', launcher.click); + document.removeEventListener('click', launcher.focusClick); launchers.delete(id); - for (const [key, entry] of openWindows) { + for (const entry of openWindows.values()) { if (entry.owner === launcher) { + // The component owns the listener, not the independent viewer. Retain its handle for a replacement + // component, but release the old circuit reference and never notify that disposed owner again. + entry.owner = null; + } + } + releaseUnusedContext(); +} + +export function adoptTerminalWindows(id, keys) { + const launcher = launchers.get(id); + if (!launcher) { + throw new Error('The terminal window launcher is no longer available.'); + } + + // Only adopt the caller's current terminal identities, never every window in this module. In particular, + // AppHost dock IDs come from its metadata snapshot, not resource names or persisted titles from another run. + const context = getOpenerContext(launcher.baseUri); + const notifications = []; + for (const key of keys) { + launcher.dockKeys.add(key); + let entry = openWindows.get(key); + let record = readRecord(context, key); + if (entry?.win?.closed) { + removeRecord(entry); openWindows.delete(key); + entry = null; + record = readRecord(context, key); + } + if (record && entry?.record?.generation !== record.generation) { + entry = { win: null, owner: launcher, record, context }; + openWindows.set(key, entry); } + if (!entry) { + continue; + } + + entry.owner = launcher; + notifications.push(notify(launcher, () => { + if (openWindows.get(key) === entry && entry.owner === launcher && !entry.win?.closed) { + // Reuse the detach acknowledgement without focusing or navigating the independent window. + return launcher.owner.invokeMethodAsync('OnTerminalWindowOpenedAsync', key, entry.win ? 'adopted' : 'recovering'); + } + })); } - stopPollingIfEmpty(); + requestDiscovery(context); + + // The dock must reconcile these acknowledgements before mounting ANY candidate viewer. Returning just a + // snapshot could overtake a close/return notification and resurrect a stale detached state. + return Promise.all(notifications); } function notify(launcher, callback) { // Serialize notifications, NOT browser operations. A slow open acknowledgement cannot delay a subsequent // click's popup, and a close notification cannot overtake the corresponding detach acknowledgement. - launcher.pending = launcher.pending.then(() => { + const notification = launcher.pending.then(() => { if (!launcher.disposed) { return callback(); } - }).catch(error => console.warn('Could not update terminal window state in the dashboard.', error)); + }); + launcher.pending = notification.catch(error => console.warn('Could not update terminal window state in the dashboard.', error)); + // Click/poll notifications are fire-and-forget, but adoption must not enable viewers if reconciliation failed. + return notification; } /** @@ -102,7 +189,7 @@ function notify(launcher, callback) { */ function openTerminalWindow(key, url, width, height, owner) { const existing = openWindows.get(key); - if (existing && !existing.win.closed) { + if (existing?.win && !existing.win.closed) { existing.win.focus(); existing.owner = owner; return 'focused'; @@ -112,24 +199,59 @@ function openTerminalWindow(key, url, width, height, owner) { // A name makes the popup reusable: if the user closed the tab that opened it and detaches again, the browser // targets the same window instead of stacking a second one on top of it. - const win = window.open(url, windowNameFor(key), features); + let record; + let context; + let createdRecord = false; + if (owner.dockKeys.has(key)) { + context = getOpenerContext(owner.baseUri); + record = readRecord(context, key); + if (!record || existing?.win?.closed) { + const target = new URL(url); + if (target.origin !== new URL(context.baseUri).origin || + target.pathname !== terminalPath(context.baseUri, key)) { + throw new Error('The terminal window URL is outside this dashboard.'); + } + const generation = newId(); + target.searchParams.set('windowOwner', context.ownerId); + target.searchParams.set('windowGeneration', generation); + record = { version: 1, key, generation, url: target.href }; + // Persist BEFORE opening. A main-page reload between window.open and the popup's initialization + // must not allow the new dock to create a competing auto-fit viewer. + window.localStorage.setItem(recordKey(context, key), JSON.stringify(record)); + createdRecord = true; + } + url = record.url; + } + let win; + try { + win = window.open(url, record ? coordinatedWindowName(context, record) : windowNameFor(key), features); + } catch (error) { + if (createdRecord) { + removeRecord({ context, record }); + } + throw error; + } if (!win) { + if (createdRecord) { + removeRecord({ context, record }); + } // Blocked. The caller surfaces this, because a silently missing window looks like the terminal was lost. return 'blocked'; } - openWindows.set(key, { win, owner }); + openWindows.set(key, { win, owner, record, context }); ensurePolling(); return 'opened'; } export function focusTerminalWindow(key) { const entry = openWindows.get(key); - if (!entry || entry.win.closed) { + if (!entry || entry.win?.closed) { return false; } - entry.win.focus(); + // An unresolved durable record is not a closed window. The native focus button can recover its named target. + entry.win?.focus(); return true; } @@ -139,9 +261,16 @@ export function focusTerminalWindow(key) { */ export function closeTerminalWindow(key) { const entry = openWindows.get(key); + // Revocation is durable before returning control to the dock. A suspended or reloading detached page checks + // this generation before it mounts again, and a late ready message cannot resurrect a returned window. + removeRecord(entry); + if (!entry && openerContext) { + // Explicit return must also recover from a corrupt record that passive adoption could not parse. + window.localStorage.removeItem(recordKey(openerContext, key)); + } openWindows.delete(key); - if (entry && !entry.win.closed) { + if (entry?.win && !entry.win.closed) { entry.win.close(); } stopPollingIfEmpty(); @@ -149,7 +278,7 @@ export function closeTerminalWindow(key) { export function isTerminalWindowOpen(key) { const entry = openWindows.get(key); - return !!entry && !entry.win.closed; + return !!entry && !entry.win?.closed; } function windowNameFor(key) { @@ -167,17 +296,25 @@ function ensurePolling() { // Snapshot the entries: the .NET callback can re-enter this module (for example by detaching another // terminal) and mutate the map while we are walking it. for (const [key, entry] of [...openWindows.entries()]) { - if (!entry.win.closed) { + let ended = entry.win?.closed; + if (!ended && entry.announced) { + try { + ended = entry.win.location.href !== entry.record.url; + } catch { + // A recovered window that navigates to another origin is no longer this terminal viewer. + ended = true; + } + } + if (!ended) { continue; } - openWindows.delete(key); - - notify(entry.owner, () => { - if (!openWindows.has(key)) { - return entry.owner.owner.invokeMethodAsync('OnTerminalWindowClosedAsync', key); - } - }); + try { + removeRecord(entry); + forgetWindow(key, entry); + } catch (error) { + reportTrackingFailure(entry, error); + } } stopPollingIfEmpty(); @@ -189,4 +326,247 @@ function stopPollingIfEmpty() { clearInterval(pollHandle); pollHandle = null; } + releaseUnusedContext(); +} + +function newId() { + // getRandomValues also works on HTTP origins; randomUUID requires a secure context. + return [...window.crypto.getRandomValues(new Uint32Array(4))].map(value => value.toString(16).padStart(8, '0')).join(''); +} + +function terminalPath(baseUri, key) { + return new URL(`terminal-window/apphost/${encodeURIComponent(key)}`, baseUri).pathname; +} + +function recordKey(context, key) { + return RECORD_PREFIX + JSON.stringify([context.baseUri, context.ownerId, key]); +} + +function requestKey(context) { + return REQUEST_PREFIX + JSON.stringify([context.baseUri, context.ownerId]); +} + +function coordinatedWindowName(context, record) { + // A new detach generation gets a distinct browsing context. A delayed return for the old generation must + // never close a replacement that happens to have the same terminal ID. + return windowNameFor(JSON.stringify([context.baseUri, context.ownerId, record.key, record.generation])); +} + +function readRecord(context, key) { + const raw = window.localStorage.getItem(recordKey(context, key)); + if (raw === null) { + return null; + } + // Records contain {version:1,key,generation,url}; URL carries fontSize, windowOwner, windowGeneration. + // Treat corrupt/unavailable storage as a failure, never as evidence that no detached viewer exists. + const record = JSON.parse(raw); + const url = new URL(record.url); + if (record.version !== 1 || record.key !== key || typeof record.generation !== 'string' || !record.generation || + url.origin !== new URL(context.baseUri).origin || url.pathname !== terminalPath(context.baseUri, key) || + url.searchParams.get('windowOwner') !== context.ownerId || url.searchParams.get('windowGeneration') !== record.generation) { + throw new Error('Invalid detached terminal window record.'); + } + return record; +} + +function removeRecord(entry) { + if (entry?.record && readRecord(entry.context, entry.record.key)?.generation === entry.record.generation) { + window.localStorage.removeItem(recordKey(entry.context, entry.record.key)); + } +} + +function getOpenerContext(baseUri) { + if (openerContext) { + if (openerContext.baseUri !== baseUri) { + throw new Error('A terminal launcher cannot change dashboard scope.'); + } + return openerContext; + } + // sessionStorage survives reload but is initially copied into windows opened by this page. Include the + // browsing-context name to separate those new dashboards, preserving any existing nonempty target name. + // https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage + if (!window.name) { + window.name = `aspire-dashboard-${newId()}`; + } + const storageKey = OWNER_PREFIX + baseUri; + const raw = window.sessionStorage.getItem(storageKey); + const saved = raw === null ? null : JSON.parse(raw); + if (saved && (typeof saved.name !== 'string' || typeof saved.id !== 'string' || !saved.id)) { + throw new Error('Invalid terminal window owner record.'); + } + const ownerId = saved?.name === window.name ? saved.id : newId(); + window.sessionStorage.setItem(storageKey, JSON.stringify({ name: window.name, id: ownerId })); + const context = { baseUri, ownerId, documentId: newId() }; + context.message = event => { + const data = event.data; + if (event.origin !== new URL(baseUri).origin || data?.type !== MESSAGE_TYPE || + data.baseUri !== baseUri || data.ownerId !== ownerId || data.documentId !== context.documentId) { + return; + } + const entry = openWindows.get(data.key); + if (!entry?.record || entry.record.generation !== data.generation || !event.source || + (entry.win && entry.win !== event.source)) { + return; + } + try { + if (readRecord(context, data.key)?.generation !== data.generation || + event.source.name !== coordinatedWindowName(context, entry.record) || + event.source.location.href !== entry.record.url) { + return; + } + entry.win = event.source; + entry.announced = true; + ensurePolling(); + if (entry.owner) { + notifyLaunch(entry.owner, data.key, 'adopted'); + } + } catch (error) { + reportTrackingFailure(entry, error); + } + }; + context.storage = event => { + for (const [key, entry] of openWindows) { + if (!entry.record || (event.key !== null && event.key !== recordKey(context, key))) { + continue; + } + try { + const record = readRecord(context, key); + if (!record) { + forgetWindow(key, entry); + } else if (record.generation !== entry.record.generation) { + entry.win = null; + entry.announced = false; + entry.record = record; + if (entry.owner) { + notifyLaunch(entry.owner, key, 'recovering'); + } + requestDiscovery(context); + } + } catch (error) { + reportTrackingFailure(entry, error); + } + } + stopPollingIfEmpty(); + }; + window.addEventListener('message', context.message); + window.addEventListener('storage', context.storage); + openerContext = context; + return context; +} + +function requestDiscovery(context) { + window.localStorage.setItem(requestKey(context), JSON.stringify({ documentId: context.documentId, requestId: newId() })); +} + +function forgetWindow(key, entry) { + if (openWindows.get(key) !== entry) { + return; + } + openWindows.delete(key); + const owner = entry.owner; + if (owner) { + notify(owner, () => { + if (!openWindows.has(key)) { + return owner.owner.invokeMethodAsync('OnTerminalWindowClosedAsync', key); + } + }); + } +} + +function reportTrackingFailure(entry, error) { + if (entry.trackingFailureReported) { + return; + } + entry.trackingFailureReported = true; + console.warn('Could not reconcile the detached terminal window.', error); + if (entry.owner) { + notifyLaunch(entry.owner, entry.record.key, 'failed'); + } +} + +function releaseUnusedContext() { + if (openerContext && ![...launchers.values()].some(launcher => launcher.dockKeys.size) && + ![...openWindows.values()].some(entry => entry.record)) { + window.removeEventListener('message', openerContext.message); + window.removeEventListener('storage', openerContext.storage); + openerContext = null; + } +} + +export function registerDetachedTerminalWindow(id, key, baseUri, owner) { + unregisterDetachedTerminalWindow(id); + const url = new URL(window.location.href); + const ownerId = url.searchParams.get('windowOwner'); + const generation = url.searchParams.get('windowGeneration'); + if (!url.searchParams.has('windowOwner') && !url.searchParams.has('windowGeneration')) { + return true; + } + if (!ownerId || !generation) { + throw new Error('Incomplete detached terminal window identity.'); + } + const context = { baseUri, ownerId }; + const record = readRecord(context, key); + if (!record || record.generation !== generation) { + window.close(); + return false; + } + if (window.name !== coordinatedWindowName(context, record) || url.href !== record.url) { + throw new Error('The detached terminal window identity does not match its browsing context.'); + } + const page = { id, context, record, owner, disposed: false }; + const reconcile = () => { + try { + if (readRecord(context, key)?.generation !== generation) { + unregisterDetachedTerminalWindow(id); + owner.invokeMethodAsync('OnDetachedTerminalWindowRevokedAsync', id) + .catch(error => console.warn('Could not release the returned terminal viewer.', error)); + window.close(); + return false; + } + const raw = window.localStorage.getItem(requestKey(context)); + if (raw !== null && window.opener && !window.opener.closed) { + const request = JSON.parse(raw); + if (typeof request.documentId !== 'string' || !request.documentId) { + throw new Error('Invalid terminal window discovery request.'); + } + // event.source gives the reloaded opener a real WindowProxy, without a passive window.open. + // https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage + window.opener.postMessage({ + type: MESSAGE_TYPE, baseUri, ownerId, key, generation, documentId: request.documentId, + }, new URL(baseUri).origin); + } + return true; + } catch (error) { + unregisterDetachedTerminalWindow(id); + console.warn('Could not coordinate the detached terminal window.', error); + owner.invokeMethodAsync('OnDetachedTerminalWindowTrackingFailedAsync', id) + .catch(error => console.warn('Could not report terminal window coordination failure.', error)); + return false; + } + }; + page.storage = event => { + if (!page.disposed && (event.key === null || event.key === requestKey(context) || event.key === recordKey(context, key))) { + reconcile(); + } + }; + detachedPages.set(id, page); + window.addEventListener('storage', page.storage); + return reconcile(); +} + +export function releaseDetachedTerminalWindow(id) { + const page = detachedPages.get(id); + if (page) { + removeRecord(page); + unregisterDetachedTerminalWindow(id); + } +} + +export function unregisterDetachedTerminalWindow(id) { + const page = detachedPages.get(id); + if (page) { + page.disposed = true; + window.removeEventListener('storage', page.storage); + detachedPages.delete(id); + } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs index 9de01d5b57e..8d41a5d94e9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs @@ -1,11 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Threading.Channels; using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Model; using Bunit; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; @@ -211,4 +213,119 @@ public void RegistrationFailure_StaysDisabledAndShowsActionableToast() Assert.Single(module.Invocations, invocation => invocation.Identifier == "registerTerminalWindowButton"); Assert.Single(toasts.FindComponents()); } + + [Fact] + public async Task Adoption_RegistersWithoutViewerMetadataAndWaitsForReconciliation() + { + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + var registration = module.SetupVoid("registerTerminalWindowButton", _ => true); + var adoption = module.SetupVoid("adoptTerminalWindows", _ => true); + var opened = new List<(string Key, TerminalWindowOpenResult Result)>(); + var checkedKeys = Channel.CreateUnbounded(); + var cut = RenderComponent(builder => builder + .Add(p => p.Label, "Open") + .Add(p => p.Disabled, true) + .Add(p => p.WindowKeysToAdopt, ["first", "inactive"]) + .Add(p => p.OnWindowOpened, launch => opened.Add(launch)) + .Add(p => p.OnWindowsAdopted, keys => { checkedKeys.Writer.TryWrite(keys); })); + + Assert.Single(registration.Invocations); + Assert.Empty(adoption.Invocations); + Assert.False(checkedKeys.Reader.TryPeek(out _)); + registration.SetVoidResult(); + + // Registration renders before starting adoption, which is deliberately paused here. No subsequent render + // can wake WaitForAssertion, so observe the interop invocation independently on the renderer instead. + await AsyncTestHelpers.AssertIsTrueRetryAsync( + () => cut.InvokeAsync(() => adoption.Invocations.Count > 0), + "Window adoption did not start after registration completed."); + var invocation = Assert.Single(adoption.Invocations); + Assert.Equal(registration.Invocations.Single().Arguments[1], invocation.Arguments[0]); + Assert.Equal(["first", "inactive"], Assert.IsType(invocation.Arguments[1])); + Assert.True(cut.FindComponent().Instance.Disabled); + cut.Render(); + Assert.Single(registration.Invocations); + Assert.Single(adoption.Invocations); + + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("inactive", "adopted")); + Assert.Equal([("inactive", TerminalWindowOpenResult.Adopted)], opened); + Assert.False(checkedKeys.Reader.TryPeek(out _)); + adoption.SetVoidResult(); + Assert.Equal(["first", "inactive"], await checkedKeys.Reader.ReadAsync().AsTask().DefaultTimeout()); + + cut.SetParametersAndRender(builder => builder.Add(p => p.WindowKeysToAdopt, ["inactive", "new"])); + Assert.Equal(["new"], await checkedKeys.Reader.ReadAsync().AsTask().DefaultTimeout()); + Assert.Equal(2, adoption.Invocations.Count); + Assert.Equal(["new"], Assert.IsType(adoption.Invocations.Last().Arguments[1])); + Assert.Single(registration.Invocations); + Assert.True(cut.FindComponent().Instance.Disabled); + } + + [Fact] + public async Task Disposal_DuringAdoption_DoesNotSignalReadinessOrCloseWindows() + { + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + var adoption = module.SetupVoid("adoptTerminalWindows", _ => true); + var checkedKeys = new List(); + var cut = RenderComponent(builder => builder + .Add(p => p.Label, "Open") + .Add(p => p.WindowKeysToAdopt, ["terminal"]) + .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys))); + Assert.Single(adoption.Invocations); + + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); + adoption.SetVoidResult(); + cut.Render(); + + Assert.Empty(checkedKeys); + Assert.Equal(["registerTerminalWindowButton", "adoptTerminalWindows", "unregisterTerminalWindowButton"], + module.Invocations.Select(i => i.Identifier)); + } + + [Fact] + public void AdoptionFailure_DoesNotSignalReadinessOrRetryOnEveryRender() + { + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + var adoption = module.SetupVoid("adoptTerminalWindows", _ => true); + adoption.SetException(new JSException("Window reconciliation failed")); + var checkedKeys = new List(); + var toasts = RenderComponent(); + var cut = RenderComponent(builder => builder + .Add(p => p.Label, "Open") + .Add(p => p.WindowKeysToAdopt, ["terminal"]) + .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys))); + + toasts.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalWindowTrackingFailed, + Assert.Single(toasts.FindComponents()).Instance.Title)); + cut.Render(); + Assert.Empty(checkedKeys); + Assert.Single(adoption.Invocations); + } + + [Theory] + [InlineData("registerTerminalWindowButton")] + [InlineData("adoptTerminalWindows")] + public async Task TrackingFailure_ReportsNewTerminalsWithoutRetrying(string operation) + { + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + var failure = module.SetupVoid(operation, _ => true); + failure.SetException(new JSException("Browser storage unavailable")); + var failedKeys = Channel.CreateUnbounded(); + var checkedKeys = new List(); + RenderComponent(); + var cut = RenderComponent(builder => builder + .Add(p => p.Label, "Open") + .Add(p => p.WindowKeysToAdopt, ["first"]) + .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys)) + .Add(p => p.OnWindowTrackingFailed, keys => { failedKeys.Writer.TryWrite(keys); })); + Assert.Equal(["first"], await failedKeys.Reader.ReadAsync().AsTask().DefaultTimeout()); + + cut.SetParametersAndRender(builder => builder.Add(p => p.WindowKeysToAdopt, ["first", "new"])); + Assert.Equal(["new"], await failedKeys.Reader.ReadAsync().AsTask().DefaultTimeout()); + cut.Render(); + Assert.False(failedKeys.Reader.TryPeek(out _)); + Assert.Empty(checkedKeys); + Assert.Single(failure.Invocations); + } } diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs index 361ee4228ad..85f975d68b6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs @@ -2,7 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. import assert from "node:assert/strict"; -import { afterEach, beforeEach, mock, test } from "node:test"; +import { readFile } from "node:fs/promises"; +import { afterEach, beforeEach, describe, mock, test } from "node:test"; import * as terminalWindows from "../../../src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js"; let keys; @@ -29,11 +30,15 @@ beforeEach(() => { documentDescriptor = Object.getOwnPropertyDescriptor(globalThis, "document"); Object.defineProperty(globalThis, "document", { configurable: true, - value: { getElementById: id => elements.get(id) ?? null }, + value: Object.assign(new EventTarget(), { getElementById: id => elements.get(id) ?? null }), }); Object.defineProperty(globalThis, "window", { configurable: true, - value: { + value: Object.assign(new EventTarget(), { + name: "", + crypto: globalThis.crypto, + sessionStorage: new TestStorage(), + localStorage: new TestStorage(), open(url, name, features) { // Browsers reuse and navigate an existing browsing context with the same target name. let popup = contexts.get(name); @@ -50,7 +55,7 @@ beforeEach(() => { calls.push({ name, popup, features }); return popup; }, - }, + }), }); mock.method(globalThis, "setInterval", callback => { poll = callback; @@ -97,7 +102,7 @@ function register(key = "terminal", url = "https://localhost/dashboard/terminal- registrations.push(id); elements.set(id, button); buttons.set(key, button); - terminalWindows.registerTerminalWindowButton(id, id, owner); + terminalWindows.registerTerminalWindowButton(id, id, owner, "https://localhost/dashboard/"); return { id, button, owner }; } @@ -134,7 +139,7 @@ for (const [firstKey, secondKey] of [ }); } -test("the same key focuses its window and reuses its stable name after untracking", () => { +test("the same key retains its handle and focuses without navigation after launcher replacement", () => { const key = "resource:a.b:0"; const firstUrl = "https://localhost/dashboard/terminal-window/resource/a.b/0"; const nextUrl = `${firstUrl}?fontSize=16`; @@ -148,13 +153,12 @@ test("the same key focuses its window and reuses its stable name after untrackin terminalWindows.unregisterTerminalWindowButton(registrations[0]); buttons.delete(key); assert.equal(first.popup.closed, false); - assert.equal(terminalWindows.isTerminalWindowOpen(key), false); + assert.equal(terminalWindows.isTerminalWindowOpen(key), true); - assert.equal(open(key, nextUrl), "opened"); - assert.equal(calls.length, 2); - assert.equal(calls[1].name, first.name); - assert.equal(calls[1].popup, first.popup); - assert.equal(first.popup.url, nextUrl); + assert.equal(open(key, nextUrl), "focused"); + assert.equal(calls.length, 1); + assert.equal(first.popup.focusCalls, 2); + assert.equal(first.popup.url, firstUrl); }); test("native clicks open and focus synchronously, even while a previous .NET acknowledgement is pending", async () => { @@ -231,7 +235,7 @@ test("blocked popups are reported with the captured key and can be retried", asy test("re-registration removes the old listener and disposal leaves independent windows open", async () => { const { button, id, owner } = register(); - terminalWindows.registerTerminalWindowButton(id, id, owner); + terminalWindows.registerTerminalWindowButton(id, id, owner, "https://localhost/dashboard/"); button.click(); await flushNotifications(); assert.equal(calls.length, 1); @@ -242,11 +246,149 @@ test("re-registration removes the old listener and disposal leaves independent w await flushNotifications(); assert.equal(calls.length, 1); assert.equal(calls[0].popup.closed, false); + assert.equal(typeof poll, "function"); + assert.equal(terminalWindows.isTerminalWindowOpen("terminal"), true); + assert.equal(notifications.length, 1); + + calls[0].popup.close(); + poll(); + await flushNotifications(); assert.equal(poll, null); assert.equal(terminalWindows.isTerminalWindowOpen("terminal"), false); assert.equal(notifications.length, 1); }); +test("a replacement adopts all requested surviving handles without clicks, focus, or navigation", async () => { + const old = register("first", "https://localhost/dashboard/terminal-window/apphost/first?fontSize=23"); + old.button.click(); + keys.add("inactive"); + old.button.setAttribute("data-terminal-window-key", "inactive"); + old.button.setAttribute("data-terminal-window-url", "https://localhost/dashboard/terminal-window/apphost/inactive?fontSize=19"); + old.button.click(); + await flushNotifications(); + terminalWindows.unregisterTerminalWindowButton(old.id); + const unrelated = register("resource:first:0"); + unrelated.button.click(); + await flushNotifications(); + const current = register("first"); + current.button.disabled = true; + + await terminalWindows.adoptTerminalWindows(current.id, ["first", "inactive", "missing"]); + assert.deepEqual(notifications.slice(3), [ + ["OnTerminalWindowOpenedAsync", "first", "adopted"], + ["OnTerminalWindowOpenedAsync", "inactive", "adopted"], + ]); + assert.equal(calls.length, 3); + assert.deepEqual(calls.map(call => call.popup.focusCalls), [0, 0, 0]); + assert.equal(calls[0].popup.url, "https://localhost/dashboard/terminal-window/apphost/first?fontSize=23"); + assert.equal(calls[1].popup.url, "https://localhost/dashboard/terminal-window/apphost/inactive?fontSize=19"); + + terminalWindows.unregisterTerminalWindowButton(current.id); + for (const call of calls) { + call.popup.close(); + } + poll(); + await flushNotifications(); + assert.deepEqual(notifications.slice(5), [["OnTerminalWindowClosedAsync", "resource:first:0"]]); + assert.equal(poll, null); +}); + +test("a closed orphan is not adopted, even before polling notices its closure", async () => { + const old = register(); + old.button.click(); + await flushNotifications(); + terminalWindows.unregisterTerminalWindowButton(old.id); + calls[0].popup.close(); + const current = register(); + await terminalWindows.adoptTerminalWindows(current.id, ["terminal"]); + poll(); + assert.deepEqual(notifications, [["OnTerminalWindowOpenedAsync", "terminal", "opened"]]); + assert.equal(poll, null); +}); + +test("adoption transfers callbacks from a stale connected launcher without waiting for its circuit", async () => { + const { promise, resolve } = Promise.withResolvers(); + const oldCalls = []; + const old = register("terminal", undefined, (...args) => { + oldCalls.push(args); + return promise; + }); + old.button.click(); + await flushNotifications(); + old.button.click(); + const current = register(); + + await terminalWindows.adoptTerminalWindows(current.id, ["terminal"]); + resolve(); + await flushNotifications(); + terminalWindows.unregisterTerminalWindowButton(old.id); + assert.equal(terminalWindows.isTerminalWindowOpen("terminal"), true); + assert.deepEqual(oldCalls, [["OnTerminalWindowOpenedAsync", "terminal", "opened"]]); + assert.deepEqual(notifications, [["OnTerminalWindowOpenedAsync", "terminal", "adopted"]]); + calls[0].popup.close(); + poll(); + await flushNotifications(); + assert.deepEqual(notifications[1], ["OnTerminalWindowClosedAsync", "terminal"]); +}); + +for (const end of ["return", "close", "dispose", "replace"]) { + test(`${end} while adoption is queued does not resurrect a detached pane`, async () => { + const { promise, resolve } = Promise.withResolvers(); + const old = register("old"); + old.button.click(); + await flushNotifications(); + terminalWindows.unregisterTerminalWindowButton(old.id); + const current = register("current", undefined, (...args) => { + notifications.push(args); + return promise; + }); + current.button.click(); + await flushNotifications(); + const adoption = terminalWindows.adoptTerminalWindows(current.id, ["old"]); + + if (end === "return") { + terminalWindows.closeTerminalWindow("old"); + } else if (end === "close") { + calls[0].popup.close(); + poll(); + } else if (end === "dispose") { + terminalWindows.unregisterTerminalWindowButton(current.id); + } else { + const replacement = register("old"); + await terminalWindows.adoptTerminalWindows(replacement.id, ["old"]); + } + resolve(); + await adoption; + await flushNotifications(); + assert.deepEqual(notifications.slice(2), end === "close" + ? [["OnTerminalWindowClosedAsync", "old"]] + : end === "replace" ? [["OnTerminalWindowOpenedAsync", "old", "adopted"]] : []); + assert.equal(calls.length, 2); + assert.equal(calls[0].popup.closed, end === "return" || end === "close"); + }); +} + +test("adoption completes only after reconciliation and reports a rejected acknowledgement", async () => { + const old = register(); + old.button.click(); + await flushNotifications(); + terminalWindows.unregisterTerminalWindowButton(old.id); + const { promise, reject } = Promise.withResolvers(); + const current = register("terminal", undefined, () => promise); + const warnings = []; + mock.method(console, "warn", (...args) => warnings.push(args)); + let completed = false; + const adoption = terminalWindows.adoptTerminalWindows(current.id, ["terminal"]); + const rejected = assert.rejects(adoption, /Circuit unavailable/); + adoption.then(() => { completed = true; }, () => {}); + await flushNotifications(); + assert.equal(completed, false); + reject(new Error("Circuit unavailable")); + await rejected; + assert.equal(completed, false); + assert.equal(warnings.length, 1); +}); + test("disposing an old owner cannot untrack a window adopted by a replacement button", async () => { const old = register(); old.button.click(); @@ -377,3 +519,336 @@ class TestButton extends EventTarget { closest() { return this.inertAncestor ? this : null; } click() { this.dispatchEvent(new Event("click")); } } + +class TestStorage { + values = new Map(); + getItem(key) { return this.values.get(key) ?? null; } + setItem(key, value) { this.values.set(key, String(value)); } + removeItem(key) { this.values.delete(key); } +} + +describe("cross-document terminal tracking", async () => { + // Separate module instances model actual document loss: no opener map or WindowProxy is shared across reload. + // These protocol tests exercise storage/message ordering; the real-browser check verifies browser semantics. + const documentModules = globalThis.__terminalWindowTestDocuments = new Map(); + const moduleSource = await readFile(new URL("../../../src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js", import.meta.url), "utf8"); + + function emit(target, type, properties) { + const event = new Event(type); + for (const [key, value] of Object.entries(properties)) { + Object.defineProperty(event, key, { value }); + } + target.dispatchEvent(event); + } + + function createBrowser() { + const windows = []; + const stores = new Map(); + const messages = []; + function createWindow(url = "https://localhost/dashboard/", name = "", opener) { + const browserWindow = { + location: new URL(url), name, closed: false, suspended: false, focusCalls: 0, + crypto: globalThis.crypto, sessionStorage: new TestStorage(), + events: new EventTarget(), timers: new Map(), openCalls: [], + focus() { this.focusCalls++; }, + close() { this.closed = true; }, + addEventListener(...args) { this.events.addEventListener(...args); }, + removeEventListener(...args) { this.events.removeEventListener(...args); }, + setInterval(callback) { const id = Symbol(); browserWindow.timers.set(id, callback); return id; }, + clearInterval(id) { browserWindow.timers.delete(id); }, + open(targetUrl, targetName, features) { + let popup = windows.find(item => item.name === targetName && !item.closed); + if (!popup) { + popup = createWindow(targetUrl, targetName, browserWindow); + } + popup.location = new URL(targetUrl); + this.openCalls.push({ popup, targetUrl, targetName, features }); + return popup; + }, + }; + browserWindow.sessionStorage.values = new Map(opener?.sessionStorage.values); + if (opener) { + browserWindow.opener = { + get closed() { return opener.closed; }, + postMessage(data, origin) { + messages.push({ data, source: browserWindow, origin: browserWindow.location.origin, target: opener }); + queueMicrotask(() => { + if (!opener.closed && !opener.suspended && opener.location.origin === origin) { + emit(opener.events, "message", { data, source: browserWindow, origin: browserWindow.location.origin }); + } + }); + }, + }; + } + const store = stores.get(browserWindow.location.origin) ?? new TestStorage(); + stores.set(browserWindow.location.origin, store); + const changeStorage = (key, value) => { + const oldValue = store.getItem(key); + if (value === null) { + store.removeItem(key); + } else { + store.setItem(key, value); + } + if (oldValue !== value) { + for (const other of windows.filter(item => item !== browserWindow && item.location.origin === browserWindow.location.origin)) { + queueMicrotask(() => { + if (!other.closed && !other.suspended) { + emit(other.events, "storage", { key, newValue: value, oldValue }); + } + }); + } + } + }; + browserWindow.localStorage = { + getItem: key => store.getItem(key), + setItem: (key, value) => changeStorage(key, String(value)), + removeItem: key => changeStorage(key, null), + }; + windows.push(browserWindow); + return browserWindow; + } + return { createWindow, windows, stores, messages }; + } + + async function loadDocument(browserWindow) { + browserWindow.events = new EventTarget(); + browserWindow.timers.clear(); + const document = Object.assign(new EventTarget(), { + elements: new Map(), + getElementById(id) { return this.elements.get(id); }, + }); + browserWindow.document = document; + const id = ++nextId; + documentModules.set(id, browserWindow); + const prelude = `const window = globalThis.__terminalWindowTestDocuments.get(${id}); + const document = window.document; + const setInterval = window.setInterval; + const clearInterval = window.clearInterval;\n`; + const module = await import(`data:text/javascript;base64,${Buffer.from(prelude + moduleSource + `\n//# sourceURL=terminal-window-document-${id}.mjs`).toString("base64")}`); + return { + module, window: browserWindow, notifications: [], + register(key = "terminal", baseUri = "https://localhost/dashboard/") { + const buttonId = `launcher-${id}-${document.elements.size}`; + const button = new TestButton(key, `${baseUri}terminal-window/apphost/${encodeURIComponent(key)}?fontSize=23`); + button.setAttribute("data-terminal-window-focus-group", buttonId); + document.elements.set(buttonId, button); + module.registerTerminalWindowButton(buttonId, buttonId, + { invokeMethodAsync: async (...args) => { this.notifications.push(args); } }, baseUri); + return { id: buttonId, button }; + }, + registerPopup(key = "terminal", baseUri = "https://localhost/dashboard/") { + return module.registerDetachedTerminalWindow(`popup-${id}`, key, baseUri, + { invokeMethodAsync: async (...args) => { this.notifications.push(args); } }); + }, + poll() { for (const callback of browserWindow.timers.values()) { callback(); } }, + }; + } + + async function openCoordinatedWindow(browser, baseUri = "https://localhost/dashboard/") { + const mainWindow = browser.createWindow(baseUri); + const main = await loadDocument(mainWindow); + const launcher = main.register("terminal", baseUri); + await main.module.adoptTerminalWindows(launcher.id, ["terminal"]); + launcher.button.click(); + const popup = await loadDocument(mainWindow.openCalls[0].popup); + assert.equal(popup.registerPopup("terminal", baseUri), true); + await flushNotifications(); + return { main, launcher, popup }; + } + + test("document reload recovers a live WindowProxy without opening, focusing, or navigating the popup", async () => { + const browser = createBrowser(); + const { main, popup } = await openCoordinatedWindow(browser); + const popupUrl = popup.window.location.href; + const recovered = await loadDocument(main.window); + const launcher = recovered.register(); + await recovered.module.adoptTerminalWindows(launcher.id, ["terminal"]); + await flushNotifications(); + + assert.equal(main.window.openCalls.length, 1); + assert.equal(popup.window.location.href, popupUrl); + assert.equal(popup.window.focusCalls, 0); + assert.ok(recovered.notifications.some(call => call[2] === "adopted")); + assert.equal(recovered.module.focusTerminalWindow("terminal"), true); + assert.equal(popup.window.focusCalls, 1); + popup.window.close(); + recovered.poll(); + await flushNotifications(); + assert.deepEqual(recovered.notifications.at(-1), ["OnTerminalWindowClosedAsync", "terminal"]); + assert.equal(recovered.module.isTerminalWindowOpen("terminal"), false); + }); + + test("a suspended or closed-before-recovery popup keeps a conservative placeholder until explicit return", async () => { + for (const closed of [false, true]) { + const browser = createBrowser(); + const { main, popup } = await openCoordinatedWindow(browser); + popup.window.suspended = true; + popup.window.closed = closed; + const recovered = await loadDocument(main.window); + const launcher = recovered.register(); + await recovered.module.adoptTerminalWindows(launcher.id, ["terminal"]); + for (let i = 0; i < 100; i++) { + recovered.poll(); + } + await flushNotifications(); + assert.deepEqual(recovered.notifications, [["OnTerminalWindowOpenedAsync", "terminal", "recovering"]]); + assert.equal(main.window.openCalls.length, 1); + assert.equal(recovered.module.isTerminalWindowOpen("terminal"), true); + recovered.module.closeTerminalWindow("terminal"); + assert.equal(recovered.module.isTerminalWindowOpen("terminal"), false); + popup.window.suspended = false; + const reloadedPopup = await loadDocument(popup.window); + assert.equal(reloadedPopup.registerPopup(), false); + assert.equal(popup.window.closed, true); + } + }); + + test("a detached document reload preserves its generation but a returned generation cannot mount again", async () => { + const browser = createBrowser(); + const { main, popup } = await openCoordinatedWindow(browser); + const reloadedPopup = await loadDocument(popup.window); + assert.equal(reloadedPopup.registerPopup(), true); + await flushNotifications(); + assert.equal(main.module.isTerminalWindowOpen("terminal"), true); + assert.equal(main.window.openCalls.length, 1); + main.module.closeTerminalWindow("terminal"); + const afterReturn = await loadDocument(popup.window); + assert.equal(afterReturn.registerPopup(), false); + }); + + test("return racing discovery rejects late ready messages and does not close a new generation", async () => { + const browser = createBrowser(); + const { main, popup } = await openCoordinatedWindow(browser); + const recovered = await loadDocument(main.window); + const launcher = recovered.register(); + const adoption = recovered.module.adoptTerminalWindows(launcher.id, ["terminal"]); + recovered.module.closeTerminalWindow("terminal"); + await adoption; + await flushNotifications(); + assert.equal(recovered.module.isTerminalWindowOpen("terminal"), false); + assert.equal(popup.window.closed, true); + launcher.button.click(); + const replacement = main.window.openCalls.at(-1).popup; + assert.notEqual(replacement, popup.window); + const lateMessage = browser.messages[0]; + emit(main.window.events, "message", lateMessage); + await flushNotifications(); + assert.equal(replacement.closed, false); + assert.equal(recovered.module.isTerminalWindowOpen("terminal"), true); + }); + + test("native focus recovers an unresolved named target and retains its font while passive recovery never opens it", async () => { + const browser = createBrowser(); + const { main, popup } = await openCoordinatedWindow(browser); + popup.window.suspended = true; + const recovered = await loadDocument(main.window); + const launcher = recovered.register(); + await recovered.module.adoptTerminalWindows(launcher.id, ["terminal"]); + await flushNotifications(); + assert.equal(main.window.openCalls.length, 1); + const focus = new TestButton("terminal", ""); + focus.setAttribute("data-terminal-window-focus-key", "terminal"); + focus.setAttribute("data-terminal-window-focus-group", launcher.id); + focus.closest = selector => selector === "[data-terminal-window-focus-key]" ? focus : null; + emit(main.window.document, "click", { target: focus }); + assert.equal(main.window.openCalls.length, 2); + assert.equal(main.window.openCalls[1].popup, popup.window); + assert.equal(new URL(main.window.openCalls[1].targetUrl).searchParams.get("fontSize"), "23"); + }); + + test("dashboard instances, origins, PathBase, and terminal identities do not adopt each other's records", async () => { + const browser = createBrowser(); + const { main } = await openCoordinatedWindow(browser); + for (const baseUri of ["https://localhost/dashboard/", "https://localhost/other/", "https://other.example/dashboard/"]) { + const other = await loadDocument(browser.createWindow(baseUri, "", main.window)); + const launcher = other.register("terminal", baseUri); + await other.module.adoptTerminalWindows(launcher.id, ["terminal", "another-apphost-terminal"]); + assert.deepEqual(other.notifications, []); + assert.equal(other.module.isTerminalWindowOpen("terminal"), false); + } + const recovered = await loadDocument(main.window); + const launcher = recovered.register("new-apphost-terminal"); + await recovered.module.adoptTerminalWindows(launcher.id, ["new-apphost-terminal"]); + assert.deepEqual(recovered.notifications, []); + }); + + test("spoofed origins and stale document or window generations cannot supply a recovered handle", async () => { + const browser = createBrowser(); + const { main, popup } = await openCoordinatedWindow(browser); + popup.window.suspended = true; + const recovered = await loadDocument(main.window); + const launcher = recovered.register(); + await recovered.module.adoptTerminalWindows(launcher.id, ["terminal"]); + const old = browser.messages[0]; + for (const patch of [ + { origin: "https://evil.example" }, + { data: { ...old.data, baseUri: "https://localhost/other/" } }, + { data: { ...old.data, generation: "previous-window" } }, + {}, + ]) { + emit(main.window.events, "message", { ...old, ...patch }); + } + await flushNotifications(); + assert.deepEqual(recovered.notifications, [["OnTerminalWindowOpenedAsync", "terminal", "recovering"]]); + assert.equal(popup.window.focusCalls, 0); + }); + + test("corrupt or unavailable durable storage rejects recovery rather than reporting no detached windows", async () => { + for (const corrupt of [false, true]) { + const browser = createBrowser(); + const { main } = await openCoordinatedWindow(browser); + const recovered = await loadDocument(main.window); + const launcher = recovered.register(); + const store = browser.stores.get(main.window.location.origin); + if (corrupt) { + const recordKey = [...store.values.keys()].find(key => key.startsWith("aspire-terminal-window:")); + store.setItem(recordKey, "{invalid-json"); + } else { + main.window.localStorage.getItem = () => { throw new Error("Storage denied"); }; + } + assert.throws(() => recovered.module.adoptTerminalWindows(launcher.id, ["terminal"])); + assert.deepEqual(recovered.notifications, []); + } + }); + + test("a blocked or failed native popup does not leave a phantom durable detachment after reload", async () => { + mock.method(console, "error", () => {}); + for (const throws of [false, true]) { + const browser = createBrowser(); + const main = await loadDocument(browser.createWindow()); + const launcher = main.register(); + await main.module.adoptTerminalWindows(launcher.id, ["terminal"]); + main.window.open = () => { + if (throws) { + throw new Error("Browser unavailable"); + } + return null; + }; + launcher.button.click(); + await flushNotifications(); + assert.deepEqual(main.notifications, [["OnTerminalWindowOpenedAsync", "terminal", throws ? "failed" : "blocked"]]); + assert.equal(main.module.isTerminalWindowOpen("terminal"), false); + const recovered = await loadDocument(main.window); + const replacement = recovered.register(); + await recovered.module.adoptTerminalWindows(replacement.id, ["terminal"]); + assert.deepEqual(recovered.notifications, []); + } + }); + + test("empty coordinated-window identities cannot bypass generation validation", async () => { + const browser = createBrowser(); + const popup = await loadDocument(browser.createWindow( + "https://localhost/dashboard/terminal-window/apphost/terminal?windowOwner=&windowGeneration=")); + assert.throws(() => popup.registerPopup(), /Incomplete detached terminal window identity/); + }); + + test("terminal disappearance releases the record without disposing a producer or closing an independent page", async () => { + const browser = createBrowser(); + const { main, popup } = await openCoordinatedWindow(browser); + popup.module.releaseDetachedTerminalWindow(`popup-${nextId}`); + await flushNotifications(); + assert.equal(popup.window.closed, false); + assert.equal(main.module.isTerminalWindowOpen("terminal"), false); + assert.deepEqual(main.notifications.at(-1), ["OnTerminalWindowClosedAsync", "terminal"]); + }); +}); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index 802309fe42f..39485ccae51 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -350,7 +350,11 @@ public async Task SelectTab_UpdatesAccessibleSelectionWithoutRemountingPanes() var cut = RenderComponent(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second", "third")); - cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("[role=tab]").Count)); + cut.WaitForAssertion(() => + { + Assert.Equal(3, cut.FindAll("[role=tab]").Count); + Assert.Equal(3, cut.FindComponents().Count); + }); var terminals = cut.FindComponents().Select(view => view.Instance).ToArray(); Assert.Equal("Terminals", cut.Find("[role=tablist]").GetAttribute("aria-label")); @@ -402,9 +406,11 @@ public async Task CloseActiveTab_WaitsForWatchRemovalAndSelectsAdjacentTab(int s string[] ids = ["first", "second", "third"]; await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot(ids)); cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("[role=tab]").Count)); - await cut.FindAll("[role=tab]")[selected].ClickAsync(new()); + // Window adoption can still rerender the dock after its tabs appear. Find and dispatch together so + // the click never captures an event handler from the preceding render. + await cut.InvokeAsync(() => cut.FindAll("[role=tab]")[selected].ClickAsync(new())); - var close = cut.FindAll(".terminal-dock-tab-close")[selected].ClickAsync(new()); + var close = cut.InvokeAsync(() => cut.FindAll(".terminal-dock-tab-close")[selected].ClickAsync(new())); cut.WaitForAssertion(() => Assert.Equal([ids[selected]], client.ClosedTerminals.ToArray())); Assert.Equal(3, cut.FindAll("[role=tab]").Count); Assert.Equal(ids[selected], cut.Find("[role=tab][aria-selected=true]").TextContent.Trim()); @@ -531,7 +537,11 @@ public async Task HideDock_IsInertWithoutClosingOrRemountingTerminals(bool hideW var cut = RenderComponent(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); - cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".terminal-dock-tab").Count)); + cut.WaitForAssertion(() => + { + Assert.Equal(2, cut.FindAll(".terminal-dock-tab").Count); + Assert.Equal(2, cut.FindComponents().Count); + }); var terminals = cut.FindComponents().Select(view => view.Instance).ToArray(); var height = cut.Find(".terminal-dock").GetAttribute("style"); Assert.False(cut.Find(".terminal-dock").HasAttribute("inert")); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs new file mode 100644 index 00000000000..83c11e044e6 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs @@ -0,0 +1,215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Layout; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.DashboardService.Proto.V1; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Layout; + +public partial class TerminalDockTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReloadRecovery_KeepsPlaceholderUntilExplicitReturnOrConfirmedClosure(bool storageFailure) + { + var updates = Channel.CreateUnbounded(); + var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + var adoption = module.SetupVoid("adoptTerminalWindows", _ => true); + var cut = RenderComponent(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); + var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); + if (storageFailure) + { + adoption.SetException(new JSException("Storage denied")); + } + else + { + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("terminal", "recovering")); + adoption.SetVoidResult(); + } + cut.WaitForAssertion(() => + { + Assert.Single(cut.FindAll(".terminal-dock-detached")); + Assert.Equal(Resources.TerminalStrings.TerminalDockRecoveringWindow, + cut.Find(".terminal-dock-detached > span").TextContent); + Assert.Empty(cut.FindComponents()); + }); + cut.Render(); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await cut.InvokeAsync(cut.Instance.ToggleAsync); + Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal")); + var focus = cut.Find("[data-terminal-window-focus-key]"); + Assert.Equal("terminal", focus.GetAttribute("data-terminal-window-focus-key")); + Assert.Equal(cut.Find(".terminal-dock-detach").GetAttribute("data-terminal-window-focus-group"), + focus.GetAttribute("data-terminal-window-focus-group")); + + await cut.InvokeAsync(() => cut.FindAll(".terminal-dock-detached-actions .aspire-button")[1].ClickAsync(new())); + cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); + Assert.Empty(client.ClosedTerminals); + } + + [Theory] + [InlineData("")] + [InlineData("/aspire/nested")] + public async Task ReplacementDock_AdoptsWindowsBeforeMountingAnyViewers(string pathBase) + { + var updates = Channel.CreateUnbounded(); + var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); + Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); + TerminalSetupHelpers.SetupTerminalComponents(this, client, pathBase); + var old = RenderComponent(); + await old.InvokeAsync(old.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("active", "inactive")); + old.WaitForAssertion(() => + { + Assert.Equal(2, old.FindComponents().Count); + Assert.Equal(2, JSInterop.Invocations.Count(i => i.Identifier == "initTerminal")); + }); + var oldLauncher = TerminalSetupHelpers.GetWindowLauncher(this, old); + await old.InvokeAsync(() => oldLauncher.OnTerminalWindowOpenedAsync("active", "opened")); + await old.InvokeAsync(() => oldLauncher.OnTerminalWindowOpenedAsync("inactive", "opened")); + await old.InvokeAsync(() => old.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + Assert.Empty(client.ClosedTerminals); + Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "closeTerminalWindow")); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var module = new TestJSObjectReference + { + BeforeInvokeAsync = identifier => + { + if (identifier == "adoptTerminalWindows") + { + started.TrySetResult(); + return release.Task; + } + return Task.CompletedTask; + } + }; + TestJSObjectReference.SetupImport(this, $"{pathBase}/js/app-terminalwindow.js").SetResult(module); + var cut = RenderComponent(); + TerminalWindowLauncher launcher; + try + { + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("active", "inactive")); + await started.Task.DefaultTimeout(); + Assert.Equal(2, cut.FindAll("[role=tab]").Count); + Assert.Empty(cut.FindComponents()); + Assert.Equal(2, JSInterop.Invocations.Count(i => i.Identifier == "initTerminal")); + var registration = Assert.Single(module.Invocations, i => i.Identifier == "registerTerminalWindowButton"); + launcher = Assert.IsType>(registration.Arguments[2]).Value; + var adoption = Assert.Single(module.Invocations, i => i.Identifier == "adoptTerminalWindows"); + Assert.Equal(registration.Arguments[1], adoption.Arguments[0]); + Assert.Equal(["active", "inactive"], Assert.IsType(adoption.Arguments[1])); + + // bUnit drives the JS acknowledgements, not real popup handles. The Node suite verifies retention. + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("active", "adopted")); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("inactive", "adopted")); + Assert.Equal(2, cut.FindAll(".terminal-dock-detached").Count); + Assert.Empty(cut.FindComponents()); + } + finally + { + release.TrySetResult(); + } + + await cut.InvokeAsync(() => launcher.OnTerminalWindowClosedAsync("active")); + cut.WaitForAssertion(() => + { + var view = Assert.Single(cut.FindComponents()).Instance; + Assert.Equal("dock:active", view.SizeMemoryKey); + Assert.True(view.AutoFit); + Assert.Single(cut.FindAll(".terminal-dock-detached")); + }); + await cut.FindAll(".terminal-dock-detached-actions .aspire-button")[1].ClickAsync(new()); + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll(".terminal-dock-detached")); + Assert.Equal([true, false], cut.FindComponents().Select(c => c.Instance.AutoFit)); + }); + var close = Assert.Single(module.Invocations, i => i.Identifier == "closeTerminalWindow"); + Assert.Equal("inactive", close.Arguments[0]); + Assert.Empty(client.ClosedTerminals); + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + Assert.Single(module.Invocations, i => i.Identifier == "unregisterTerminalWindowButton"); + Assert.Equal(1, module.DisposeCount); + } + + [Fact] + public async Task Adoption_ChangedSnapshotChecksNewIdentitiesAndDoesNotMountRemovedTerminal() + { + var updates = Channel.CreateUnbounded(); + var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); + TerminalSetupHelpers.SetupTerminalComponents(this, client); + var started = Channel.CreateUnbounded(); + var firstRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var adoptionCount = 0; + var module = new TestJSObjectReference + { + BeforeInvokeAsync = identifier => + { + if (identifier == "adoptTerminalWindows") + { + started.Writer.TryWrite(true); + return ++adoptionCount == 1 ? firstRelease.Task : secondRelease.Task; + } + return Task.CompletedTask; + } + }; + TestJSObjectReference.SetupImport(this, "/js/app-terminalwindow.js").SetResult(module); + var cut = RenderComponent(); + try + { + await cut.InvokeAsync(cut.Instance.ToggleAsync); + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("old-identity")); + await started.Reader.ReadAsync().AsTask().DefaultTimeout(); + var registration = Assert.Single(module.Invocations, i => i.Identifier == "registerTerminalWindowButton"); + var launcher = Assert.IsType>(registration.Arguments[2]).Value; + await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("new-identity")); + cut.WaitForAssertion(() => Assert.Equal("new-identity", cut.Find("[role=tab]").TextContent.Trim())); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("old-identity", "adopted")); + var close = Assert.Single(module.Invocations, i => i.Identifier == "closeTerminalWindow"); + Assert.Equal("old-identity", close.Arguments[0]); + Assert.Empty(cut.FindComponents()); + Assert.Empty(cut.FindAll(".terminal-dock-detached")); + + firstRelease.SetResult(); + await started.Reader.ReadAsync().AsTask().DefaultTimeout(); + var adoptions = module.Invocations.Where(i => i.Identifier == "adoptTerminalWindows").ToArray(); + Assert.Equal(2, adoptions.Length); + Assert.Equal(["new-identity"], Assert.IsType(adoptions[1].Arguments[1])); + Assert.Empty(cut.FindComponents()); + await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("new-identity", "adopted")); + } + finally + { + firstRelease.TrySetResult(); + secondRelease.TrySetResult(); + } + + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindComponents()); + Assert.Single(cut.FindAll(".terminal-dock-detached")); + }); + Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal")); + Assert.Empty(client.ClosedTerminals); + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs index 3268935560b..050cb2c098d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs @@ -12,12 +12,79 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; using Xunit; namespace Aspire.Dashboard.Components.Tests.Pages; public class TerminalWindowTests : DashboardTestContext { + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CoordinatedWindow_ChecksGenerationBeforeMountingAndDoesNotRevokeOnDisposal(bool stillDetached) + { + TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient()); + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + var registration = module.Setup("registerDetachedTerminalWindow", _ => true); + Services.GetRequiredService().NavigateTo( + "terminal-window/apphost/terminal?fontSize=23&windowOwner=owner&windowGeneration=generation"); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + Assert.Empty(cut.FindComponents()); + Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal")); + Assert.Single(registration.Invocations); + + registration.SetResult(stillDetached); + cut.WaitForAssertion(() => + { + if (stillDetached) + { + Assert.Equal(23, cut.FindComponent().Instance.InitialFontSize); + } + else + { + Assert.Empty(cut.FindComponents()); + Assert.Single(cut.FindAll(".terminal-window-ended")); + } + }); + await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); + Assert.Single(module.Invocations, i => i.Identifier == + (stillDetached ? "unregisterDetachedTerminalWindow" : "releaseDetachedTerminalWindow")); + Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "closeTerminalWindow")); + } + + [Fact] + public async Task CoordinatedWindow_RevocationRejectsStaleRegistrationAndRemovesCurrentViewer() + { + TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient()); + Services.GetRequiredService().NavigateTo( + "terminal-window/apphost/terminal?windowOwner=owner&windowGeneration=generation"); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); + var registration = Assert.Single(JSInterop.Invocations, i => i.Identifier == "registerDetachedTerminalWindow"); + var id = Assert.IsType(registration.Arguments[0]); + await cut.InvokeAsync(() => cut.Instance.OnDetachedTerminalWindowRevokedAsync("obsolete-registration")); + Assert.Single(cut.FindComponents()); + await cut.InvokeAsync(() => cut.Instance.OnDetachedTerminalWindowRevokedAsync(id)); + cut.WaitForAssertion(() => Assert.Empty(cut.FindComponents())); + Assert.Single(JSInterop.Invocations, i => i.Identifier == "releaseDetachedTerminalWindow"); + } + + [Fact] + public void CoordinatedWindow_StorageFailureDoesNotMountViewer() + { + TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient()); + var module = TerminalSetupHelpers.SetupTerminalWindows(this); + module.Setup("registerDetachedTerminalWindow", _ => true).SetException(new JSException("Storage denied")); + Services.GetRequiredService().NavigateTo( + "terminal-window/apphost/terminal?windowOwner=owner&windowGeneration=generation"); + var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + cut.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalWindowTrackingFailed, + cut.Find(".terminal-window-ended").TextContent)); + Assert.Empty(cut.FindComponents()); + Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal")); + } + [Theory] [InlineData("", "terminal", "terminal")] [InlineData("/aspire/nested", "terminal", "terminal")] diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index ae8390116bf..89325c72156 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -97,9 +97,13 @@ public static BunitJSModuleInterop SetupTerminalWindows(TestContext context, str { var windows = context.JSInterop.SetupModule($"{pathBase}/js/app-terminalwindow.js"); windows.SetupVoid("registerTerminalWindowButton", _ => true).SetVoidResult(); + windows.SetupVoid("adoptTerminalWindows", _ => true).SetVoidResult(); windows.Setup("focusTerminalWindow", _ => true).SetResult(true); windows.SetupVoid("closeTerminalWindow", _ => true).SetVoidResult(); windows.SetupVoid("unregisterTerminalWindowButton", _ => true).SetVoidResult(); + windows.Setup("registerDetachedTerminalWindow", _ => true).SetResult(true); + windows.SetupVoid("unregisterDetachedTerminalWindow", _ => true).SetVoidResult(); + windows.SetupVoid("releaseDetachedTerminalWindow", _ => true).SetVoidResult(); return windows; } From 7956cdd1a3205fecf299bb2a9b7667329f24bec2 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 18 Sep 2026 12:16:29 +1000 Subject: [PATCH 100/106] Align terminal handshake tests with completion close status Cover permanent NotFound/FailedPrecondition failures with WebSocket close 4000, retain HTTP 503 for transient statuses, and assert session state and gRPC cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Model/DashboardClientTests.cs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 993ff7595fa..609717ac5c4 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -166,9 +166,11 @@ public async Task TerminalStream_RpcReadFailurePreservesStatusAsStreamError(Stat } [Theory] - [InlineData(StatusCode.NotFound, StatusCodes.Status404NotFound)] - [InlineData(StatusCode.Unavailable, StatusCodes.Status503ServiceUnavailable)] - public async Task TerminalStream_HandshakeFailureReturnsHttpErrorBeforeUpgrade(StatusCode statusCode, int expectedStatus) + [InlineData(StatusCode.NotFound, true)] + [InlineData(StatusCode.FailedPrecondition, true)] + [InlineData(StatusCode.Unavailable, false)] + [InlineData(StatusCode.DeadlineExceeded, false)] + public async Task TerminalStream_HandshakeFailurePreservesPermanentAndTransientStatus(StatusCode statusCode, bool permanentFailure) { var channel = Channel.CreateUnbounded(); channel.Writer.TryComplete(new RpcException(new Status(statusCode, "Terminal is unavailable."))); @@ -183,6 +185,7 @@ public async Task TerminalStream_HandshakeFailureReturnsHttpErrorBeforeUpgrade(S using var stream = new GrpcTerminalClientStream(call, "terminal"); var dashboardClient = new TestDashboardClient(attachTerminal: (_, _) => Task.FromResult(stream)); var sessions = new TerminalViewSessionRegistry(); + using var session = sessions.Create("/api/apphost-terminal?terminalId=terminal", readOnly: false); using var server = new TestServer(new WebHostBuilder().Configure(app => { app.UseWebSockets(); @@ -195,12 +198,31 @@ public async Task TerminalStream_HandshakeFailureReturnsHttpErrorBeforeUpgrade(S })); var client = server.CreateWebSocketClient(); client.ConfigureRequest = request => request.Headers.Origin = "https://dashboard.example.com"; + var uri = new Uri($"wss://dashboard.example.com/api/apphost-terminal?terminalId=terminal&viewId={session.Id}"); - var exception = await Assert.ThrowsAsync(() => client.ConnectAsync( - new Uri("wss://dashboard.example.com/api/apphost-terminal?terminalId=terminal"), CancellationToken.None).DefaultTimeout()); + if (permanentFailure) + { + using var socket = await client.ConnectAsync(uri, CancellationToken.None).DefaultTimeout(); + var message = await socket.ReceiveAsync(new ArraySegment(new byte[64]), CancellationToken.None).DefaultTimeout(); + + Assert.Equal(WebSocketMessageType.Close, message.MessageType); + Assert.Equal((WebSocketCloseStatus)4000, message.CloseStatus); + Assert.Equal("Terminal ended", message.CloseStatusDescription); + await session.Ended.DefaultTimeout(); + Assert.True(session.ReadOnly); + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Received", CancellationToken.None).DefaultTimeout(); + } + else + { + var exception = await Assert.ThrowsAsync(() => client.ConnectAsync(uri, CancellationToken.None).DefaultTimeout()); + + Assert.Contains(StatusCodes.Status503ServiceUnavailable.ToString(System.Globalization.CultureInfo.InvariantCulture), exception.Message); + Assert.False(session.Ended.IsCompleted); + Assert.False(session.ReadOnly); + } - Assert.Contains(expectedStatus.ToString(System.Globalization.CultureInfo.InvariantCulture), exception.Message); await disposed.Task.DefaultTimeout(); + // The proxy classifies the RPC status; the stream never received an Ended frame. Assert.False(stream.TerminalEnded); } From d57892acbbb8d78ec5e2b26186672a3eb7b68df7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 18 Sep 2026 13:01:35 +1000 Subject: [PATCH 101/106] Preserve terminal input cancellation and validate bundle RIDs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 5 + .../Terminals/AspireTerminal.cs | 2 + .../Terminals/TerminalAutomation.cs | 19 +++- .../Terminals/Hex1bAspireTerminalTests.cs | 103 ++++++++++++++++++ .../Utils/GatedTerminalWorkloadAdapter.cs | 3 +- .../Hex1bNativePublishingTests.cs | 44 +++++++- tools/CreateLayout/Program.cs | 1 + tools/CreateLayout/README.md | 4 +- 8 files changed, 176 insertions(+), 5 deletions(-) diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index dd8edf9593a..18423070b01 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -115,6 +115,11 @@ Windows workloads receive input through ConPTY and may interpret it differently. Numeric-keypad keys, extended modifiers, key-down/up events, and Kitty keyboard protocol are not part of this API. Typing text is not bracketed paste. +Key and text input report caller cancellation as `OperationCanceledException` +with the caller's token, including when Hex1b wraps a canceled input step. +Canceling an input operation does not dispose the terminal; later input remains +usable. Unrelated automation failures are not converted to cancellation. + Public C# lookup currently requires a terminal ID. A resource-name/replica lookup API that avoids constructing internal IDs is deferred to [#20219](https://github.com/microsoft/aspire/issues/20219). diff --git a/src/Aspire.Hosting/Terminals/AspireTerminal.cs b/src/Aspire.Hosting/Terminals/AspireTerminal.cs index ce795ebea0d..ab661e70768 100644 --- a/src/Aspire.Hosting/Terminals/AspireTerminal.cs +++ b/src/Aspire.Hosting/Terminals/AspireTerminal.cs @@ -93,6 +93,7 @@ internal AspireTerminal(ITerminalBackend backend) /// A task representing the input operation. /// is . /// The AppHost-owned terminal has already stopped. + /// The input operation was canceled by . public Task SendTextAsync(string text, CancellationToken cancellationToken = default) => Backend.SendTextAsync(text, cancellationToken); @@ -112,6 +113,7 @@ public Task SendTextAsync(string text, CancellationToken cancellationToken = def /// A task representing the input operation. /// is an uninitialized value. /// The AppHost-owned terminal has already stopped. + /// The input operation was canceled by . public Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default) { // Reject invalid keys before the backend can start a workload or connect to a resource terminal. diff --git a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs index c3508a482dc..febd9bc6c26 100644 --- a/src/Aspire.Hosting/Terminals/TerminalAutomation.cs +++ b/src/Aspire.Hosting/Terminals/TerminalAutomation.cs @@ -26,7 +26,7 @@ internal static class TerminalAutomation public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); public static Task SendTextAsync(Hex1bTerminalAutomator automator, string text, CancellationToken cancellationToken) - => automator.TypeAsync(text, cancellationToken); + => ObserveInputAsync(automator.TypeAsync(text, cancellationToken), cancellationToken); public static Task SendKeyAsync( Hex1bTerminal terminal, @@ -45,7 +45,22 @@ public static Task SendKeyAsync( return terminal.SendInputAsync([0x1b, letter], cancellationToken); } - return automator.KeyAsync(key.Key, key.Modifiers, cancellationToken); + return ObserveInputAsync(automator.KeyAsync(key.Key, key.Modifiers, cancellationToken), cancellationToken); + } + + private static async Task ObserveInputAsync(Task operation, CancellationToken cancellationToken) + { + try + { + await operation.ConfigureAwait(false); + } + catch (Hex1bAutomationException ex) when (cancellationToken.IsCancellationRequested && + ex.InnerException is OperationCanceledException canceled && canceled.CancellationToken == cancellationToken) + { + // Hex1b wraps canceled input steps as automation failures. Preserve normal cancellation semantics, + // but do not hide unrelated failures just because the caller canceled at the same time. + throw new OperationCanceledException("Terminal input was canceled.", ex, cancellationToken); + } } public static async Task WaitForTextAsync( diff --git a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs index 8ae457e09cb..0a5ab80cb50 100644 --- a/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Terminals/Hex1bAspireTerminalTests.cs @@ -9,6 +9,7 @@ using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Hex1b; +using Hex1b.Automation; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; @@ -20,6 +21,108 @@ namespace Aspire.Hosting.Tests.Terminals; [Trait("Partition", "2")] public class Hex1bAspireTerminalTests { + [Theory] + [InlineData("key", true)] + [InlineData("key", false)] + [InlineData("modified-key", true)] + [InlineData("modified-key", false)] + [InlineData("alt-letter", true)] + [InlineData("alt-letter", false)] + [InlineData("text", true)] + [InlineData("text", false)] + public async Task SendInput_CallerCancellationPreservesTokenAndTerminal(string inputKind, bool preCanceled) + { + await using var service = TestTerminalService.Create(); + var output = new Pipe(); + var input = new Pipe(); + await using var outputReader = output.Reader.AsStream(); + await using var outputWriter = output.Writer.AsStream(); + await using var inputReader = input.Reader.AsStream(); + await using var inputWriter = input.Writer.AsStream(); + using var gated = new GatedTerminalWriteStream(inputWriter); + await using var terminal = service.CreateTerminal("Cancellation", TerminalPlacement.None, + Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, gated)), 80, 24); + using var cts = new CancellationTokenSource(); + if (preCanceled) + { + cts.Cancel(); + } + + var blockingInput = Task.CompletedTask; + try + { + if (!preCanceled) + { + // Hold Hex1b's input lock so cancellation happens while the next operation waits to write, + // not inside StreamWorkloadAdapter, which suppresses cancellation from its own stream. + blockingInput = terminal.SendKeyAsync(AspireTerminalKey.Alt(AspireTerminalKey.E)); + await gated.WriteStarted.DefaultTimeout(); + } + + var operation = inputKind switch + { + "key" => terminal.SendKeyAsync(AspireTerminalKey.Enter, cts.Token), + "modified-key" => terminal.SendKeyAsync(AspireTerminalKey.Ctrl(AspireTerminalKey.R), cts.Token), + "alt-letter" => terminal.SendKeyAsync(AspireTerminalKey.Alt(AspireTerminalKey.E), cts.Token), + "text" => terminal.SendTextAsync("canceled", cts.Token), + _ => throw new InvalidOperationException($"Unknown input kind '{inputKind}'.") + }; + if (!preCanceled) + { + Assert.False(operation.IsCompleted); + await cts.CancelAsync(); + } + + var exception = await Assert.ThrowsAnyAsync(() => operation).DefaultTimeout(); + Assert.Equal(cts.Token, exception.CancellationToken); + Assert.True(operation.IsCanceled); + } + finally + { + gated.ReleaseWrite(); + await blockingInput.DefaultTimeout(); + } + + await terminal.SendTextAsync("after").DefaultTimeout(); + var expected = Encoding.UTF8.GetBytes(preCanceled ? "after" : "\x1b" + "eafter"); + var bytes = new byte[expected.Length]; + await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout(); + Assert.Equal(expected, bytes); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task SendKey_UnrelatedFailuresAreNotConvertedToCallerCancellation(bool cancelCaller, bool unrelatedCancellation) + { + await using var service = TestTerminalService.Create(); + using var cts = new CancellationTokenSource(); + Exception expected = unrelatedCancellation + ? new OperationCanceledException(new CancellationToken(canceled: true)) + : new IOException("Input failed."); + var workload = new GatedTerminalWorkloadAdapter + { + OnWriteInput = (_, _) => + { + if (cancelCaller) + { + cts.Cancel(); + } + return ValueTask.FromException(expected); + } + }; + workload.ReleaseDispose(); + await using var terminal = service.CreateTerminal("Failed input", TerminalPlacement.None, + Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24); + + var exception = await Assert.ThrowsAsync( + () => terminal.SendKeyAsync(AspireTerminalKey.Enter, cts.Token)).DefaultTimeout(); + + Assert.Same(expected, exception.InnerException); + } + [Theory] [InlineData(80, 24)] [InlineData(82, 28)] diff --git a/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWorkloadAdapter.cs b/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWorkloadAdapter.cs index 8846eed4b0f..c0cc7f13182 100644 --- a/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWorkloadAdapter.cs +++ b/tests/Aspire.Hosting.Tests/Utils/GatedTerminalWorkloadAdapter.cs @@ -15,6 +15,7 @@ internal sealed class GatedTerminalWorkloadAdapter : IHex1bTerminalWorkloadAdapt public Task DisposeStarted => _disposeStarted.Task; public bool IsDisposed { get; private set; } public Exception? DisposalException { get; init; } + public Func, CancellationToken, ValueTask>? OnWriteInput { get; init; } public event Action? Disconnected; public void ReleaseDispose() => _releaseDispose.TrySetResult(); @@ -27,7 +28,7 @@ public async ValueTask> ReadOutputAsync(CancellationToken c } public ValueTask WriteInputAsync(ReadOnlyMemory data, CancellationToken ct = default) - => ValueTask.CompletedTask; + => OnWriteInput?.Invoke(data, ct) ?? ValueTask.CompletedTask; public ValueTask ResizeAsync(int width, int height, CancellationToken ct = default) => ValueTask.CompletedTask; diff --git a/tests/Infrastructure.Tests/Hex1bNativePublishingTests.cs b/tests/Infrastructure.Tests/Hex1bNativePublishingTests.cs index 7148d8c28e9..18d84c4abd8 100644 --- a/tests/Infrastructure.Tests/Hex1bNativePublishingTests.cs +++ b/tests/Infrastructure.Tests/Hex1bNativePublishingTests.cs @@ -72,6 +72,10 @@ public async Task UnrelatedPublishCollisionsStillFail() [InlineData("win-x64", null)] [InlineData("win-arm64", null)] [InlineData("linux-x64", null)] + [InlineData("linux-arm64", null)] + [InlineData("linux-musl-x64", null)] + [InlineData("osx-x64", null)] + [InlineData("osx-arm64", null)] [InlineData("win-x64", "arm64/OpenConsole.exe")] [InlineData("win-arm64", "hex1bpty.exe")] public async Task BundlePreservesPtyLayoutAndRejectsMissingSidecars(string rid, string? missingSidecar) @@ -97,7 +101,12 @@ public async Task BundlePreservesPtyLayoutAndRejectsMissingSidecars(string rid, { "win-x64" => "windows-amd64", "win-arm64" => "windows-arm64", - _ => "linux-amd64" + "linux-x64" => "linux-amd64", + "linux-arm64" => "linux-arm64", + "linux-musl-x64" => "linux-musl-amd64", + "osx-x64" => "darwin-amd64", + "osx-arm64" => "darwin-arm64", + _ => throw new InvalidOperationException($"Unknown runtime identifier '{rid}'.") }; var packages = Path.Combine(_workspace.Path, "packages"); WriteFile(Path.Combine(packages, $"microsoft.developercontrolplane.{packageRid}", "1.0.0", "tools", "dcp"), "dcp"); @@ -127,6 +136,39 @@ public async Task BundlePreservesPtyLayoutAndRejectsMissingSidecars(string rid, } } + [Theory] + [InlineData("win-x86", false)] + [InlineData("win-x86", true)] + [InlineData("win-unknown", true)] + [InlineData("linux-x86", true)] + public async Task BundleRejectsUnsupportedRidBeforeChangingOutput(string rid, bool existingOutput) + { + var layout = Path.Combine(_workspace.Path, "layout"); + var marker = Path.Combine(layout, "existing.txt"); + if (existingOutput) + { + WriteFile(marker, "Keep the existing layout."); + } + + var testAssembly = typeof(Hex1bNativePublishingTests).Assembly.Location; + var result = await RunDotNetAsync( + ["exec", "--runtimeconfig", Path.ChangeExtension(testAssembly, ".runtimeconfig.json"), + "--depsfile", Path.ChangeExtension(testAssembly, ".deps.json"), + typeof(Aspire.Tools.CreateLayout.Program).Assembly.Location, + "--output", layout, "--artifacts", Path.Combine(_workspace.Path, "missing-artifacts"), "--rid", rid]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Equal(existingOutput, Directory.Exists(layout)); + if (existingOutput) + { + Assert.Equal([marker], Directory.GetFiles(layout, "*", SearchOption.AllDirectories)); + Assert.Equal("Keep the existing layout.", File.ReadAllText(marker)); + } + Assert.Contains($"'{rid}'", result.Output); + Assert.Contains("win-x64", result.Output); + Assert.Contains("win-arm64", result.Output); + } + [Fact] public void SharedTargetsAreImportedAndShippedForHostingConsumers() { diff --git a/tools/CreateLayout/Program.cs b/tools/CreateLayout/Program.cs index 9a8ed3fbeeb..cde684ebcd8 100644 --- a/tools/CreateLayout/Program.cs +++ b/tools/CreateLayout/Program.cs @@ -37,6 +37,7 @@ public static async Task Main(string[] args) Description = "Runtime identifier", Required = true }; + ridOption.AcceptOnlyFromAmong("win-x64", "win-arm64", "linux-x64", "linux-arm64", "linux-musl-x64", "osx-x64", "osx-arm64"); var bundleVersionOption = new Option("--bundle-version") { diff --git a/tools/CreateLayout/README.md b/tools/CreateLayout/README.md index 012061fe33c..ded84635bbc 100644 --- a/tools/CreateLayout/README.md +++ b/tools/CreateLayout/README.md @@ -36,13 +36,15 @@ dotnet run --project tools/CreateLayout/CreateLayout.csproj -- [options] |--------|-------------| | `-o, --output ` | Output directory for the layout | | `-a, --artifacts ` | Path to build artifacts directory | +| `--rid ` | Target runtime identifier: `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `linux-musl-x64`, `osx-x64`, or `osx-arm64` | + +Unsupported runtime identifiers, including `win-x86`, are rejected before the output directory is changed. ### Optional Options | Option | Description | |--------|-------------| | `-r, --runtime ` | Path to existing .NET runtime to include | -| `--rid ` | Runtime identifier (default: current platform) | | `--bundle-version ` | Version string for the layout | | `--download-runtime` | Download .NET and ASP.NET runtimes from Microsoft | | `--runtime-version ` | Specific .NET SDK version to download | From e16d077c30b8caac93760526575f4733de9c557d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 18 Sep 2026 13:02:41 +1000 Subject: [PATCH 102/106] Make detached terminal recovery failure-safe Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 7 + .../wwwroot/js/app-terminalwindow.js | 76 ++++++++--- .../JavaScript/TerminalWindow.test.mjs | 123 ++++++++++++++++++ .../Layout/TerminalDockWindowTrackingTests.cs | 15 ++- 4 files changed, 197 insertions(+), 24 deletions(-) diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 18423070b01..382c923ce03 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -427,6 +427,13 @@ handle recovery leaves an unconfirmed placeholder until **Focus window** or **Return to dock** is used. Focus reuses the named window through user activation; Return revokes its generation so delayed discovery cannot restore the old window. +Adoption validates the complete record batch and discovery request before +transferring live-window ownership or acknowledging any detached pane. A failed +batch keeps the recovery placeholder rather than partially adopting windows. +Explicit Return always releases tracking and attempts to close a known live window, even +if durable storage cleanup fails. Corrupt records are removed when storage is +writable; storage failures remain visible through the dashboard's warning log. + The terminal frame keeps font decrease/increase buttons, the current font size, and the live columns-by-rows selector together in its bottom-right footer. A separate Fit button switches to container-sized rows and columns diff --git a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js index 6f32a79851d..ef860111819 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js @@ -136,25 +136,34 @@ export function adoptTerminalWindows(id, keys) { // Only adopt the caller's current terminal identities, never every window in this module. In particular, // AppHost dock IDs come from its metadata snapshot, not resource names or persisted titles from another run. const context = getOpenerContext(launcher.baseUri); - const notifications = []; + const candidates = []; for (const key of keys) { - launcher.dockKeys.add(key); let entry = openWindows.get(key); let record = readRecord(context, key); if (entry?.win?.closed) { removeRecord(entry); - openWindows.delete(key); entry = null; record = readRecord(context, key); } if (record && entry?.record?.generation !== record.generation) { entry = { win: null, owner: launcher, record, context }; - openWindows.set(key, entry); } + candidates.push({ key, entry }); + } + // Validate the whole batch, including the discovery write, before transferring ownership or queuing any + // acknowledgements. Otherwise a later failure leaves earlier panes adopted without batch readiness, and a + // subsequent close notification removes their failure placeholders without allowing a dock viewer to mount. + requestDiscovery(context); + + const notifications = []; + for (const { key, entry } of candidates) { + launcher.dockKeys.add(key); if (!entry) { + openWindows.delete(key); continue; } + openWindows.set(key, entry); entry.owner = launcher; notifications.push(notify(launcher, () => { if (openWindows.get(key) === entry && entry.owner === launcher && !entry.win?.closed) { @@ -163,8 +172,6 @@ export function adoptTerminalWindows(id, keys) { } })); } - requestDiscovery(context); - // The dock must reconcile these acknowledgements before mounting ANY candidate viewer. Returning just a // snapshot could overtake a close/return notification and resurrect a stale detached state. return Promise.all(notifications); @@ -261,19 +268,27 @@ export function focusTerminalWindow(key) { */ export function closeTerminalWindow(key) { const entry = openWindows.get(key); - // Revocation is durable before returning control to the dock. A suspended or reloading detached page checks - // this generation before it mounts again, and a late ready message cannot resurrect a returned window. - removeRecord(entry); - if (!entry && openerContext) { - // Explicit return must also recover from a corrupt record that passive adoption could not parse. - window.localStorage.removeItem(recordKey(openerContext, key)); - } - openWindows.delete(key); + try { + // Attempt durable revocation before returning control to the dock, including corrupt records. Propagate + // storage failures to the caller for logging, but never let them leave a known live viewer competing + // with the dock that the caller reattaches even when this operation fails. + removeRecord(entry, true); + if (!entry && openerContext) { + window.localStorage.removeItem(recordKey(openerContext, key)); + } + } finally { + // Forget the handle before closing it so queued acknowledgements and late ready messages cannot + // resurrect this detachment, even if durable storage or the browser close operation fails. + openWindows.delete(key); - if (entry?.win && !entry.win.closed) { - entry.win.close(); + try { + if (entry?.win && !entry.win.closed) { + entry.win.close(); + } + } finally { + stopPollingIfEmpty(); + } } - stopPollingIfEmpty(); } export function isTerminalWindowOpen(key) { @@ -353,7 +368,10 @@ function coordinatedWindowName(context, record) { } function readRecord(context, key) { - const raw = window.localStorage.getItem(recordKey(context, key)); + return parseRecord(context, key, window.localStorage.getItem(recordKey(context, key))); +} + +function parseRecord(context, key, raw) { if (raw === null) { return null; } @@ -369,9 +387,25 @@ function readRecord(context, key) { return record; } -function removeRecord(entry) { - if (entry?.record && readRecord(entry.context, entry.record.key)?.generation === entry.record.generation) { - window.localStorage.removeItem(recordKey(entry.context, entry.record.key)); +function removeRecord(entry, removeInvalid = false) { + if (!entry?.record) { + return; + } + const key = recordKey(entry.context, entry.record.key); + // A failed read cannot establish which generation is stored; do not erase a possible replacement. A + // successfully read but invalid record, however, cannot authorize a viewer and explicit return can clear it. + const raw = window.localStorage.getItem(key); + let record; + try { + record = parseRecord(entry.context, entry.record.key, raw); + } catch (error) { + if (removeInvalid) { + window.localStorage.removeItem(key); + } + throw error; + } + if (record?.generation === entry.record.generation) { + window.localStorage.removeItem(key); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs index 85f975d68b6..0194e13333e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs @@ -477,6 +477,21 @@ test("reopening a closed window suppresses its obsolete queued close notificatio ]); }); +test("a browser close failure still forgets the handle and releases polling", async () => { + const { button } = register(); + button.click(); + await flushNotifications(); + notifications.length = 0; + button.click(); + mock.method(calls[0].popup, "close", () => { throw new Error("Browser close denied"); }); + + assert.throws(() => terminalWindows.closeTerminalWindow("terminal"), /Browser close denied/); + assert.equal(terminalWindows.isTerminalWindowOpen("terminal"), false); + assert.equal(poll, null); + await flushNotifications(); + assert.deepEqual(notifications, []); +}); + test("failed browser operations and rejected notifications are observed without poisoning later clicks", async () => { const errors = []; const warnings = []; @@ -811,6 +826,114 @@ describe("cross-document terminal tracking", async () => { } }); + for (const failure of ["corrupt-json", "invalid-record", "read-denied", "remove-denied"]) { + test(`explicit return releases its live handle even when durable revocation fails: ${failure}`, async () => { + const browser = createBrowser(); + const { main, launcher, popup } = await openCoordinatedWindow(browser); + const store = browser.stores.get(main.window.location.origin); + const recordKey = [...store.values.keys()].find(key => key.startsWith("aspire-terminal-window:")); + const raw = store.getItem(recordKey); + let restore = () => {}; + if (failure === "corrupt-json") { + store.setItem(recordKey, "{invalid-json"); + } else if (failure === "invalid-record") { + store.setItem(recordKey, JSON.stringify({ ...JSON.parse(raw), version: 2 })); + } else { + const method = failure === "read-denied" ? "getItem" : "removeItem"; + const fault = mock.method(main.window.localStorage, method, () => { throw new Error("Storage denied"); }); + restore = () => fault.mock.restore(); + } + + main.notifications.length = 0; + launcher.button.click(); // Queue an acknowledgement that must not survive the explicit return. + assert.throws(() => main.module.closeTerminalWindow("terminal"), + failure === "corrupt-json" ? SyntaxError + : failure === "invalid-record" ? /Invalid detached terminal window record/ : /Storage denied/); + restore(); + assert.equal(popup.window.closed, true); + assert.equal(main.module.isTerminalWindowOpen("terminal"), false); + assert.equal(main.window.timers.size, 0); + emit(main.window.events, "message", browser.messages[0]); + await flushNotifications(); + assert.deepEqual(main.notifications, []); + assert.equal(store.getItem(recordKey), + failure === "corrupt-json" || failure === "invalid-record" ? null : raw); + + if (store.getItem(recordKey) === null) { + const reloadedPopup = await loadDocument(popup.window); + assert.equal(reloadedPopup.registerPopup(), false); + } + }); + } + + test("explicit return of an old handle preserves a newer durable generation", async () => { + const browser = createBrowser(); + const { main, popup } = await openCoordinatedWindow(browser); + const store = browser.stores.get(main.window.location.origin); + const recordKey = [...store.values.keys()].find(key => key.startsWith("aspire-terminal-window:")); + const record = JSON.parse(store.getItem(recordKey)); + const url = new URL(record.url); + url.searchParams.set("windowGeneration", "replacement"); + const replacement = JSON.stringify({ ...record, generation: "replacement", url: url.href }); + store.setItem(recordKey, replacement); + + main.module.closeTerminalWindow("terminal"); + assert.equal(popup.window.closed, true); + assert.equal(main.module.isTerminalWindowOpen("terminal"), false); + assert.equal(store.getItem(recordKey), replacement); + }); + + for (const failure of ["later-record", "discovery"]) { + for (const reload of [false, true]) { + test(`failed adoption leaves no partial acknowledgements or ownership: ${failure}, reload=${reload}`, async () => { + const browser = createBrowser(); + const { main, launcher: oldLauncher, popup } = await openCoordinatedWindow(browser); + main.module.unregisterTerminalWindowButton(oldLauncher.id); + const current = reload ? await loadDocument(main.window) : main; + const launcher = current.register(); + const store = browser.stores.get(main.window.location.origin); + const recordKey = [...store.values.keys()].find(key => key.startsWith("aspire-terminal-window:")); + const identity = JSON.parse(recordKey.slice("aspire-terminal-window:".length)); + identity[2] = "later"; + const laterKey = "aspire-terminal-window:" + JSON.stringify(identity); + let restore; + if (failure === "later-record") { + store.setItem(laterKey, "{invalid-json"); + restore = () => store.removeItem(laterKey); + } else { + const fault = mock.method(main.window.localStorage, "setItem", () => { throw new Error("Discovery denied"); }); + restore = () => fault.mock.restore(); + } + current.notifications.length = 0; + assert.throws(() => current.module.adoptTerminalWindows(launcher.id, ["terminal", "later"]), + failure === "later-record" ? SyntaxError : /Discovery denied/); + await flushNotifications(); + const afterFailure = [...current.notifications]; + const trackedAfterFailure = current.module.isTerminalWindowOpen("terminal"); + + // Even if queued acknowledgements are suppressed, a failed batch must not acquire ownership: + // a later close callback would clear the failure placeholder without setting batch readiness. + popup.window.close(); + current.poll(); + await flushNotifications(); + restore(); + assert.deepEqual({ + afterFailure, afterClose: current.notifications, trackedAfterFailure, + }, { + afterFailure: [], afterClose: [], trackedAfterFailure: !reload, + }); + assert.equal(main.window.openCalls.length, 1); + assert.equal(popup.window.focusCalls, 0); + + await current.module.adoptTerminalWindows(launcher.id, ["terminal", "later"]); + assert.deepEqual(current.notifications, reload + ? [["OnTerminalWindowOpenedAsync", "terminal", "recovering"]] + : []); + current.module.closeTerminalWindow("terminal"); + }); + } + } + test("a blocked or failed native popup does not leave a phantom durable detachment after reload", async () => { mock.method(console, "error", () => {}); for (const throws of [false, true]) { diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs index 83c11e044e6..aff1f2b962f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs @@ -19,15 +19,22 @@ namespace Aspire.Dashboard.Components.Tests.Layout; public partial class TerminalDockTests { [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task ReloadRecovery_KeepsPlaceholderUntilExplicitReturnOrConfirmedClosure(bool storageFailure) + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ReloadRecovery_KeepsPlaceholderUntilExplicitReturnOrConfirmedClosure(bool storageFailure, bool closeFailure) { var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); var module = TerminalSetupHelpers.SetupTerminalWindows(this); var adoption = module.SetupVoid("adoptTerminalWindows", _ => true); + if (closeFailure) + { + // JS reports failed durable revocation after unconditionally releasing its live popup handle. + module.SetupVoid("closeTerminalWindow", _ => true).SetException(new JSException("Storage denied")); + } var cut = RenderComponent(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); @@ -59,6 +66,8 @@ public async Task ReloadRecovery_KeepsPlaceholderUntilExplicitReturnOrConfirmedC await cut.InvokeAsync(() => cut.FindAll(".terminal-dock-detached-actions .aspire-button")[1].ClickAsync(new())); cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); + var close = Assert.Single(module.Invocations, i => i.Identifier == "closeTerminalWindow"); + Assert.Equal("terminal", close.Arguments[0]); Assert.Empty(client.ClosedTerminals); } From 8bf9d917c35de55d1aeade56af3cb0a317bd3aa6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 18 Sep 2026 13:17:54 +1000 Subject: [PATCH 103/106] Fix terminal component test setup and async synchronization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- .../Layout/MainLayoutTerminalTests.cs | 12 ++++++++++-- .../Layout/TerminalDockWindowTrackingTests.cs | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs index c6a64d0226f..a9552f654c2 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs @@ -28,9 +28,14 @@ public partial class MainLayoutTests public async Task TerminalDock_RequiresResourceService(bool isEnabled, bool isDesktop) { var updates = Channel.CreateUnbounded(); + var subscriptionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var client = new TestDashboardClient( isEnabled: isEnabled, - terminalChannelProvider: () => updates, + terminalChannelProvider: () => + { + subscriptionStarted.TrySetResult(); + return updates; + }, resourceChannelProvider: () => Channel.CreateUnbounded>()); TerminalSetupHelpers.SetupTerminalView(this); TerminalSetupHelpers.SetupTerminalDock(this); @@ -52,7 +57,9 @@ public async Task TerminalDock_RequiresResourceService(bool isEnabled, bool isDe if (isEnabled) { - cut.WaitForAssertion(() => Assert.Equal(1, client.ActiveTerminalSubscriptionCount)); + // Subscription starts on a worker without triggering a render; a render-driven wait can miss it. + await subscriptionStarted.Task.DefaultTimeout(); + Assert.Equal(1, client.ActiveTerminalSubscriptionCount); Assert.Single(cut.FindAll(".terminal-dock")); var dock = cut.FindComponent().Instance; await cut.InvokeAsync(() => client.SetConnectionState(DashboardConnectionState.Disconnected)); @@ -64,6 +71,7 @@ public async Task TerminalDock_RequiresResourceService(bool isEnabled, bool isDe else { Assert.Empty(cut.FindAll(".terminal-dock")); + Assert.False(subscriptionStarted.Task.IsCompleted); Assert.Equal(0, client.TerminalSubscriptionCount); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs index aff1f2b962f..a43baa202d8 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; using Xunit; @@ -35,6 +36,7 @@ public async Task ReloadRecovery_KeepsPlaceholderUntilExplicitReturnOrConfirmedC // JS reports failed durable revocation after unconditionally releasing its live popup handle. module.SetupVoid("closeTerminalWindow", _ => true).SetException(new JSException("Storage denied")); } + var toasts = RenderComponent(); var cut = RenderComponent(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); @@ -55,6 +57,13 @@ public async Task ReloadRecovery_KeepsPlaceholderUntilExplicitReturnOrConfirmedC cut.Find(".terminal-dock-detached > span").TextContent); Assert.Empty(cut.FindComponents()); }); + if (storageFailure) + { + // The placeholder renders before the error notification. Observe the toast too so the test + // cannot finish while the adoption failure handler is still running. + toasts.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalWindowTrackingFailed, + Assert.Single(toasts.FindComponents()).Instance.Title)); + } cut.Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await cut.InvokeAsync(cut.Instance.ToggleAsync); From 7adbc359db9e4eb5e31ebbbf039cafdf1b4abf9c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 18 Sep 2026 15:17:04 +1000 Subject: [PATCH 104/106] Polish dashboard terminal layout and selection UX Share tab and splitter styling, improve responsive empty-state content, and paint terminal input focus above the canvas. Refine selection copying and expiration, and update localized navigation and shortcut help. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c --- docs/specs/with-terminal.md | 31 +++++- .../Components/Controls/TerminalView.razor | 2 +- .../Controls/TerminalView.razor.css | 19 +++- .../Components/Controls/TerminalView.razor.js | 22 ++++- .../Components/Dialogs/HelpDialog.razor | 28 ------ .../Components/Dialogs/HelpDialog.razor.cs | 51 ++++++++++ .../Components/Layout/MobileNavMenu.razor.cs | 2 +- .../Components/Layout/TerminalDock.razor | 15 +-- .../Components/Layout/TerminalDock.razor.css | 91 ++++++++---------- .../Resources/Dialogs.Designer.cs | 11 ++- src/Aspire.Dashboard/Resources/Dialogs.resx | 3 + .../Resources/xlf/Dialogs.cs.xlf | 5 + .../Resources/xlf/Dialogs.de.xlf | 5 + .../Resources/xlf/Dialogs.es.xlf | 5 + .../Resources/xlf/Dialogs.fr.xlf | 5 + .../Resources/xlf/Dialogs.it.xlf | 5 + .../Resources/xlf/Dialogs.ja.xlf | 5 + .../Resources/xlf/Dialogs.ko.xlf | 5 + .../Resources/xlf/Dialogs.pl.xlf | 5 + .../Resources/xlf/Dialogs.pt-BR.xlf | 5 + .../Resources/xlf/Dialogs.ru.xlf | 5 + .../Resources/xlf/Dialogs.tr.xlf | 5 + .../Resources/xlf/Dialogs.zh-Hans.xlf | 5 + .../Resources/xlf/Dialogs.zh-Hant.xlf | 5 + src/Aspire.Dashboard/wwwroot/css/controls.css | 31 ++++-- .../Dialogs/HelpDialogTests.cs | 42 ++++++++ .../JavaScript/TerminalView.test.mjs | 95 ++++++++++++++++++- .../Layout/MainLayoutTerminalTests.cs | 9 +- .../Playwright/TerminalDockTests.cs | 81 ++++++++++++++++ 29 files changed, 491 insertions(+), 107 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Dialogs/HelpDialog.razor.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Dialogs/HelpDialogTests.cs diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 382c923ce03..a6d9268a123 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -357,14 +357,19 @@ Sixel and Kitty Graphics Protocol are rendered from server-authoritative state. Historical rendering is text-only. The dashboard's independent console-log view remains available. -Text selections use a translucent Aspire accent highlight. A Fluent copy button -appears below and to the right of the last visible selected line, clamping to the -canvas edges and moving above the line when there is not enough room below. +Text selections use a translucent Aspire accent highlight. A compact Fluent copy +button appears only when more than one terminal cell is selected, so clicking to +focus does not show it. It appears below and to the right of the last visible +selected line, clamping to the canvas edges and moving above the line when there +is not enough room below. The dashboard uses Hex1b's public selection overlay and copy action; Hex1b retains ownership of authoritative selection text, history and clipboard handling. After a successful copy, the selection and copy overlay are cleared and focus returns to the terminal, ready for Cmd+V or Ctrl+V. A failed copy leaves the selection available for retry. +Selections invalidated by resizing, reflow or changed/evicted output are cleared +through Hex1b's public selection callback without showing its selection-expired +message. HMP checkpoints retain uploaded Kitty image data even when an animation temporarily removes its placements. They also preserve partially received ANSI @@ -448,18 +453,34 @@ changing the grid. The bottom-left footer hint advertises F6, which m keyboard focus from terminal input to the footer controls; Shift+F6 moves focus to the preceding dashboard control. +Dock tabs share the Resources/Parameters tab styling. The dock resize handle +uses the dashboard's Fluent splitter styling, including neutral gray hover, +drag and keyboard-focus feedback. + Press the backtick key (`), without Shift, to show or hide the terminal -dock. The shortcut is suppressed while a terminal or text input has focus so it +dock. The help dialog lists this shortcut under **Site-wide navigation** only +when the resource service is enabled and the selected run is not read-only. +The shortcut is suppressed while a terminal or text input has focus so it does not consume typed input. Press F6 first to move from terminal input to its footer controls before toggling the dock. The desktop header also has a terminal toggle button. On mobile, open the -navigation menu and select **Toggle terminal** to open, collapse, or reopen the +navigation menu and select **Terminal** to open, collapse, or reopen the dock without a keyboard. Both controls are available only for writable live runs with the resource service enabled, and are hidden while switching runs. When the dock is empty, it lists links to terminal-enabled resources on their resource pages. Resource terminals remain separate from AppHost-owned dock tabs. +As the empty panel shrinks, supplementary text and its icon are hidden first, +then the documentation link, then the backtick hint. The heading has highest +priority, and overflowing content remains scrollable from its beginning. +The documentation link uses the same text size as the surrounding copy. + +Focused terminal input uses the same inset focus highlight as dashboard +textboxes, around the terminal's mount area rather than its title or footer. +The highlight is layered above the canvas so rendering cannot obscure it. +Moving focus to the footer or another control removes the highlight without +changing terminal dimensions. Before the first opening, the dock watches only AppHost terminal metadata so `Show()` can reveal it remotely. Resource-link tracking and browser controls start diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index cd1b360f614..c1bc46bb994 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -8,7 +8,7 @@ IconOnly="true" Title="@ControlsLoc[nameof(Resources.ControlsStrings.GridValueCopyToClipboard)]" aria-label="@ControlsLoc[nameof(Resources.ControlsStrings.GridValueCopyToClipboard)]"> - +
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css index bf5e75b4f09..b5bfc3adc93 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.css @@ -131,6 +131,8 @@ } .terminal-container { + position: relative; + isolation: isolate; width: 100%; height: 100%; min-width: 0; @@ -138,6 +140,15 @@ overflow: hidden; } +.terminal-container::after { + /* Paint focus above the composited canvas without resizing the terminal or intercepting input. */ + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; +} + .terminal-container ::deep ::part(selection-highlight) { background: var(--colorBrandBackground); opacity: 0.3; @@ -145,8 +156,8 @@ .terminal-view ::deep .terminal-selection-actions { position: absolute; - width: 32px; - height: 32px; + width: var(--aspire-control-height); + height: var(--aspire-control-height); pointer-events: auto; } @@ -156,7 +167,9 @@ .terminal-view ::deep .terminal-selection-copy { width: 100%; + min-width: 0; height: 100%; + min-height: 0; border-radius: var(--borderRadiusMedium); box-shadow: 0 2px 8px rgb(0 0 0 / 30%); } @@ -164,7 +177,9 @@ .terminal-view ::deep .terminal-selection-copy::part(control) { padding: 0; width: 100%; + min-width: 0; height: 100%; + min-height: 0; border-radius: inherit; } diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index f9941c37a5e..de6f9a224fa 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -174,6 +174,15 @@ function inputFailed(state, error) { }); } +function clearInvalidatedSelection(state) { + const client = state.client; + if (client?.connected && client.selection.status === "invalidated") { + // Resize, reflow and output changes can expire the producer's selection. + // Clear through the public API before the package's expiry message is painted. + client.clearSelection(); + } +} + function focusAfterMouseControl(state, event) { // Keyboard/assistive activation has detail 0; keep focus for repeated keyboard adjustments. // https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#usage_notes @@ -286,7 +295,11 @@ function createSelectionUI(state, current) { event.detail.overlay.append(actions); } detail = event.detail; - const selectable = detail.connected && ["valid", "pending"].includes(detail.selection.status); + // Ranges have exclusive end columns; text length differs for wide and combining characters. + const selectedCells = detail.selection.ranges.reduce( + (count, range) => count + range.endColumn - range.startColumn, 0); + const selectable = detail.connected && ["valid", "pending"].includes(detail.selection.status) && + selectedCells > 1; const hadFocus = actions.contains(document.activeElement); actions.hidden = !selectable; const position = selectable @@ -381,6 +394,11 @@ async function mountClient(state, generation, controller) { } }, onSelectionUI: createSelectionUI(state, current), + onSelectionChange() { + if (current()) { + clearInvalidatedSelection(state); + } + }, // The package chooses WebGL2 on ordinary HTTP/unavailable WebGPU; // unexpected initialization and runtime rendering errors still surface. // https://github.com/mitchdenny/hex1b/pull/491 @@ -429,6 +447,8 @@ async function mountClient(state, generation, controller) { return; } state.client = client; + // Selection notifications can precede mount completion, before the handle is available. + clearInvalidatedSelection(state); // Policy can change while mount is waiting for its first frame. client.setReadOnly(state.readOnly || state.ended); if (state.ended) { diff --git a/src/Aspire.Dashboard/Components/Dialogs/HelpDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/HelpDialog.razor index 221d4035acf..d3607cc89a1 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/HelpDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/HelpDialog.razor @@ -1,5 +1,4 @@ @using Aspire.Dashboard.Resources -@inject IStringLocalizer Loc @@ -52,30 +51,3 @@ } - -@code { - private List GetShortcutsByCategory() => - [ - new(Loc[nameof(Dialogs.HelpDialogCategoryPanels)], - [ - new KeyboardShortcut(["+"], Loc[nameof(Dialogs.HelpDialogIncreasePanelSize)]), - new KeyboardShortcut(["-"], Loc[nameof(Dialogs.HelpDialogDecreasePanelSize)]), - new KeyboardShortcut(["shift", "r"], Loc[nameof(Dialogs.HelpDialogResetPanelSize)]), - new KeyboardShortcut(["shift", "t"], Loc[nameof(Dialogs.HelpDialogTogglePanelOrientation)]), - new KeyboardShortcut(["shift", "x"], Loc[nameof(Dialogs.HelpDialogTogglePanelOpen)]), - ]), - new(Loc[nameof(Dialogs.HelpDialogCategoryPageNavigation)], - [ - new KeyboardShortcut(["r"], Loc[nameof(Dialogs.HelpDialogGoToResources)]), - new KeyboardShortcut(["c"], Loc[nameof(Dialogs.HelpDialogGoToConsoleLogs)]), - new KeyboardShortcut(["s"], Loc[nameof(Dialogs.HelpDialogGoToStructuredLogs)]), - new KeyboardShortcut(["t"], Loc[nameof(Dialogs.HelpDialogGoToTraces)]), - new KeyboardShortcut(["m"], Loc[nameof(Dialogs.HelpDialogGoToMetrics)]), - ]), - new(Loc[nameof(Dialogs.HelpDialogCategoryNavigation)], - [ - new KeyboardShortcut(["?"], Loc[nameof(Dialogs.HelpDialogGoToHelp)]), - new KeyboardShortcut(["shift", "s"], Loc[nameof(Dialogs.HelpDialogGoToSettings)]) - ]) - ]; -} diff --git a/src/Aspire.Dashboard/Components/Dialogs/HelpDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/HelpDialog.razor.cs new file mode 100644 index 00000000000..d0eef8a8568 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/HelpDialog.razor.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; + +namespace Aspire.Dashboard.Components.Dialogs; + +public partial class HelpDialog +{ + [Inject] + public required IStringLocalizer Loc { get; init; } + + [Inject] + public required IDashboardClient DashboardClient { get; init; } + + private List GetShortcutsByCategory() + { + List navigationShortcuts = + [ + new(["?"], Loc[nameof(Resources.Dialogs.HelpDialogGoToHelp)]), + new(["shift", "s"], Loc[nameof(Resources.Dialogs.HelpDialogGoToSettings)]) + ]; + + if (DashboardClient.IsEnabled && !DashboardClient.IsReadOnly) + { + navigationShortcuts.Add(new(["`"], Loc[nameof(Resources.Dialogs.HelpDialogToggleTerminalDock)])); + } + + return + [ + new(Loc[nameof(Resources.Dialogs.HelpDialogCategoryPanels)], + [ + new KeyboardShortcut(["+"], Loc[nameof(Resources.Dialogs.HelpDialogIncreasePanelSize)]), + new KeyboardShortcut(["-"], Loc[nameof(Resources.Dialogs.HelpDialogDecreasePanelSize)]), + new KeyboardShortcut(["shift", "r"], Loc[nameof(Resources.Dialogs.HelpDialogResetPanelSize)]), + new KeyboardShortcut(["shift", "t"], Loc[nameof(Resources.Dialogs.HelpDialogTogglePanelOrientation)]), + new KeyboardShortcut(["shift", "x"], Loc[nameof(Resources.Dialogs.HelpDialogTogglePanelOpen)]), + ]), + new(Loc[nameof(Resources.Dialogs.HelpDialogCategoryPageNavigation)], + [ + new KeyboardShortcut(["r"], Loc[nameof(Resources.Dialogs.HelpDialogGoToResources)]), + new KeyboardShortcut(["c"], Loc[nameof(Resources.Dialogs.HelpDialogGoToConsoleLogs)]), + new KeyboardShortcut(["s"], Loc[nameof(Resources.Dialogs.HelpDialogGoToStructuredLogs)]), + new KeyboardShortcut(["t"], Loc[nameof(Resources.Dialogs.HelpDialogGoToTraces)]), + new KeyboardShortcut(["m"], Loc[nameof(Resources.Dialogs.HelpDialogGoToMetrics)]), + ]), + new(Loc[nameof(Resources.Dialogs.HelpDialogCategoryNavigation)], navigationShortcuts) + ]; + } +} diff --git a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs index 3092a95a463..082191998f3 100644 --- a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs @@ -217,7 +217,7 @@ private IEnumerable GetMobileNavMenuEntries() if (IsTerminalDockEnabled) { yield return new MobileNavMenuEntry( - TerminalLoc[nameof(Resources.TerminalStrings.MainLayoutToggleTerminalDock)], + TerminalLoc[nameof(Resources.TerminalStrings.TerminalTitle)], ToggleTerminalDockAsync, new Icons.Regular.Size20.WindowConsole() ); diff --git a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor index 1f0d6d4a0e8..2645527748c 100644 --- a/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor +++ b/src/Aspire.Dashboard/Components/Layout/TerminalDock.razor @@ -6,14 +6,17 @@ rather than unmounting it so the terminal keeps its renderer, measured cell metrics, and WebSocket. *@ @if (_hasBeenOpened) { -
-