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
WithTerminal() adds first-class interactive terminal (PTY) support to Aspire resources. An AppHost author opts a resource into terminal access with a single call, and the dashboard then renders a live xterm.js terminal per replica. The same session is also attachable from the CLI via aspire terminal attach.
This issue retrospectively documents the architecture that landed for Aspire 13.4 alongside #16317 and microsoft/dcp#133, so future contributors have a single high-level reference for the moving parts.
That's it. The order of WithReplicas(...) and WithTerminal() does not matter - per-replica hosts are materialised during BeforeStartEvent once the model is fully built.
WithTerminal() accepts an optional configuration callback for terminal dimensions:
The polyglot withTerminal uses the default 120x30 grid. Custom dimensions for non-C# AppHosts are a follow-up.
Dashboard
When a resource is configured with WithTerminal(), the dashboard's Console logs view is replaced by a Terminal view for that resource. Each replica gets its own xterm.js session - keystrokes are forwarded server-side; resizes are negotiated end-to-end. (Console log lines still flow through to the AppHost log pipeline, so log queries and OTLP-exported logs are unaffected.)
CLI
aspire terminal ps # list sessions in the connected AppHost
aspire terminal attach agent --replica 0 # attach the local terminal to replica 0
When a resource has multiple replicas and the CLI is interactive, aspire terminal attach prompts for replica selection. In non-interactive mode, --replica N is required.
The producer/consumer split lets multiple consumers (dashboard + multiple CLI sessions) attach simultaneously without coupling DCP to consumer counts.
Per-replica fan-out
WithReplicas(3).WithTerminal() produces three terminal host processes named {parent}-terminalhost-0 ... {parent}-terminalhost-2, each brokering the PTY bytes for its replica. They are hidden resources - they don't show up as primary cards in the dashboard, but they are present in the resource graph for diagnostics.
Wire protocol
We don't define a custom wire protocol. The terminal traffic uses Hex1b's HMP v1 (Hex Multiplex Protocol, version 1), which already handles:
VT byte streaming with backpressure
Resize requests in both directions
Hello/StateSync replay so a late-attaching consumer sees the current scrollback
Hmp1WorkloadAdapter is what the AppHost-side terminal host uses to multiplex DCP's PTY traffic to the consumer-facing listener. Hmp1PresentationAdapter is what consumers (Dashboard WebSocket proxy and the CLI) use to attach.
When WithTerminal() is applied, every replica snapshot emitted by the dashboard service carries four properties:
Key
Sensitivity
Meaning
terminal.enabled
non-sensitive
Marker. "true" when the replica has a PTY.
terminal.replicaIndex
non-sensitive
0-based stable index from DcpInstancesAnnotation.
terminal.replicaCount
non-sensitive
Total replicas for the parent resource.
terminal.consumerUdsPath
sensitive
The local UDS that consumers connect to.
The consumer UDS path is marked sensitive so the dashboard UI masks the value in the property list. The browser never sees the path - the dashboard proxy resolves it server-side from (resource, replica) pairs presented by the resource snapshot stream.
Dashboard /api/terminal WebSocket endpoint
Authenticated (RequireAuthorization(FrontendAuthorizationDefaults.PolicyName)) endpoint at /api/terminal?resource=<displayName>&replica=<index>.
TerminalWebSocketProxy resolves the connection entirely server-side via ITerminalConnectionResolver, then runs two pumps over an Hmp1WorkloadAdapter:
Inbound (browser -> producer): binary frames -> HMP v1 Input; text frames -> JSON resize control ({"type":"resize","cols":N,"rows":N}).
Frame type - not content - distinguishes keystrokes from control messages. The browser cannot induce the dashboard to connect to an arbitrary local socket; it can only request (resource, replica) pairs already present in the snapshot stream.
Packaging
Aspire.TerminalHost ships as per-RID NuGet packages and is also rolled into the polyglot CLI bundle alongside the dashboard. The hosting layer resolves the binary path via:
Bundle fallback: when the resolved dashboard path points at aspire-managed, reuse it with the terminalhost subcommand. This keeps the polyglot bundle light (no second binary copy).
Status (Aspire 13.4)
Public API: WithTerminal<T>(this IResourceBuilder<T>, Action<TerminalOptions>?)
Polyglot ATS export: withTerminal
Per-replica TerminalHost processes (one per replica, ordering-independent)
Dashboard /api/terminal WebSocket proxy and per-replica TerminalView
aspire terminal ps and aspire terminal attach CLI commands
Hex1b HMP v1 broker on producer/consumer/control UDS sockets
Windows executable PTY via DCP ConPTY
DCP packaging blocker: end-to-end PTY only works once the microsoft/dcp#133 terminal/PTY changes ship in the Microsoft.DeveloperControlPlane.* NuGet packages consumed by Aspire. Until then, the AppHost spawns the terminal host correctly but the bundled DCP (release/0.23, no terminal support) never dials the producer-uds. Local development can override via DcpPublisher__CliPath.
Linux / macOS / container PTY (Phase 3 follow-ups)
Polyglot overload that accepts a configuration DTO (so non-C# AppHosts can customise columns/rows)
Summary
WithTerminal()adds first-class interactive terminal (PTY) support to Aspire resources. An AppHost author opts a resource into terminal access with a single call, and the dashboard then renders a live xterm.js terminal per replica. The same session is also attachable from the CLI viaaspire terminal attach.This issue retrospectively documents the architecture that landed for Aspire 13.4 alongside #16317 and microsoft/dcp#133, so future contributors have a single high-level reference for the moving parts.
End-user experience
AppHost (C#)
That's it. The order of
WithReplicas(...)andWithTerminal()does not matter - per-replica hosts are materialised duringBeforeStartEventonce the model is fully built.WithTerminal()accepts an optional configuration callback for terminal dimensions:AppHost (polyglot - TS / JS)
The polyglot
withTerminaluses the default 120x30 grid. Custom dimensions for non-C# AppHosts are a follow-up.Dashboard
When a resource is configured with
WithTerminal(), the dashboard's Console logs view is replaced by a Terminal view for that resource. Each replica gets its own xterm.js session - keystrokes are forwarded server-side; resizes are negotiated end-to-end. (Console log lines still flow through to the AppHost log pipeline, so log queries and OTLP-exported logs are unaffected.)CLI
When a resource has multiple replicas and the CLI is interactive,
aspire terminal attachprompts for replica selection. In non-interactive mode,--replica Nis required.Process topology
flowchart LR AppHost["AppHost<br/>(Aspire.Hosting + DCP control plane)"] DCP["DCP<br/>(Windows ConPTY)"] Replica["Replica process<br/>(executable / container)"] Host["TerminalHost<br/>(Hex1b HMP v1 broker)<br/>1 process / replica"] Dashboard["Dashboard<br/>/api/terminal proxy"] CLI["aspire terminal attach"] Browser["xterm.js (browser)"] AppHost -- spawns --> Host AppHost -- spec.Terminal --> DCP DCP -- spawns + ConPTY --> Replica DCP <-- "PTY bytes (producer-uds)<br/>DCP dials, host listens" --> Host Host <-- "broadcast (consumer-uds)" --> Dashboard Host <-- "broadcast (consumer-uds)" --> CLI AppHost <-- "lifecycle/stats (control-uds)" --> Host Dashboard <-- WebSocket --> BrowserThree actors, three socket roles
producerUdsPathconsumerUdsPathcontrolUdsPathThe producer/consumer split lets multiple consumers (dashboard + multiple CLI sessions) attach simultaneously without coupling DCP to consumer counts.
Per-replica fan-out
WithReplicas(3).WithTerminal()produces three terminal host processes named{parent}-terminalhost-0...{parent}-terminalhost-2, each brokering the PTY bytes for its replica. They are hidden resources - they don't show up as primary cards in the dashboard, but they are present in the resource graph for diagnostics.Wire protocol
We don't define a custom wire protocol. The terminal traffic uses Hex1b's HMP v1 (Hex Multiplex Protocol, version 1), which already handles:
Hmp1WorkloadAdapteris what the AppHost-side terminal host uses to multiplex DCP's PTY traffic to the consumer-facing listener.Hmp1PresentationAdapteris what consumers (Dashboard WebSocket proxy and the CLI) use to attach.Property contract (gRPC ResourceService snapshots)
When
WithTerminal()is applied, every replica snapshot emitted by the dashboard service carries four properties:terminal.enabled"true"when the replica has a PTY.terminal.replicaIndexDcpInstancesAnnotation.terminal.replicaCountterminal.consumerUdsPathThe consumer UDS path is marked sensitive so the dashboard UI masks the value in the property list. The browser never sees the path - the dashboard proxy resolves it server-side from
(resource, replica)pairs presented by the resource snapshot stream.Dashboard
/api/terminalWebSocket endpointAuthenticated (
RequireAuthorization(FrontendAuthorizationDefaults.PolicyName)) endpoint at/api/terminal?resource=<displayName>&replica=<index>.TerminalWebSocketProxyresolves the connection entirely server-side viaITerminalConnectionResolver, then runs two pumps over anHmp1WorkloadAdapter:Input; text frames -> JSON resize control ({"type":"resize","cols":N,"rows":N}).Frame type - not content - distinguishes keystrokes from control messages. The browser cannot induce the dashboard to connect to an arbitrary local socket; it can only request
(resource, replica)pairs already present in the snapshot stream.Packaging
Aspire.TerminalHostships as per-RID NuGet packages and is also rolled into the polyglot CLI bundle alongside the dashboard. The hosting layer resolves the binary path via:DcpPublisher:TerminalHostPathconfig (priority 1)ASPIRE_TERMINAL_HOST_PATHenv varaspireterminalhostpathassembly metadata (inner-loop dev)aspire-managed, reuse it with theterminalhostsubcommand. This keeps the polyglot bundle light (no second binary copy).Status (Aspire 13.4)
WithTerminal<T>(this IResourceBuilder<T>, Action<TerminalOptions>?)withTerminal/api/terminalWebSocket proxy and per-replicaTerminalViewaspire terminal psandaspire terminal attachCLI commandsMicrosoft.DeveloperControlPlane.*NuGet packages consumed by Aspire. Until then, the AppHost spawns the terminal host correctly but the bundled DCP (release/0.23, no terminal support) never dials the producer-uds. Local development can override viaDcpPublisher__CliPath.Files of interest
src/Aspire.Hosting/TerminalResourceBuilderExtensions.cssrc/Aspire.Hosting/ApplicationModel/TerminalHostResource.cssrc/Aspire.Hosting/Dcp/ExecutableCreator.csGetTerminalInfoAsyncsrc/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cssrc/Aspire.Hosting/Dashboard/DashboardServiceData.cssrc/Aspire.TerminalHost/src/Aspire.Cli/Commands/TerminalCommand.csattach/pssrc/Aspire.Cli/Commands/TerminalAttachCommand.cs,TerminalPsCommand.cssrc/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cssrc/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.csTerminalView(xterm.js host)src/Aspire.Dashboard/Components/Controls/TerminalView.razor.*src/Shared/Model/KnownProperties.cs(Terminal.*)playground/Terminals/Terminals.AppHost/AppHost.csdocs/specs/with-terminal.mdRelated