diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md
index 4f10f8b8b18..8ea33c9db5c 100644
--- a/docs/specs/with-terminal.md
+++ b/docs/specs/with-terminal.md
@@ -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 `
`); 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 [--replica N]` (`Aspire.Cli/Commands/TerminalCommand.cs`)
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs
index 3abf0122522..5bfaa2de180 100644
--- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs
+++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs
@@ -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;
///
/// Gets or sets the user-facing display name of the resource that owns the
@@ -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;
+ }
+
+ // 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;
}
@@ -103,7 +176,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
}
}
- private async Task InitializeTerminalAsync()
+ private async Task InitializeTerminalAsync(string resourceName, int replicaIndex)
{
try
{
@@ -112,12 +185,30 @@ private async Task InitializeTerminalAsync()
_selfRef ??= DotNetObjectReference.Create(this);
+ _connectedGeneration = -1;
_terminalId = await _jsModule.InvokeAsync(
- "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;
+ }
+ 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;
}
}
@@ -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;
}
@@ -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(
+ "reconnectTerminal",
+ _terminalId,
+ BuildWebSocketUrl(newResourceName, newReplicaIndex));
+ if (generation > 0)
+ {
+ _connectedGeneration = generation;
+ }
}
catch (JSDisconnectedException)
{
@@ -166,10 +265,7 @@ 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;
}
@@ -177,24 +273,27 @@ public Task OnTerminalStateChanged(TerminalToolbarState state)
return OnToolbarStateChanged.InvokeAsync(state);
}
- ///
- /// 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.
- ///
- 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;
}
///
@@ -279,6 +378,29 @@ 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.
+ ///
+ 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);
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js
index f7df0dedf5c..6532d69c540 100644
--- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js
+++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js
@@ -142,9 +142,14 @@ function cancelPendingReconnect(state) {
// 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 CSS-scale .xterm to fit our viewport (letterboxing on whichever
-// axis has spare room).
+// (.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;
@@ -311,11 +316,36 @@ 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.
+ */
+.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,
@@ -387,6 +417,20 @@ function buildChrome(state) {
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);
+ }
+
// 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.
@@ -410,7 +454,10 @@ function buildChrome(state) {
const titleText = document.createElement('span');
titleText.id = 'terminal-title';
titleText.textContent = 'terminal';
- titlebar.appendChild(titleText);
+ const dimsText = document.createElement('span');
+ dimsText.id = 'terminal-dims';
+ dimsText.textContent = '';
+ titlebar.append(titleText, dimsText);
const body = document.createElement('div');
body.id = 'terminal-body';
@@ -424,33 +471,70 @@ function buildChrome(state) {
state.terminalFrame = frame;
state.terminalTitlebar = titlebar;
state.titleText = titleText;
+ state.dimsText = dimsText;
state.terminalBody = body;
}
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 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}` : '';
}
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, stageW - FRAME_BORDER_PX * 2),
- height: Math.max(0, stageH - titlebarH - FRAME_BORDER_PX * 2),
+ width: Math.max(0, outerW - TERMINAL_BODY_PADDING_PX * 2),
+ height: Math.max(0, outerH - TERMINAL_BODY_PADDING_PX * 2),
};
}
// 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, then CSS
-// transform: scale() .xterm so the rendered grid fills available
-// space without distortion. Pin #terminal-body to the SCALED visible
-// bounds so the frame card hugs the content (no empty layout space
-// around the scaled grid). Letterboxing on whichever axis has spare
-// room (preserves aspect).
+// - 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
@@ -471,17 +555,29 @@ function applyRoleAwareLayout(state) {
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 { width: availableW, height: availableH } = getAvailableBodySpace(state);
+ const availableW = probeW;
+ const availableH = probeH;
if (!isSecondary) {
- // Primary, no-primary, or pre-handshake: clear any secondary
- // pinning on .xterm so it can flow naturally inside body.
+ // 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 = '';
@@ -493,6 +589,7 @@ function applyRoleAwareLayout(state) {
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) {
@@ -505,17 +602,23 @@ function applyRoleAwareLayout(state) {
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 available, fit() picks cols×rows.
- const bodyW = `${availableW}px`;
- const bodyH = `${availableH}px`;
+ // 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);
}
@@ -523,23 +626,68 @@ function applyRoleAwareLayout(state) {
return;
}
- // Secondary lock-and-scale.
- const needsResize = term.cols !== state.client.width || term.rows !== state.client.height;
- if (needsResize) {
- try { term.resize(state.client.width, state.client.height); } catch { /* ignore */ }
+ // 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);
}
-
- // If we just resized, defer measurement to next frame so the
- // renderer can write the new .xterm-screen dims first.
- if (needsResize) {
- requestAnimationFrame(() => {
- if (generation !== state.layoutGeneration) return;
- const fresh = getAvailableBodySpace(state);
- measureAndScale(state, fresh.width, fresh.height);
- });
- } else {
- measureAndScale(state, availableW, availableH);
+ 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);
+ notifyToolbar(state);
+ });
}
function pinBodyToNatural(state, root, body) {
@@ -551,8 +699,11 @@ function pinBodyToNatural(state, root, body) {
const w = screenEl.offsetWidth;
const h = screenEl.offsetHeight;
if (w > 0 && h > 0) {
- const bodyW = `${w}px`;
- const bodyH = `${h}px`;
+ // 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;
@@ -574,8 +725,33 @@ function calibrateRatios(state) {
const h = screenEl.offsetHeight;
const fs = term.options.fontSize || state.currentFontPx;
if (w > 0 && h > 0 && term.cols > 0 && term.rows > 0 && fs > 0) {
- state.cellWRatio = (w / term.cols) / fs;
- state.cellHRatio = (h / term.rows) / fs;
+ 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;
+ }
}
}
@@ -588,26 +764,78 @@ function computeOptimalFont(state, cols, rows, availW, availH) {
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 */ }
+}
+
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;
+ if (state.term) {
+ state.term.options.fontSize = state.currentFontPx;
+ forceFontRemeasure(state.term);
+ }
applyRoleAwareLayout(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');
+ }
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,
+ });
+ }
}
// Computes the current toolbar state snapshot and (when changed) pushes
@@ -696,59 +924,8 @@ function buildToolbarSnapshot(state) {
};
}
-function measureAndScale(state, availableW, availableH) {
- const term = state.term;
- if (!term || !state.client) return;
- const root = term.element;
- if (!root) return;
- const body = root.parentElement;
- if (!body) return;
-
- const screenEl =
- root.querySelector('.xterm-screen') ||
- root.querySelector('canvas.xterm-text-layer') ||
- root;
- const naturalWidth = screenEl.offsetWidth;
- const naturalHeight = screenEl.offsetHeight;
-
- if (naturalWidth <= 0 || naturalHeight <= 0 ||
- availableW <= 0 || availableH <= 0) {
- return;
- }
-
- const scale = Math.min(
- availableW / naturalWidth,
- availableH / naturalHeight);
-
- if (scale <= 0) return;
-
- const xtermTransform = `scale(${scale})`;
- const xtermW = `${naturalWidth}px`;
- const xtermH = `${naturalHeight}px`;
- if (root.style.transform !== xtermTransform ||
- root.style.width !== xtermW ||
- root.style.height !== xtermH) {
- root.style.transformOrigin = 'top left';
- root.style.transform = xtermTransform;
- root.style.width = xtermW;
- root.style.height = xtermH;
- }
-
- // Math.floor + clamp to availableW/H so we never produce a body 1px
- // wider than the stage from sub-pixel accumulation — a 1px overflow
- // re-triggers ResizeObserver in a tight loop and looks like the
- // terminal is bouncing.
- const bodyW = `${Math.min(availableW, Math.floor(naturalWidth * scale))}px`;
- const bodyH = `${Math.min(availableH, Math.floor(naturalHeight * scale))}px`;
- if (body.style.width !== bodyW || body.style.height !== bodyH) {
- body.style.width = bodyW;
- body.style.height = bodyH;
- }
-}
-
-// "Take control" handler. Clears any secondary lock-and-scale styling
-// then RequestPrimary at our current grid dims so the producer resizes
-// the PTY to match what we just laid out.
+// "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;
@@ -802,6 +979,12 @@ export async function initTerminal(element, wsUrl, dotNetRef) {
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,
@@ -816,6 +999,7 @@ export async function initTerminal(element, wsUrl, dotNetRef) {
terminalFrame: null,
terminalTitlebar: null,
titleText: null,
+ dimsText: null,
terminalBody: null,
};
@@ -896,6 +1080,7 @@ export async function initTerminal(element, wsUrl, dotNetRef) {
requestAnimationFrame(() => {
calibrateRatios(state);
applyRoleAwareLayout(state);
+ updateDimsReadout(state);
});
// OSC 0 / OSC 2 / OSC 1 — terminal apps push window/icon titles via
@@ -913,16 +1098,33 @@ export async function initTerminal(element, wsUrl, dotNetRef) {
// 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);
- calibrateRatios(state);
- notifyToolbar(state);
+ 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) {
- state.client.sendInput(textEncoder.encode(data));
- }
+ if (!state.client) return;
+ maybeAutoPromote(state);
+ state.client.sendInput(textEncoder.encode(data));
});
// Re-layout on container size change (window resize, sidebar collapse,
@@ -1068,8 +1270,8 @@ function connectClient(state, wsUrl) {
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-lock-and-scale to
- // the new dims.
+ // For secondaries this is the trigger to re-fit the frame to
+ // the new producer dims.
applyRoleAwareLayout(state);
};
@@ -1127,11 +1329,13 @@ function connectClient(state, wsUrl) {
scheduleReconnect(state);
}
}
+
+ return myGeneration;
}
export function reconnectTerminal(id, wsUrl) {
const state = terminals.get(id);
- if (!state) return;
+ if (!state) return 0;
dbg(state, 'reconnectTerminal (Razor explicit)', { wsUrl });
@@ -1139,7 +1343,7 @@ export function reconnectTerminal(id, wsUrl) {
// Reset the backoff so we connect immediately rather than waiting
// for the next pending auto-reconnect timer slot.
state.reconnect.attempts = 0;
- connectClient(state, wsUrl);
+ return connectClient(state, wsUrl);
}
export function disposeTerminal(id) {
@@ -1201,12 +1405,6 @@ export function getSizePresets() {
return SIZE_PRESETS.map((p) => ({ value: p.value, label: p.label, cols: p.cols, rows: p.rows }));
}
-export function takePrimaryFromHost(id) {
- const state = terminals.get(id);
- if (!state) return;
- takePrimary(state);
-}
-
export function setFontSizeFromHost(id, newSize) {
const state = terminals.get(id);
if (!state || typeof newSize !== 'number') return;
@@ -1264,3 +1462,16 @@ export function refreshToolbarState(id) {
state._lastToolbarJson = null;
flushToolbarState(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);
+}
diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor
index 149a31dc98a..2da6176b1fa 100644
--- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor
+++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor
@@ -8,7 +8,7 @@
@@ -18,7 +18,7 @@
MainContentStyle="margin-top: 10px;"
MobileToolbarButtonText="@Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsSelectResourceToolbar)]">
-
- @if (!_selectedResourceHasTerminal)
+ @if (_activeView == ConsoleLogsView.Console)
{
-
-
- @TerminalPrimaryButtonLabel(terminalState.Status)
-
-
- −
- @terminalState.FontPx
- +
-
- @if (_terminalSizePresets.Count > 0)
+ // This takes up too much horizontal space on mobile, so show on a new line on mobile
+ @if (_activeView == ConsoleLogsView.Console)
{
-
+ @PageViewModel.Status
}
- @if (terminalState.Cols > 0 && terminalState.Rows > 0)
+ @if (_activeView == ConsoleLogsView.Console || _selectedResourceHasTerminal)
{
-
- @terminalState.Cols × @terminalState.Rows
-
+ @if (_activeView == ConsoleLogsView.Console)
+ {
+
+ }
+
+
}
}
-
- @if (ViewportInformation.IsDesktop)
+ else if (_activeView == ConsoleLogsView.Console || _selectedResourceHasTerminal)
{
- // This takes up too much horizontal space on mobile, so show on a new line on mobile
- @if (!_selectedResourceHasTerminal)
+ @if (_activeView == ConsoleLogsView.Console)
{
- @PageViewModel.Status
-
-
-
}
- }
- else if (!_selectedResourceHasTerminal)
- {
-
- @if (!_selectedResourceHasTerminal)
+ @if (_activeView == ConsoleLogsView.Console)
{
@if (PageViewModel.SelectedResource?.Id is not null)
@@ -168,25 +132,46 @@
+ @* 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 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. *@
@if (_selectedResourceHasTerminal && _terminalResourceName is not null)
{
-
+
+
+
+
+
+
}
else
{
+ @ref="_logViewerRef"
+ LogEntries="@_logEntries"
+ FilterText="@_logFilter"
+ ShowTimestamp="@_showTimestamp"
+ IsTimestampUtc="@_isTimestampUtc"
+ NoWrapLogs="@_noWrapLogs"
+ ShowNoLogsMessage="@_showNoLogsMessage"
+ ShowResourcePrefix="@_isSubscribedToAll"/>
}
diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
index 2ef1e4ca277..3387351d047 100644
--- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
@@ -20,7 +20,6 @@
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
-using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
@@ -170,6 +169,22 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in
private Controls.TerminalToolbarState? _terminalToolbarState;
private IReadOnlyList _terminalSizePresets = Array.Empty();
+ // View toggle for terminal resources. The page surfaces both LogViewer
+ // and TerminalView in MainSection (both stay mounted so flipping does
+ // not tear down the PTY or the log subscription) and uses CSS to hide
+ // the inactive one. For non-terminal resources only LogViewer is shown
+ // and this field is unused. The view is purely user-controlled — the
+ // page defaults to Console on resource selection and only changes via
+ // the ⋯ menu picker. There is no auto-switching in either direction:
+ // hosting messages (WaitFor, startup failures, stop/exit output) stay
+ // visible on Console until the user explicitly clicks Terminal.
+ 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.
+ private ConsoleLogsView? _lastRenderedView;
+
// UI
private SelectViewModel _allResource = null!;
private AspirePageContentLayout? _contentLayout;
@@ -457,6 +472,21 @@ _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.
+ if (_selectedResourceHasTerminal &&
+ _activeView == ConsoleLogsView.Terminal &&
+ _lastRenderedView != ConsoleLogsView.Terminal &&
+ _terminalViewRef is { } terminalForLayout)
+ {
+ await terminalForLayout.RefreshLayoutAsync();
+ }
+
+ _lastRenderedView = _activeView;
}
private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceName)
@@ -472,6 +502,10 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa
// the wrong badge/dims/dropdown for the new resource while the JS
// terminal is initializing and pushing its first snapshot.
_terminalToolbarState = null;
+ // Default the view to Console on every resource change so pre-PTY
+ // hosting messages (WaitFor, startup failures) are visible immediately
+ // on selection. The user picks Terminal explicitly from the ⋯ menu.
+ _activeView = ConsoleLogsView.Console;
if (!isAllSelected && selectedResourceName is not null &&
_resourceByName.TryGetValue(selectedResourceName, out var selectedResource) &&
@@ -482,12 +516,13 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa
_terminalResourceName = selectedResource.DisplayName;
_terminalReplicaIndex = replicaIndex;
Logger.LogDebug("Resource '{ResourceName}' has terminal at replica {ReplicaIndex}", selectedResourceName, replicaIndex);
-
- // Don't subscribe to console logs for terminal resources —
- // the terminal view replaces the log viewer.
- await CancelAllSubscriptionsAsync();
- _isSubscribedToAll = false;
- return;
+ // Intentionally fall through to the normal subscription path so
+ // the resource's console log stream is collected even while the
+ // user is on the Terminal view. The Console view in the View
+ // dropdown shows these logs and they're needed for pre-PTY
+ // hosting messages (WaitFor, startup failures) and post-PTY
+ // exit messages — flipping to the Terminal view should never
+ // cause us to miss anything from the console stream.
}
// Cancel all existing subscriptions
@@ -524,6 +559,14 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa
{
StartNoLogsMessageDelay();
}
+
+ // Rebuild the options menu now that _selectedResourceHasTerminal
+ // reflects the new selection. The earlier UpdateMenuButtons() call
+ // in OnParametersSetAsync ran before this method updated the flag,
+ // so without this the menu keeps the previous resource's shape —
+ // e.g. the Console/Terminal view toggle would linger on a resource
+ // that has no WithTerminal(), and be missing on the reverse switch.
+ UpdateMenuButtons();
}
private bool IsAllSelected()
@@ -537,64 +580,142 @@ private void UpdateMenuButtons()
_logsMenuItems.Clear();
_resourceMenuItems.Clear();
- _logsMenuItems.Add(new()
- {
- IsDisabled = PageViewModel.SelectedResource is null && !_isSubscribedToAll,
- OnClick = DownloadLogsAsync,
- Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.DownloadLogs)],
- Icon = new Icons.Regular.Size16.ArrowDownload()
- });
+ var selectedResource = GetSelectedResource();
- _logsMenuItems.Add(new()
+ // View toggle (Console / Terminal): only meaningful for terminal-
+ // enabled resources; keeps the menu identical to today for the common
+ // no-terminal case.
+ if (_selectedResourceHasTerminal)
{
- IsDivider = true
- });
+ _logsMenuItems.Add(new()
+ {
+ OnClick = () => HandleViewChangedAsync(nameof(ConsoleLogsView.Console)),
+ Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewConsoleOption)],
+ });
- var selectedResource = GetSelectedResource();
+ _logsMenuItems.Add(new()
+ {
+ OnClick = () => HandleViewChangedAsync(nameof(ConsoleLogsView.Terminal)),
+ Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewTerminalOption)],
+ });
+
+ _logsMenuItems.Add(new()
+ {
+ IsDivider = true
+ });
+ }
- // Only show the "Hide hidden resources" menu item when viewing all resources
- // Use IsAllSelected() instead of _isSubscribedToAll because UpdateMenuButtons()
- // can be called before the subscription is established
- if (IsAllSelected())
+ if (_activeView == ConsoleLogsView.Terminal)
{
- CommonMenuItems.AddToggleHiddenResourcesMenuItem(
- _logsMenuItems,
- ControlsStringsLoc,
- _showHiddenResources,
- _resourceByName.Values,
- SessionStorage,
- EventCallback.Factory.Create(this, async
- value =>
+ // 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()
+ {
+ 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)
{
- _showHiddenResources = value;
- UpdateResourcesList();
- UpdateMenuButtons();
+ var value = preset.Value;
+ nested.Add(new()
+ {
+ OnClick = () => TerminalSizeChangedAsync(value),
+ Text = preset.Label,
+ IsDisabled = !sizeSelectEnabled,
+ });
+ }
- await this.RefreshIfMobileAsync(_contentLayout);
- }));
+ _logsMenuItems.Add(new()
+ {
+ Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSize)],
+ Icon = new Icons.Regular.Size16.ArrowExpand(),
+ NestedMenuItems = nested,
+ });
+ }
}
-
- _logsMenuItems.Add(new()
+ else
{
- OnClick = () => ToggleTimestampAsync(showTimestamp: !_showTimestamp, isTimestampUtc: _isTimestampUtc),
- Text = _showTimestamp ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampHide)] : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampShow)],
- Icon = new Icons.Regular.Size16.CalendarClock()
- });
+ // Console-view items: preserved from the original menu.
+ _logsMenuItems.Add(new()
+ {
+ IsDisabled = PageViewModel.SelectedResource is null && !_isSubscribedToAll,
+ OnClick = DownloadLogsAsync,
+ Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.DownloadLogs)],
+ Icon = new Icons.Regular.Size16.ArrowDownload()
+ });
- _logsMenuItems.Add(new()
- {
- OnClick = () => ToggleTimestampAsync(showTimestamp: _showTimestamp, isTimestampUtc: !_isTimestampUtc),
- Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampShowUtc)],
- Icon = _isTimestampUtc ? new Icons.Regular.Size16.CheckboxChecked() : new Icons.Regular.Size16.CheckboxUnchecked(),
- IsDisabled = !_showTimestamp
- });
+ _logsMenuItems.Add(new()
+ {
+ IsDivider = true
+ });
- _logsMenuItems.Add(new()
- {
- OnClick = () => ToggleWrapLogsAsync(noWrapLogs: !_noWrapLogs),
- Text = _noWrapLogs ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsWrapLogs)] : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNoWrapLogs)],
- Icon = _noWrapLogs ? new Icons.Regular.Size16.TextWrap() : new Icons.Regular.Size16.TextWrapOff()
- });
+ // Only show the "Hide hidden resources" menu item when viewing all resources
+ // Use IsAllSelected() instead of _isSubscribedToAll because UpdateMenuButtons()
+ // can be called before the subscription is established
+ if (IsAllSelected())
+ {
+ CommonMenuItems.AddToggleHiddenResourcesMenuItem(
+ _logsMenuItems,
+ ControlsStringsLoc,
+ _showHiddenResources,
+ _resourceByName.Values,
+ SessionStorage,
+ EventCallback.Factory.Create(this, async
+ value =>
+ {
+ _showHiddenResources = value;
+ UpdateResourcesList();
+ UpdateMenuButtons();
+
+ await this.RefreshIfMobileAsync(_contentLayout);
+ }));
+ }
+
+ _logsMenuItems.Add(new()
+ {
+ OnClick = () => ToggleTimestampAsync(showTimestamp: !_showTimestamp, isTimestampUtc: _isTimestampUtc),
+ Text = _showTimestamp ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampHide)] : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampShow)],
+ Icon = new Icons.Regular.Size16.CalendarClock()
+ });
+
+ _logsMenuItems.Add(new()
+ {
+ OnClick = () => ToggleTimestampAsync(showTimestamp: _showTimestamp, isTimestampUtc: !_isTimestampUtc),
+ Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampShowUtc)],
+ Icon = _isTimestampUtc ? new Icons.Regular.Size16.CheckboxChecked() : new Icons.Regular.Size16.CheckboxUnchecked(),
+ IsDisabled = !_showTimestamp
+ });
+
+ _logsMenuItems.Add(new()
+ {
+ OnClick = () => ToggleWrapLogsAsync(noWrapLogs: !_noWrapLogs),
+ Text = _noWrapLogs ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsWrapLogs)] : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNoWrapLogs)],
+ Icon = _noWrapLogs ? new Icons.Regular.Size16.TextWrap() : new Icons.Regular.Size16.TextWrapOff()
+ });
+ }
if (selectedResource != null)
{
@@ -1228,11 +1349,56 @@ private async Task OnTerminalToolbarStateChangedAsync(Controls.TerminalToolbarSt
.ToList();
}
+ UpdateMenuButtons();
+ StateHasChanged();
+ }
+
+ private Task HandleViewChangedAsync(string? newView)
+ {
+ if (newView is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ // Parse defensively so a bad enum value can't tear down the page.
+ // The menu-item click handlers pass nameof(...) literals today, but
+ // this indirection keeps the entry point safe if it grows a new
+ // caller.
+ if (!Enum.TryParse(newView, ignoreCase: true, out var parsed))
+ {
+ return Task.CompletedTask;
+ }
+
+ // Menu items for Terminal are only rendered when the current
+ // resource supports terminal (see UpdateMenuButtons — Terminal
+ // menu items are gated on _selectedResourceHasTerminal). But
+ // a Terminal click can still land here after the selection has
+ // moved to a non-terminal resource (e.g. the user clicked
+ // Terminal on the menu, then the resource-selector re-selected
+ // a shell-less resource before this handler ran). Switching to
+ // Terminal in that state hides the search/menu toolbar (the
+ // razor markup only renders it for Console view or
+ // terminal-enabled resources) and leaves the page in a broken
+ // state until the user selects another resource. Fall back to
+ // Console when the current selection can't host a terminal.
+ if (parsed == ConsoleLogsView.Terminal && !_selectedResourceHasTerminal)
+ {
+ parsed = ConsoleLogsView.Console;
+ }
+
+ _activeView = parsed;
+ UpdateMenuButtons();
StateHasChanged();
+ return Task.CompletedTask;
}
- private Task TerminalTakeControlAsync()
- => _terminalViewRef?.TakePrimaryAsync() ?? Task.CompletedTask;
+ // Test-only accessors. The view-toggle behavior is reachable from bUnit
+ // only by inspecting the internal state — the user-visible signal
+ // (display:none on a wrapper div) is awkward to assert against in
+ // bUnit. These mirror existing internal hooks (e.g. _logEntries) used
+ // by ConsoleLogsTests.
+ internal ConsoleLogsView ActiveViewForTest => _activeView;
+ internal Task HandleViewChangedForTestAsync(string? newView) => HandleViewChangedAsync(newView);
private Task TerminalFontMinusAsync()
{
@@ -1261,32 +1427,6 @@ private Task TerminalSizeChangedAsync(string? newKey)
return _terminalViewRef.SetSizeModeAsync(newKey);
}
- // Consolidated primary/take-control toggle. Appearance reflects the
- // current role (Accent = you are primary, Neutral = not), and the label
- // reflects the action available (or the current state when no action is
- // possible). Disabled state comes from CanTakeControl in the snapshot.
- private static Appearance TerminalPrimaryButtonAppearance(string status) => status switch
- {
- "primary" => Appearance.Accent,
- _ => Appearance.Neutral,
- };
-
- private string TerminalPrimaryButtonLabel(string status) => status switch
- {
- "primary" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarPrimaryLabel)],
- "connecting" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarConnectingLabel)],
- _ => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarTakeControlLabel)],
- };
-
- private string TerminalPrimaryButtonTitle(string status) => status switch
- {
- "primary" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarPrimaryTitle)],
- "no-primary" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarNoPrimaryTitle)],
- "viewer" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarViewerTitle)],
- "connecting" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarConnectingTitle)],
- _ => status,
- };
-
// IComponentWithTelemetry impl
public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.ConsoleLogs);
@@ -1296,4 +1436,17 @@ public void UpdateTelemetryProperties()
new ComponentTelemetryProperty(TelemetryPropertyKeys.ConsoleLogsShowTimestamp, new AspireTelemetryProperty(_showTimestamp, AspireTelemetryPropertyType.UserSetting))
], Logger);
}
+
+ ///
+ /// The two MainSection contents the page can show
+ /// for a resource that has WithTerminal() applied. Non-terminal
+ /// resources implicitly always show .
+ ///
+ public enum ConsoleLogsView
+ {
+ /// The resource's standard log stream (LogViewer).
+ Console,
+ /// The interactive xterm.js terminal (TerminalView).
+ Terminal,
+ }
}
diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs
index a36c9aedb85..ff19d46fa5b 100644
--- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs
+++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs
@@ -189,12 +189,6 @@ public static string TerminalToolbarDecreaseFontSize {
}
}
- public static string TerminalToolbarFontSize {
- get {
- return ResourceManager.GetString("TerminalToolbarFontSize", resourceCulture);
- }
- }
-
public static string TerminalToolbarIncreaseFontSize {
get {
return ResourceManager.GetString("TerminalToolbarIncreaseFontSize", resourceCulture);
@@ -213,63 +207,15 @@ public static string TerminalToolbarGridSizeAuto {
}
}
- public static string TerminalToolbarCurrentGrid {
- get {
- return ResourceManager.GetString("TerminalToolbarCurrentGrid", resourceCulture);
- }
- }
-
- public static string TerminalToolbarPrimaryLabel {
- get {
- return ResourceManager.GetString("TerminalToolbarPrimaryLabel", resourceCulture);
- }
- }
-
- public static string TerminalToolbarConnectingLabel {
- get {
- return ResourceManager.GetString("TerminalToolbarConnectingLabel", resourceCulture);
- }
- }
-
- public static string TerminalToolbarTakeControlLabel {
- get {
- return ResourceManager.GetString("TerminalToolbarTakeControlLabel", resourceCulture);
- }
- }
-
- public static string TerminalToolbarPrimaryTitle {
- get {
- return ResourceManager.GetString("TerminalToolbarPrimaryTitle", resourceCulture);
- }
- }
-
- public static string TerminalToolbarNoPrimaryTitle {
- get {
- return ResourceManager.GetString("TerminalToolbarNoPrimaryTitle", resourceCulture);
- }
- }
-
- public static string TerminalToolbarViewerTitle {
- get {
- return ResourceManager.GetString("TerminalToolbarViewerTitle", resourceCulture);
- }
- }
-
- public static string TerminalToolbarConnectingTitle {
- get {
- return ResourceManager.GetString("TerminalToolbarConnectingTitle", resourceCulture);
- }
- }
-
- public static string TerminalHeader {
+ public static string ConsoleLogsViewConsoleOption {
get {
- return ResourceManager.GetString("TerminalHeader", resourceCulture);
+ return ResourceManager.GetString("ConsoleLogsViewConsoleOption", resourceCulture);
}
}
- public static string TerminalPageTitle {
+ public static string ConsoleLogsViewTerminalOption {
get {
- return ResourceManager.GetString("TerminalPageTitle", resourceCulture);
+ return ResourceManager.GetString("ConsoleLogsViewTerminalOption", resourceCulture);
}
}
}
diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx
index 3bdba5face6..9288b6421cb 100644
--- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx
+++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx
@@ -192,47 +192,21 @@
Decrease font size
-
- Terminal font size
-
Increase font size
- Terminal grid size
+ Terminal dimensions
- Auto
-
-
- Current terminal grid
-
-
- Primary
-
-
- Connecting…
-
-
- Take control
-
-
- This tab owns primary input on the terminal session.
+ Fit
-
- No client currently owns primary input. Click to take it.
-
-
- Another client owns primary input. Click to take control.
-
-
- Connecting to the terminal session…
+
+ Console logs
+ Option in the View dropdown that shows the resource's console logs.
-
+
Terminal
-
-
- {0} terminal
- {0} is an application name
+ Option in the View dropdown that shows the resource's interactive terminal.
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf
index 60bd4f5b1fd..3dd7a86b9ee 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf
@@ -97,6 +97,16 @@
Neznámý stav
+
+ Console logs
+ Console logs
+ Option in the View dropdown that shows the resource's console logs.
+
+
+ Terminal
+ Terminal
+ Option in the View dropdown that shows the resource's interactive terminal.
+ Watching logs...Sledují se protokoly...
@@ -117,49 +127,19 @@
Stav protokolu služby
-
- Terminal
- Terminal
-
-
-
- {0} terminal
- {0} terminal
- {0} is an application name
-
-
- Connecting…
- Connecting…
-
-
-
- Connecting to the terminal session…
- Connecting to the terminal session…
-
-
-
- Current terminal grid
- Current terminal grid
-
- Decrease font sizeDecrease font size
-
- Terminal font size
- Terminal font size
-
-
- Terminal grid size
- Terminal grid size
+ Terminal dimensions
+ Terminal dimensions
- Auto
- Auto
+ Fit
+ Fit
@@ -167,31 +147,6 @@
Increase font size
-
- No client currently owns primary input. Click to take it.
- No client currently owns primary input. Click to take it.
-
-
-
- Primary
- Primary
-
-
-
- This tab owns primary input on the terminal session.
- This tab owns primary input on the terminal session.
-
-
-
- Take control
- Take control
-
-
-
- Another client owns primary input. Click to take control.
- Another client owns primary input. Click to take control.
-
-