Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9b4cbdf
Add Console/Terminal view toggle to ConsoleLogs page
mitchdenny Jun 30, 2026
119d90d
Rename Console option to 'Console logs' in view picker
mitchdenny Jun 30, 2026
f249396
Fix terminal view duplication and auto-switch on resource restart
mitchdenny Jun 30, 2026
1434be4
Flip back to Console when a terminal resource is manually stopped
mitchdenny Jun 30, 2026
20a86e5
Suppress auto-switch back to Terminal while resource is stopped
mitchdenny Jun 30, 2026
c31f24c
Fix text selection offset in secondary TerminalView mode
mitchdenny Jul 1, 2026
647072f
Refine terminal font after first-render calibration
mitchdenny Jul 1, 2026
f2fec8f
Merge remote-tracking branch 'origin/main' into mitchdenny-console-te…
mitchdenny Jul 1, 2026
03ed1a2
Address PR feedback: dispatcher-safe StateHasChanged, nest ConsoleLog…
mitchdenny Jul 1, 2026
c81effd
Document manual-Stop auto-switch trigger in with-terminal spec
mitchdenny Jul 2, 2026
7a5cbef
Address Adam's feedback: dispatcher-safe stopped edge, exit reattach …
mitchdenny Jul 2, 2026
c28ca82
Simplify terminal toolbar: labelled font/dimensions dropdowns, right-…
mitchdenny Jul 2, 2026
445845c
Refit terminal after font-size change so cols×rows adjust
mitchdenny Jul 2, 2026
1857909
Revert to font +/- buttons and drop broken RAF re-fit
mitchdenny Jul 2, 2026
e94af21
Shorten Fit label and inline live dims into dimensions dropdown
mitchdenny Jul 2, 2026
e88e9a3
Collapse terminal toolbar controls into the settings menu
mitchdenny Jul 2, 2026
0f9fdb6
Show Fit's predicted dimensions in Terminal dimensions menu
mitchdenny Jul 2, 2026
d31ec0d
Improve icons in terminal options menu
mitchdenny Jul 2, 2026
0887485
Skip terminal layout when container is display:none
mitchdenny Jul 2, 2026
a17f5c9
Address PR feedback: remove dead resource, fix stale comments
mitchdenny Jul 2, 2026
a3e3775
Rebuild options menu after subscription switch
mitchdenny Jul 2, 2026
2b16a2f
Merge remote-tracking branch 'origin/main' into mitchdenny-console-te…
mitchdenny Jul 2, 2026
252eb33
Keep 'Console logs' as page title and header in Terminal view
mitchdenny Jul 2, 2026
ebc76a6
Address race-condition feedback from PR review
mitchdenny Jul 3, 2026
e410159
Fix dashboard terminal view races
adamint Jul 3, 2026
249bebb
Show checkmark on active Console/Terminal view menu item
mitchdenny Jul 3, 2026
713d37a
Remove Console/Terminal auto-switching
mitchdenny Jul 3, 2026
c4b0040
Remove dead terminal exit callback plumbing
mitchdenny Jul 3, 2026
2bf7a52
Drop checkmark indicators from menu items and pad terminal body
mitchdenny Jul 3, 2026
b1ecaec
Show live cols x rows in terminal titlebar
mitchdenny Jul 3, 2026
8f8774a
Fix Fit dimensions preview drift after resize
mitchdenny Jul 3, 2026
78b1146
Address Copilot review feedback
mitchdenny Jul 3, 2026
b866005
Fix Fit menu preview using fitAddon.proposeDimensions()
mitchdenny Jul 3, 2026
3f8350f
Fit preview: use fitFontPx ratio math, not proposeDimensions
mitchdenny Jul 3, 2026
37be98f
Remove dead TerminalToolbarCurrentGrid resource
mitchdenny Jul 3, 2026
e3ab44f
Reset currentFontPx to fitFontPx when entering Fit mode
mitchdenny Jul 3, 2026
25e7551
TEMP: diagnostic logging for Fit selection
mitchdenny Jul 3, 2026
2651c68
Force xterm cell re-measure on every font-size change
mitchdenny Jul 3, 2026
7372e0a
TEMP: verbose diag around setSizeMode + safeFit
mitchdenny Jul 3, 2026
f6b3e28
Address Copilot review feedback
mitchdenny Jul 3, 2026
22d8994
Reject stale calibrateRatios samples that swing >40%
mitchdenny Jul 3, 2026
9887b3a
Guard HandleViewChangedAsync against non-terminal resources
mitchdenny Jul 3, 2026
031a52f
Make Fit preview reflect real fit() output, not ratio math
mitchdenny Jul 3, 2026
266e0e6
Remove unused TerminalToolbarFontSize resource + fix line ref
mitchdenny Jul 3, 2026
59f9cb9
Remove Fit preview dimensions from terminal size menu
mitchdenny Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/specs/with-terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,51 @@ to connect to an arbitrary local socket — it can only ask for
`(resource, replica)` pairs that are present in the resource snapshot
stream.

### 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
flips between them via a pair of checkable **Console logs** / **Terminal**
items rendered inside the toolbar's options (⋯) `AspireMenuButton`:

- The page defaults to **Console** on resource selection so any pre-PTY
hosting messages — `WaitFor` notifications, startup failures, image
pull progress — are visible immediately.
- The page **auto-switches to Terminal** the first time the JS terminal
reports a non-`connecting` toolbar status (PTY attached). Reconnects do
not re-trigger the auto-switch.
- The page **auto-switches back to Console** on either of two triggers,
so the user sees the final log lines including the hosting exit
message:
- The JS terminal raises `client.onExit` (PTY exited cleanly from the
guest side, e.g. `exit` at the shell).
- `OnResourceChanged` observes the resource snapshot transitioning
from a running state to a stopped/finished state (e.g. the user
presses **Stop** in the dashboard or the resource crashes before
the PTY reports exit). This path deliberately does **not** synthesize
a `connecting` toolbar status to re-arm the attach edge — a stale
in-flight `primary` snapshot arriving after we flip to Console
would then look like a fresh attach and yank the user back to
Terminal. Instead the `_selectedTerminalResourceStopped` gate
suppresses the auto-switch until the resource leaves the stopped
state, and the genuine WebSocket close → open on restart drives a
real `connecting → connected` edge which the auto-switch picks up.
- Once the user manually picks a view from the menu the page
**latches** their choice for the rest of that resource's session and
ignores subsequent auto-switch triggers. The latch resets when a
different resource is selected.
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
- Both views stay mounted across flips (visibility is toggled with
`display:none` on a wrapper `<div>`); the log subscription and the
xterm/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.

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 <resource> [--replica N]` (`Aspire.Cli/Commands/TerminalCommand.cs`)
Expand Down
123 changes: 103 additions & 20 deletions src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable
private int _terminalId;
private string? _connectedResourceName;
private int _connectedReplicaIndex = -1;
// Guards against concurrent or re-entrant initialization. OnAfterRenderAsync
// can fire again while the first InitializeTerminalAsync await is still in
// flight (Blazor does not serialize OnAfterRenderAsync calls when re-renders
// happen during awaits). Without this latch, the non-firstRender branch
// 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
// 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;

/// <summary>
/// Gets or sets the user-facing display name of the resource that owns the
Expand All @@ -45,6 +58,16 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable
[Parameter]
public EventCallback<TerminalToolbarState> OnToolbarStateChanged { get; set; }

/// <summary>
/// Raised when the JS side reports that the workload (PTY) has exited.
/// The host page subscribes so it can auto-switch the view back to the
/// resource's console logs — the workload has stopped producing
/// terminal bytes and the final exit code / hosting messages live in
/// the console log stream.
/// </summary>
[Parameter]
public EventCallback<TerminalExitInfo> OnExited { get; set; }

[Inject]
public required IJSRuntime JS { get; init; }

Expand All @@ -60,12 +83,25 @@ protected override async Task OnAfterRenderAsync(bool firstRender)

if (firstRender)
{
_initStarted = true;
await InitializeTerminalAsync();
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
_connectedResourceName = ResourceName;
_connectedReplicaIndex = ReplicaIndex;
return;
Comment thread
mitchdenny marked this conversation as resolved.
}

// 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.
if (_initStarted && _terminalId == 0)
{
return;
}

// 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
Expand Down Expand Up @@ -157,6 +193,26 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex)
}
}

/// <summary>
/// Invoked by the JS terminal when the workload (PTY) exits. The JS side
/// also writes a "[workload exited with code N]" line into the xterm
/// buffer; this callback exists so the host page can react beyond the
/// in-terminal message (e.g. flip the visible view back to console logs).
/// </summary>
[JSInvokable]
public Task OnTerminalExited(int terminalId, int exitCode)
{
// Drop stale notifications that arrive after this view was rebound to
// a different resource/replica — the previous JS terminal id is no
// longer relevant to the currently displayed resource.
if (_terminalId != 0 && terminalId != _terminalId)
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
{
return Task.CompletedTask;
}

return OnExited.InvokeAsync(new TerminalExitInfo { TerminalId = terminalId, ExitCode = exitCode });
}

/// <summary>
/// Invoked by the JS terminal whenever its role/size/font state changes.
/// Forwards the snapshot to the host page via <see cref="OnToolbarStateChanged"/>.
Expand All @@ -177,26 +233,6 @@ public Task OnTerminalStateChanged(TerminalToolbarState state)
return OnToolbarStateChanged.InvokeAsync(state);
}

/// <summary>
/// Requests primary control of the producer session. No-op if the terminal
/// JS module hasn't initialized yet or if we're already primary; JS
/// performs the authoritative role checks.
/// </summary>
public async Task TakePrimaryAsync()
{
if (_jsModule is null || _terminalId == 0)
{
return;
}
try
{
await _jsModule.InvokeVoidAsync("takePrimaryFromHost", _terminalId);
}
catch (JSDisconnectedException)
{
}
}

/// <summary>
/// Sets the terminal font size and switches sizing back to "Auto" (font-
/// driven) mode. Out-of-range values are clamped by the JS side.
Expand Down Expand Up @@ -279,6 +315,29 @@ public async Task RefreshToolbarStateAsync()
}
}

/// <summary>
/// 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.
/// </summary>
public async Task RefreshLayoutAsync()
{
if (_jsModule is null || _terminalId == 0)
{
return;
}
try
{
await _jsModule.InvokeVoidAsync("refreshLayout", _terminalId);
}
catch (JSDisconnectedException)
{
}
}

private string BuildWebSocketUrl(string resource, int replica)
{
var baseUri = new Uri(NavigationManager.BaseUri);
Expand Down Expand Up @@ -357,10 +416,34 @@ public sealed record TerminalToolbarState

/// <summary>Current xterm grid height.</summary>
public int Rows { get; init; }

/// <summary>
/// Cols the terminal would take on if switched to Fit mode right now,
/// computed from the container size and the last font-driven font. 0
/// when calibration hasn't produced cell metrics yet. Distinct from
/// <see cref="Cols"/>, which reflects the actual current grid (which
/// may be locked by a fixed preset).
/// </summary>
public int FitCols { get; init; }

/// <summary>Rows counterpart to <see cref="FitCols"/>.</summary>
public int FitRows { get; init; }
}

/// <summary>
/// A named size preset surfaced by the JS terminal (used to populate the
/// host page's size dropdown).
/// </summary>
public sealed record TerminalSizePreset(string Value, string Label, int Cols, int Rows);

/// <summary>
/// Payload pushed up from the JS terminal when the workload (PTY) exits.
/// </summary>
public sealed record TerminalExitInfo
{
/// <summary>The JS-side terminal id that emitted the exit notification.</summary>
public int TerminalId { get; init; }

/// <summary>The workload's exit code, or <c>-1</c> when the JS side did not receive one.</summary>
public int ExitCode { get; init; }
}
Loading
Loading