You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Extends Aspire's existing WithTerminal() support to interactive processes owned by the AppHost, dashboard terminal dialogs and a persistent dock, detached windows, and C#/CLI automation. This enables workflows such as authenticating an external tool, running interactive setup, or opening a container shell from a resource command.
AppHost-owned terminals: experimental TerminalService, AspireTerminal, and TerminalLaunchOptions APIs create interactive processes independently of resources orchestrated by DCP. Placement can be Dock, Dialog, or None for headless automation; the public API does not expose Hex1b types.
Terminal dialogs:IInteractionService.PromptTerminalAsync follows the progress-dialog pattern, with a terminal instead of a spinner. An optional TerminalInteractionOptions.Work callback can drive the terminal while the user watches; successful completion closes the dialog, and Cancel signals the callback's cancellation token.
Persistent terminal dock: resizable, tabbed AppHost terminals with activation, switching, closing, and keyboard access. Sessions remain available while navigating elsewhere in the dashboard.
Detached windows: open AppHost-owned or resource terminals in a dedicated browser window and return them to the originating view without creating another process. The detached window takes primary sizing ownership and retains the selected font size.
State-based terminal rendering: replaces xterm.js with @hex1b/web-terminal, rendering authoritative Hex1b terminal state rather than interpreting terminal output again in the browser. Includes Kitty Graphics Protocol and Sixel support, copy/paste and scrollback improvements, a WebGPU renderer with WebGL2 fallback, and vendored browser modules, workers, fonts, and licenses.
Font and sizing controls: font-size adjustment, dimension presets, fit-to-container, and automatic fitting. Dock/window resizing preserves the selected font size, with grid resizing governed by primary-viewer ownership.
C# and CLI automation: inspect the screen, send text and keys, wait for output, and drive existing resource sessions. The feature-gated CLI adds a VHS-style tape subset with input/wait sequences, final screen output, and text recordings.
API examples
The examples below use one Alpine container and add commands to it. Place the command registrations between this setup and builder.Build().Run(). All terminal APIs share the existing WithTerminal() diagnostic, ASPIRETERMINAL001, so one suppression covers resource terminals, AppHost-owned terminals, interactions, and automation.
shellbox.WithCommand("open-dock","Open shell in terminal dock", context =>{varterminals=context.Services.GetRequiredService<TerminalService>();varterminal=terminals.CreateTerminal(newTerminalLaunchOptions{Title="Container shell",Executable="docker",Arguments=["exec","-it",containerName,"/bin/sh"],Placement=TerminalPlacement.Dock});terminal.Start();terminal.Show();returnTask.FromResult(CommandResults.Success());});
Start() starts the process without waiting for it to exit; Show() reveals and activates its dock tab. This command intentionally does not use await using: the terminal must outlive the command. Closing its dock tab or shutting down the AppHost tears down the AppHost-owned session.
Playground: container shell in the dock, with the resource page still available above it.
A command that opens a terminal dialog
shellbox.WithCommand("open-dialog","Open shell in terminal dialog",async context =>{varterminals=context.Services.GetRequiredService<TerminalService>();varinteractions=context.Services.GetRequiredService<IInteractionService>();awaitusingvarterminal=terminals.CreateTerminal(newTerminalLaunchOptions{Title="Container shell",Executable="docker",Arguments=["exec","-it",containerName,"/bin/sh"],Placement=TerminalPlacement.Dialog});terminal.Start();varresult=awaitinteractions.PromptTerminalAsync("Interact with the container, then cancel when finished.",terminal,newTerminalInteractionOptions{Title="Container shell",PrimaryButtonText="Cancel"},context.CancellationToken);returnresult.Canceled?CommandResults.Failure("Canceled"):CommandResults.Success();});
The interaction borrows the terminal; it does not own or dispose it. Here, the command owns the terminal and disposes it when the prompt returns. Without a Work callback, this manual dialog stays open until explicitly completed or canceled; process exit alone does not close it. The same caller-owned terminal can also be reused across prompts when its lifetime is managed outside the command.
Playground: an interactive docker exec shell presented by PromptTerminalAsync.
Automating a terminal from C#
This command runs an AppHost-owned terminal without showing a dashboard view, sends a command and Enter, waits for its output, and reads the resulting screen.
shellbox.WithCommand("automate-terminal","Automate a terminal",async context =>{varterminals=context.Services.GetRequiredService<TerminalService>();awaitusingvarterminal=terminals.CreateTerminal(newTerminalLaunchOptions{Title="Automated shell",Executable="docker",Arguments=["exec","-it",containerName,"/bin/sh"],Placement=TerminalPlacement.None});terminal.Start();awaitterminal.SendTextAsync("printf 'AUTOMATION_%s\\n' ready",context.CancellationToken);awaitterminal.SendKeyAsync(AspireTerminalKey.Enter,context.CancellationToken);awaitterminal.WaitForTextAsync("AUTOMATION_ready",TimeSpan.FromSeconds(10),context.CancellationToken);Console.WriteLine(terminal.GetScreenText());returnCommandResults.Success();});
The expected output marker is deliberately not present as a contiguous string in the typed command, so shell input echo cannot satisfy the wait before the command runs.
For visible automation, use TerminalPlacement.Dialog and perform the same operations inside TerminalInteractionOptions.Work, using the supplied TerminalContext.CancellationToken. Successful callback completion closes the dialog. Cancellation signals the callback and the prompt waits for it to finish before returning; callback failures propagate after the dialog is removed.
The playground's Number guess command demonstrates this: AppHost code waits for prompts, types guesses, reads the game's responses, and narrows the range until it finds the number. The terminal below is being driven by the AppHost, not by manual typing.
Number-guess completion
When Work completes, the terminal dialog closes and the command displays the result. This captured run found 673 in 9 guesses.
Automating a resource terminal with a tape
The CLI can drive an existingWithTerminal() resource using a supported VHS-style tape subset. For example, playground/Terminals/repl-basics.tape runs commands in the playground REPL and waits for their output:
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]+>$/
With the playground running, use a CLI built from this PR:
cd playground/Terminals
aspire config set features.terminalCommandsEnabled true
aspire terminal tape play repl --replica 0 --tape-file repl-basics.tape
The playground has two REPL replicas, so the example selects replica 0 explicitly. Playback uses a secondary connection to that session: it does not launch another shell, take primary sizing ownership, resize the terminal, or stop the resource when finished. Input is shared with other viewers, so coordinate playback with anyone using that terminal.
The final screen is printed to stdout, diagnostics go to stderr, and Output records per-command screens to .txt/.ascii files. Output paths are relative to the root tape directory; existing files are not overwritten. This is not full VHS video rendering: unsupported media, clipboard, sizing, and environment commands are rejected during preflight before input is sent.
Playground: the resource terminal after playing the tape above.
More dashboard UX
All screenshots in this description were captured from the repository-built dashboard running playground/Terminals.
The same session in a separate window
The window button detaches the view without starting another process. The session retains its existing shell output and can be returned to the panel.
Originating dock while its terminal is detached
The dock offers Focus window and Return to panel rather than creating a second terminal.
Empty dock and discoverability
The empty state includes the terminal documentation link and keyboard shortcut hint.
Implementation and scope
AppHost-owned terminal sessions are tunneled over the existing dashboard gRPC connection. The browser connects to the dashboard over WebSocket using Hex1b's HWT1 presentation transport; resource terminals continue to use the terminal-host path. Releasing a handle acquired for an existing resource terminal does not stop the resource.
The new AppHost APIs remain experimental, and CLI terminal commands remain feature-gated. This PR does not add an ATS/polyglot terminal API.
playground/Terminals includes AppHost shells, container exec, dock/dialog commands, number-guess and resource-terminal automation, and non-destructive tapes.
Adopt the final stable Hex1b NuGet and @hex1b/web-terminal packages. Current paired version: 0.168.0.
Confirm Hex1b 0.168.0 is mirrored into the approved dotnet-public feed.
Remove temporary nuget.org sources/mappings from NuGet.config, tests/Shared/TemplatesTesting/data/nuget8.config, and the VS Code E2E config generator in extension/scripts/run-e2e.js; remove the repository/template-test NU1902/NU1903 suppressions at the same time.
Confirm Windows x64 CLI publishing and stabilization pass with the correct OpenConsole payload layout.
Confirm template package tests pass with the final feed configuration.
Confirm full VS Code E2E fixture restores pass with the final internal-only feed configuration.
Confirm targeted Dashboard component coverage passes after merging main, including terminal dock and detached-window behavior.
Complete review of initial terminal grid behavior and final API/implementation/CSS feedback.
Resolve outstanding feedback and obtain green required CI on the final commit.
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
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
A critical teardown race and multiple unresolved lifecycle, validation, and WebSocket issues require fixes and human review.
Review tier: Balanced Findings: 1 · 6
New issues introduced by this change (7)
Severity
Finding
src/Aspire.Hosting/Dashboard/InteractionTerminalSessionStore.cs — Canceling _stopCts completes every AttachAsync waiter immediately, before…
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor — A terminal input never supplies a Value, but both the dashboard's required-field check and…
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor — TerminalView is mounted even when InputDisabled is true. Its first render immediately connects…
src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs — This does not actually verify the session before accepting the WebSocket.…
src/Aspire.Hosting/Dashboard/DashboardService.cs — This streaming RPC bypasses ExecuteAsync and only observes the call token, despite the…
src/Aspire.Hosting/IInteractionService.cs — InteractionInput is also the public type used by CommandOptions.Arguments, but…
src/Aspire.Hosting/InteractionService.cs — Terminal inputs never produce a Value, but this validation permits Required = true. Both the…
What changed in this PR
This spike adds AppHost-owned interaction terminals tunneled through the dashboard’s existing gRPC and WebSocket infrastructure.
Changes:
Adds terminal interaction APIs, stream adapters, and session lifecycle management.
Adds dashboard rendering, proxying, and generalized terminal endpoints.
Adds tests, shared fakes, and playground terminal scenarios.
No component test renders this new terminal branch or verifies that the interaction endpoint reaches TerminalView/initTerminal. Existing InteractionsInputDialogTests cover other input types and existing terminal tests cover only resource/replica URLs. Add a bUnit test for a terminal input, including an escaped input name and the explicit endpoint.
This client-side stream adapter has no focused tests, while the corresponding server adapter does. Add coverage for selector ordering, split/empty response frames, buffer-copy behavior, and disposal/call cancellation; otherwise the dashboard half of the tunnel can regress without the existing GrpcTerminalStreamTests detecting it.
internal sealed class GrpcTerminalClientStream : Stream
This new security-sensitive WebSocket route has no endpoint-level regression test for its Origin gate. The existing /api/terminal route has TerminalWebSocketProxyEndpointTests specifically to prove rejected origins never reach the resolver; add equivalent coverage here so route wiring cannot silently bypass the copied CSWSH defense.
// 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))
{
src/Aspire.Hosting/InteractionService.cs:218
The new fake records start/complete/cancel calls, but no test injects and asserts it; CreateInteractionService always constructs it internally. As a result, regressions in registration and both teardown branches remain undetected. Add lifecycle tests analogous to the existing file-upload-store tests, including cancellation-token cleanup.
Catching every exception also swallows the cancellation caused by the two-minute test CTS, so a client that never notices tunnel shutdown waits two minutes and then makes the test pass. Bound this await independently and let a timeout fail the test while still ignoring the expected transport exception.
await clientRunTask;
}
catch (Exception)
{
// The client terminal is torn down by the server closing the tunnel; how that surfaces is not under test.
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
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
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
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
Critical concurrency, stream-lifetime, synchronization, and test compilation issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Balanced Findings: 7 · 11 · 3
New issues introduced by this change (16)
Severity
Finding
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs — The watch loop mutates _terminals and _activeTerminalId on a thread-pool thread because both…
src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs — When the attachment token is canceled, this returns immediately even though Hex1b may still be…
src/Aspire.Hosting/Terminals/TerminalService.cs — The snapshot subscription lock does not cover terminal insertion or publication. A creator can…
tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs — The Playwright fake no longer satisfies IDashboardClient: this method has the obsolete signature,…
tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs — This mock still implements the old interaction/name attach method and omits the other three new…
tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs — This class no longer implements IDashboardClient: the interface uses terminal IDs and also added…
tests/Shared/TestDashboardClient.cs — IDashboardClient now requires AttachTerminalAsync, SubscribeTerminalsAsync,…
src/Aspire.Dashboard/Components/Layout/MainLayout.razor — The terminal control is always enabled in historical/read-only mode, but SelectedDashboardClient…
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor — These dock tabs are clickable div elements with no tab semantics, focusability, or keyboard…
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs — Once this server stream ends because of a transient AppHost/dashboard disconnect, the exception is…
src/Aspire.Dashboard/ServiceClient/DashboardClient.cs — This direct server stream has no recovery path. A transient AppHost/gRPC disconnect makes the…
src/Aspire.Hosting/IInteractionService.cs — The documentation labels this property experimental, but there is no [Experimental] attribute, so…
src/Aspire.Hosting/IInteractionService.cs — This enum member is emitted into the Go/Java/Python/Rust SDKs, but InteractionInput.Terminal is…
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs — The documentation still describes the abandoned interaction-ID/input-name addressing scheme, while…
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs — There is no component coverage for this new stateful dock even though it handles asynchronous…
src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs — This comment is stale: payload frames no longer carry interaction ID or input name; only the…
Pre-existing issues (5)
Severity
Finding
src/Aspire.Hosting/InteractionService.cs — Terminal inputs never produce a Value, but this validation permits Required = true. Both the… View comment
src/Aspire.Hosting/IInteractionService.cs — InteractionInput is also the public type used by CommandOptions.Arguments, but… View comment
src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs — This does not actually verify the session before accepting the WebSocket.… View comment
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor — TerminalView is mounted even when InputDisabled is true. Its first render immediately connects… View comment
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor — A terminal input never supplies a Value, but both the dashboard's required-field check and… View comment
Issues resolved since last review (2)
Severity
Finding
src/Aspire.Hosting/Dashboard/DashboardService.cs — This streaming RPC bypasses ExecuteAsync and only observes the call token, despite the… View resolved comment
Sending the selector frame does not wait for the bidi RPC to validate the terminal ID, so this call can return a stream before the AppHost reports FailedPrecondition. The WebSocket is then accepted despite the comment's promise of a pre-upgrade HTTP error, and an unknown terminal appears as an immediately closing socket. Add an explicit server acknowledgement (or a separate validation RPC) before accepting the WebSocket. src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto:495
This comment and the reserved fields encode an abandoned intermediate shape from the branch. Because TerminalClientFrame is introduced by this PR, origin/main never shipped interaction_id or input_name; retaining this history permanently burns field numbers/names and violates the repository convention that committed comments describe the current state rather than within-branch evolution. Remove the reservations and the “Formerly” narrative. src/Aspire.Hosting/InteractionService.cs:174
A terminal does not submit a Value, but the existing server and dialog validation treat every required non-file input with an empty value as invalid. Therefore InputType.Terminal with Required = true can never complete. Either reject that combination here or explicitly exempt terminal inputs from required-value validation in both layers.
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
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
One or more issues must be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Balanced Findings: 9 · 11 · 3
New issues introduced by this change (2)
Severity
Finding
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs — The terminal dock represents live AppHost state, but this unkeyed injection resolves…
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs — Starting this watcher with Task.Run and using ConfigureAwait(false) later causes _terminals,…
Pre-existing issues (21)
Severity
Finding
tests/Shared/TestDashboardClient.cs — IDashboardClient now requires AttachTerminalAsync, SubscribeTerminalsAsync,… View comment
tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs — This class no longer implements IDashboardClient: the interface uses terminal IDs and also added… View comment
tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs — This mock still implements the old interaction/name attach method and omits the other three new… View comment
tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs — The Playwright fake no longer satisfies IDashboardClient: this method has the obsolete signature,… View comment
src/Aspire.Hosting/Terminals/TerminalService.cs — The snapshot subscription lock does not cover terminal insertion or publication. A creator can… View comment
src/Aspire.Hosting/Terminals/Hex1bAspireTerminal.cs — When the attachment token is canceled, this returns immediately even though Hex1b may still be… View comment
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs — The watch loop mutates _terminals and _activeTerminalId on a thread-pool thread because both… View comment
src/Aspire.Hosting/IInteractionService.cs — This enum member is emitted into the Go/Java/Python/Rust SDKs, but InteractionInput.Terminal is… View comment
src/Aspire.Hosting/IInteractionService.cs — The documentation labels this property experimental, but there is no [Experimental] attribute, so… View comment
src/Aspire.Dashboard/ServiceClient/DashboardClient.cs — This direct server stream has no recovery path. A transient AppHost/gRPC disconnect makes the… View comment
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs — Once this server stream ends because of a transient AppHost/dashboard disconnect, the exception is… View comment
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor — These dock tabs are clickable div elements with no tab semantics, focusability, or keyboard… View comment
src/Aspire.Dashboard/Components/Layout/MainLayout.razor — The terminal control is always enabled in historical/read-only mode, but SelectedDashboardClient… View comment
src/Aspire.Hosting/InteractionService.cs — Terminal inputs never produce a Value, but this validation permits Required = true. Both the… View comment
src/Aspire.Hosting/IInteractionService.cs — InteractionInput is also the public type used by CommandOptions.Arguments, but… View comment
src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs — This does not actually verify the session before accepting the WebSocket.… View comment
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor — TerminalView is mounted even when InputDisabled is true. Its first render immediately connects… View comment
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor — A terminal input never supplies a Value, but both the dashboard's required-field check and… View comment
src/Aspire.Dashboard/ServiceClient/GrpcTerminalClientStream.cs — This comment is stale: payload frames no longer carry interaction ID or input name; only the… View comment
src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.cs — There is no component coverage for this new stateful dock even though it handles asynchronous… View comment
A required terminal input can never be submitted. The existing dialog validation treats every required non-boolean input as missing when InputViewModel.Value is blank, but terminal inputs never produce a Value; they only carry TerminalId. Exclude terminal inputs from value validation (or reject Required = true for this type) and cover that behavior.
These clickable tab <div> elements are neither focusable nor keyboard-operable, so keyboard-only users cannot select an inactive terminal tab. Use a tab/button control, or add the appropriate tab semantics, focus management, and Enter/Space/arrow-key handling.
This direct gRPC stream is not part of DashboardClient's WatchWithRecoveryAsync loop. When the AppHost connection drops and reconnects, this call ends and each dock/window watcher catches the exception and exits, leaving terminal state permanently stale for the circuit. Add recovery/fan-out like the resource and interaction watchers, or retry this subscription until cancellation. src/Aspire.Hosting/Terminals/TerminalService.cs:124
The lock does not make the snapshot and subscription atomic because terminal creation/removal and Publish never take _syncLock. A create can add the subscriber, insert the terminal before the snapshot, and then publish Added, delivering the same terminal in both the snapshot and change stream. Synchronize registry mutation plus publication with this lock (or add sequencing/deduplication) so the documented subscription contract holds. tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs:53
This mock still has the removed interaction-based attachment signature and omits the other new terminal members from IDashboardClient, so the Playwright test infrastructure no longer compiles. Replace it with implementations of the current attach, subscribe, create, and close methods. tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs:697
This stale method does not satisfy the new IDashboardClient.AttachTerminalAsync(string, ...) contract, and the test double also lacks the new subscribe/create/close terminal members. Consequently this test project will fail with CS0535; implement the current four-member terminal contract here. tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs:154
This class no longer implements IDashboardClient: the interface now exposes AttachTerminalAsync(string, ...) plus the terminal watch/create/close methods, while this stale interaction-based method matches none of them. The dashboard test project will fail to compile until all four current members are implemented. tests/Shared/TestDashboardClient.cs:91
This test double still implements the superseded AttachInteractionTerminalAsync signature and does not implement the new AttachTerminalAsync, SubscribeTerminalsAsync, CreateDockTerminalAsync, or CloseTerminalAsync members. Because it implements IDashboardClient, every test project linking this shared file will fail with CS0535. Update the fake to the current interface contract.
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
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
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<T>() 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
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
If removeRecord(page) throws while parsing corrupt or unavailable storage, unregisterDetachedTerminalWindow(id) is skipped. The detached page then keeps its storage listener and detachedPages entry, which can continue invoking the disposed .NET owner after navigation. Put the unregister call in a finally block while allowing the storage error to propagate.
Run tests that execute TerminalDock.razor.js
eng/github-ci/test-trigger-map.yml:105
This rule routes TerminalDock.razor.js changes to Infrastructure.Tests, but DashboardTerminalScriptTests only runs TerminalView.test.mjs, KeyboardShortcuts.test.mjs, and TerminalWindow.test.mjs. The bUnit TerminalDock tests mock the JS module and do not execute the shipped resize/tab-navigation code, so changes to this file can pass the selected target without coverage. Add a Node test for this module and invoke it from the mapped target, or route the input to a target that actually executes it.
Renumber terminal client frame fields, restrict dashboard closure to AppHost-owned dock terminals, and remove obsolete migration guidance. Show the F6 hint only while terminal input is focused.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3de8011a-08b4-48c9-aa13-f0feef9a378c
These comments still attribute the pane's sizing and refit behavior to xterm, but the dock now mounts Hex1b's WebTerminal. The stale implementation name makes the sizing invariant misleading; update the rationale to describe the mounted Hex1b renderer/state instead.
This issue also appears on line 112 of the same file.
Error: System.Threading.Tasks.TaskCanceledException : A task was canceled.
Stack Trace (first frames):
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
at Microsoft.Extensions.Hosting.Internal.Host.StopAsync(CancellationToken cancellationToken)
at Aspire.Hosting.DistributedApplication.StopAsync(CancellationToken cancellationToken) in /_/src/Aspire.Hosting/DistributedApplication.cs:line 497
at Aspire.Hosting.Azure.Tests.AzureServiceBusExtensionsTests.AzureServiceBusEmulatorResourceGeneratesConfigJsonOnlyChangedProperties() in /home/runner/work/aspire/aspire/tests/Aspire.Hosting.Azure.Tests/AzureServiceBusExtensionsTests.cs:line 499
--- End of stack trace from previous location ---
Why likely flaky: Failure occurs during host teardown (StopAsync), not in test logic; PR does not modify this area of code.
Error: Aspire.Templates.Tests.ToolCommandException : Expected 0 exit code but got 1: /Users/runner/work/aspire/aspire/artifacts/bin/dotnet-9/dotnet build "-bl:/Users/runner/work/aspire/aspire/artifacts/log/test-logs/new_build_aspire_nunit_2xksfaki_f44.AppHost/new_build_aspire_nunit_2xksfaki_f44.AppHost-build.binlog" /p:TreatWarningsAsErrors=true -c Debug /p:AspireUseCliBundle=false
Standard Output:
Determining projects to restore...
All projects are up-to-date for restore.
CSC : error CS1504: Source file '.../new_build_aspire_nunit_2xksfaki_f44.AppHost.AssemblyInfo.cs' could not be opened -- Method not found: 'PAL_HashAlgorithm System.Security.Cryptography.HashAlgorithmNames.HashAlgorithmToPal(System.String)'.
Stack Trace (first frames):
at Aspire.Templates.Tests.CommandResult.EnsureExitCode(Int32 expectedExitCode, String messagePrefix, Boolean suppressOutput) in /_/tests/Shared/TemplatesTesting/CommandResult.cs:line 36
at Aspire.Templates.Tests.CommandResult.EnsureSuccessful(String messagePrefix, Boolean suppressOutput) in /_/tests/Shared/TemplatesTesting/CommandResult.cs:line 20
at Aspire.Templates.Tests.AspireProject.BuildAsync(String[] extraBuildArgs, CancellationToken token, String workingDirectory) in /_/tests/Shared/TemplatesTesting/AspireProject.cs:line 347
at Aspire.Templates.Tests.NewUpAndBuildSupportProjectTemplatesBase.CanNewAndBuildActual(String templateName, String extraTestCreationArgs, TestSdk sdk, TestTargetFramework tfm, String error) in /_/tests/Aspire.Templates.Tests/NewUpAndBuildSupportProjectTemplatesTests.cs:line 56
Why likely flaky: Roslyn compiler crashed with a 'Method not found' reflection error unrelated to any PR-generated code; indicates a corrupted macOS SDK/Roslyn toolset, matching a previously observed similar pattern.
Aspire AppHost tree E2E discovers the workspace AppHost and renders it in the Aspire view in job Tests / Run VS Code extension E2E tests / VS Code extension E2E (Windows, apphost-tree)
Error: Timed out waiting for child tree item 'Run AppHost' on 'Deploy AppHost'.
Wait timed out after 30174ms
Stack Trace (first frames):
TimeoutError: Timed out waiting for child tree item 'Run AppHost' on 'Deploy AppHost'.
Wait timed out after 30174ms
at D:\a\aspire\aspire\extension\node_modules\selenium-webdriver\lib\webdriver.js:929:22
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
Why likely flaky: UI timing timeout in a Selenium-based E2E test on Windows; the same shard has a documented history of similar flaky timeouts across unrelated PRs.
Aspire dynamic debug configuration E2E "after each" hook for "re-resolves the selected AppHost by dynamic configuration name in duplicate-alias workspaces" in job Tests / Run VS Code extension E2E tests / VS Code extension E2E (Windows, dynamic-debug-configuration)
Error: EBUSY: resource busy or locked, rmdir 'C:\Users\runneradmin\AppData\Local\Temp\aev-nhtS8o\workspace.e2e-dynamic-debug\first'
Stack Trace (first frames):
AggregateError: Dynamic debug configuration E2E teardown failed.
1. Error: EBUSY: resource busy or locked, rmdir 'C:\Users\runneradmin\AppData\Local\Temp\aev-nhtS8o\workspace\.e2e-dynamic-debug\first'
at runE2eTeardown (out\test-e2e\test-e2e\helpers\fixtures.js:217:15)
at async Context.<anonymous> (out\test-e2e\test-e2e\dynamicDebugConfiguration.e2e.test.js:62:9)
Why likely flaky: Windows file-locking race in the E2E teardown helper (EBUSY on rmdir), a previously observed recurring pattern unrelated to the PR's changes.
Suggested actions:
Re-run the failed CI jobs to confirm if the failure is intermittent
If the test continues to fail, consider quarantining it using /quarantine-test <test name> <issue URL>
Search existing issues to see if this test is already known to be flaky
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Features
Extends Aspire's existing
WithTerminal()support to interactive processes owned by the AppHost, dashboard terminal dialogs and a persistent dock, detached windows, and C#/CLI automation. This enables workflows such as authenticating an external tool, running interactive setup, or opening a container shell from a resource command.TerminalService,AspireTerminal, andTerminalLaunchOptionsAPIs create interactive processes independently of resources orchestrated by DCP. Placement can beDock,Dialog, orNonefor headless automation; the public API does not expose Hex1b types.IInteractionService.PromptTerminalAsyncfollows the progress-dialog pattern, with a terminal instead of a spinner. An optionalTerminalInteractionOptions.Workcallback can drive the terminal while the user watches; successful completion closes the dialog, and Cancel signals the callback's cancellation token.@hex1b/web-terminal, rendering authoritative Hex1b terminal state rather than interpreting terminal output again in the browser. Includes Kitty Graphics Protocol and Sixel support, copy/paste and scrollback improvements, a WebGPU renderer with WebGL2 fallback, and vendored browser modules, workers, fonts, and licenses.API examples
The examples below use one Alpine container and add commands to it. Place the command registrations between this setup and
builder.Build().Run(). All terminal APIs share the existingWithTerminal()diagnostic,ASPIRETERMINAL001, so one suppression covers resource terminals, AppHost-owned terminals, interactions, and automation.A command that opens a terminal in the dock
Start()starts the process without waiting for it to exit;Show()reveals and activates its dock tab. This command intentionally does not useawait using: the terminal must outlive the command. Closing its dock tab or shutting down the AppHost tears down the AppHost-owned session.Playground: container shell in the dock, with the resource page still available above it.
A command that opens a terminal dialog
The interaction borrows the terminal; it does not own or dispose it. Here, the command owns the terminal and disposes it when the prompt returns. Without a
Workcallback, this manual dialog stays open until explicitly completed or canceled; process exit alone does not close it. The same caller-owned terminal can also be reused across prompts when its lifetime is managed outside the command.Playground: an interactive
docker execshell presented byPromptTerminalAsync.Automating a terminal from C#
This command runs an AppHost-owned terminal without showing a dashboard view, sends a command and Enter, waits for its output, and reads the resulting screen.
The expected output marker is deliberately not present as a contiguous string in the typed command, so shell input echo cannot satisfy the wait before the command runs.
For visible automation, use
TerminalPlacement.Dialogand perform the same operations insideTerminalInteractionOptions.Work, using the suppliedTerminalContext.CancellationToken. Successful callback completion closes the dialog. Cancellation signals the callback and the prompt waits for it to finish before returning; callback failures propagate after the dialog is removed.The playground's Number guess command demonstrates this: AppHost code waits for prompts, types guesses, reads the game's responses, and narrows the range until it finds the number. The terminal below is being driven by the AppHost, not by manual typing.
Number-guess completion
When
Workcompletes, the terminal dialog closes and the command displays the result. This captured run found 673 in 9 guesses.Automating a resource terminal with a tape
The CLI can drive an existing
WithTerminal()resource using a supported VHS-style tape subset. For example,playground/Terminals/repl-basics.taperuns commands in the playground REPL and waits for their output:With the playground running, use a CLI built from this PR:
The playground has two REPL replicas, so the example selects replica 0 explicitly. Playback uses a secondary connection to that session: it does not launch another shell, take primary sizing ownership, resize the terminal, or stop the resource when finished. Input is shared with other viewers, so coordinate playback with anyone using that terminal.
The final screen is printed to stdout, diagnostics go to stderr, and
Outputrecords per-command screens to.txt/.asciifiles. Output paths are relative to the root tape directory; existing files are not overwritten. This is not full VHS video rendering: unsupported media, clipboard, sizing, and environment commands are rejected during preflight before input is sent.Playground: the resource terminal after playing the tape above.
More dashboard UX
All screenshots in this description were captured from the repository-built dashboard running
playground/Terminals.The same session in a separate window
The window button detaches the view without starting another process. The session retains its existing shell output and can be returned to the panel.
Originating dock while its terminal is detached
The dock offers Focus window and Return to panel rather than creating a second terminal.
Empty dock and discoverability
The empty state includes the terminal documentation link and keyboard shortcut hint.
Implementation and scope
AppHost-owned terminal sessions are tunneled over the existing dashboard gRPC connection. The browser connects to the dashboard over WebSocket using Hex1b's HWT1 presentation transport; resource terminals continue to use the terminal-host path. Releasing a handle acquired for an existing resource terminal does not stop the resource.
The new AppHost APIs remain experimental, and CLI terminal commands remain feature-gated. This PR does not add an ATS/polyglot terminal API.
docs/specs/with-terminal.mddescribes lifecycle, transport, controls, and automation.playground/Terminalsincludes AppHost shells, container exec, dock/dialog commands, number-guess and resource-terminal automation, and non-destructive tapes.Pre-merge checklist
@hex1b/web-terminalpackages. Current paired version:0.168.0.0.168.0is mirrored into the approveddotnet-publicfeed.NuGet.config,tests/Shared/TemplatesTesting/data/nuget8.config, and the VS Code E2E config generator inextension/scripts/run-e2e.js; remove the repository/template-testNU1902/NU1903suppressions at the same time.