Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
26 changes: 26 additions & 0 deletions docs/specs/with-terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,32 @@ 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 **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 view is purely user-controlled: the page never auto-switches
between Console and Terminal. The user picks the view from the ⋯
menu and the page stays on that view until they pick the other one,
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 `<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
168 changes: 145 additions & 23 deletions src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable
private int _terminalId;
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
// 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
// to the currently bound connection or to a superseded one. Any callback
// whose generation is below _connectedGeneration is stale and dropped.
private int _connectedGeneration = -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 Down Expand Up @@ -60,9 +81,61 @@ protected override async Task OnAfterRenderAsync(bool firstRender)

if (firstRender)
{
await InitializeTerminalAsync();
_connectedResourceName = ResourceName;
_connectedReplicaIndex = ReplicaIndex;
_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.
if (_terminalId != 0)
{
_connectedResourceName = initResource;
_connectedReplicaIndex = initReplica;
}

if (!string.Equals(ResourceName, _connectedResourceName, StringComparison.Ordinal) ||
ReplicaIndex != _connectedReplicaIndex)
{
var newResource = ResourceName;
var newReplica = ReplicaIndex;
try
{
await ReconnectAsync(newResource, newReplica);
}
catch (JSDisconnectedException)
{
return;
}
catch (Exception)
{
return;
}

_connectedResourceName = newResource;
_connectedReplicaIndex = newReplica;
}
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;
}

Expand Down Expand Up @@ -103,7 +176,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
}
}

private async Task InitializeTerminalAsync()
private async Task InitializeTerminalAsync(string resourceName, int replicaIndex)
{
try
{
Expand All @@ -112,12 +185,30 @@ private async Task InitializeTerminalAsync()

_selfRef ??= DotNetObjectReference.Create(this);

_connectedGeneration = -1;
_terminalId = await _jsModule.InvokeAsync<int>(
"initTerminal", _terminalElement, BuildWebSocketUrl(ResourceName!, ReplicaIndex), _selfRef);
"initTerminal", _terminalElement, BuildWebSocketUrl(resourceName, replicaIndex), _selfRef);
}
catch (JSDisconnectedException)
{
// Component disposed during initialization
// Component disposed during initialization. Clear _initStarted so
// that if a later render *does* fire (e.g. reconnection scenarios
// that re-mount the JS module), OnAfterRenderAsync's
// `_initStarted && _terminalId == 0` short-circuit doesn't
// permanently wedge us with no terminal.
_initStarted = false;
}
Comment thread
mitchdenny marked this conversation as resolved.
catch (Exception)
{
// Defensive: any other JS-side error (e.g. JSException while
// 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.
_initStarted = false;
}
}

Expand All @@ -133,7 +224,7 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex)
ReplicaIndex = newReplicaIndex;
if (!string.IsNullOrEmpty(newResourceName))
{
await InitializeTerminalAsync();
await InitializeTerminalAsync(newResourceName, newReplicaIndex);
}
return;
}
Expand All @@ -144,12 +235,20 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex)
{
await _jsModule.InvokeVoidAsync("disposeTerminal", _terminalId);
_terminalId = 0;
_connectedGeneration = -1;
return;
}

ResourceName = newResourceName;
ReplicaIndex = newReplicaIndex;
await _jsModule.InvokeVoidAsync("reconnectTerminal", _terminalId, BuildWebSocketUrl(newResourceName, newReplicaIndex));
var generation = await _jsModule.InvokeAsync<int>(
"reconnectTerminal",
_terminalId,
BuildWebSocketUrl(newResourceName, newReplicaIndex));
if (generation > 0)
{
_connectedGeneration = generation;
}
}
catch (JSDisconnectedException)
{
Expand All @@ -166,35 +265,35 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex)
[JSInvokable]
public Task OnTerminalStateChanged(TerminalToolbarState state)
{
// Drop stale snapshots that arrive after this view was rebound to a
// different resource/replica (the JS side bumps `generation` on every
// (re)connect; the id changes when initTerminal allocates a new one).
if (_terminalId != 0 && state.TerminalId != _terminalId)
if (IsStaleTerminalCallback(state.TerminalId, state.Generation))
{
return Task.CompletedTask;
}

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()
private bool IsStaleTerminalCallback(int terminalId, int generation)
{
if (_jsModule is null || _terminalId == 0)
// Drop stale callbacks that arrive after this view was rebound. The
// terminal id changes when initTerminal allocates a new xterm host;
// explicit reconnect keeps the id but bumps the JS-side generation.
if (_terminalId != 0 && terminalId != _terminalId)
{
return;
return true;
}
try

if (generation < _connectedGeneration)
{
await _jsModule.InvokeVoidAsync("takePrimaryFromHost", _terminalId);
return true;
}
catch (JSDisconnectedException)

if (generation > _connectedGeneration)
{
_connectedGeneration = generation;
}

return false;
}

/// <summary>
Expand Down Expand Up @@ -279,6 +378,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
Loading
Loading