// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Hex1b;
using Hex1b.Input;
using Hex1b.Theming;
using Hex1b.Widgets;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Tui;
/// <summary>
/// TUI shell for <c>aspire terminal attach</c>. Hosts an embedded
/// <see cref="Hex1bTerminal"/> wired to the resource's HMP v1 consumer
/// UDS endpoint and overlays an InfoBar with role/peer/dimension
/// information plus tmux-style chord hotkeys for taking control or
/// detaching.
/// </summary>
/// <remarks>
/// <para>
/// Lifted from Hex1b 0.147.0's <c>WebMuxerDemo.Cli.CliViewerApp</c> and
/// adapted for Aspire conventions (ILogger plumbing for diagnostics, an
/// explicit <c>viewerOnly</c> flag that controls the initial role on
/// connect, and a different InfoBar label set).
/// </para>
/// <para>
/// Renders three modes:
/// <list type="bullet">
/// <item>
/// <c>primary</c> — we hold the role; embed the inner terminal full-
/// screen because the producer's PTY tracks our host dims.
/// </item>
/// <item>
/// <c>viewer-fit</c> — host >= producer dims; embed the inner terminal
/// so the user can see what the primary is doing (read-only).
/// </item>
/// <item>
/// <c>viewer-too-small</c> — host < producer dims; show a centered
/// "doesn't fit" panel offering to take control.
/// </item>
/// </list>
/// Hotkeys use a tmux-style chord prefix (<c>Ctrl+B</c>) to avoid
/// clashing with normal input forwarded to the embedded terminal in
/// primary mode (e.g., Ctrl+C must reach the workload as SIGINT).
/// </para>
/// <para>
/// This class is the textbook "easy path" consumer of Hex1b's HMP1
/// builder extensions: it never types <c>Hmp1WorkloadAdapter</c>. The
/// embedded terminal is constructed via
/// <see cref="Hmp1BuilderExtensions.WithHmp1UdsClient(Hex1bTerminalBuilder, string, Action{Hmp1ClientOptions}?)"/>
/// and the <see cref="IHmp1ConnectionHandle"/> is captured in the
/// <see cref="Hmp1ClientOptions.OnConnected"/> callback.
/// </para>
/// </remarks>
internal sealed class TerminalViewerApp
{
// Panel background colour matching the dashboard's terminal CSS
// --aspire-term-panel variable (#161b22). When the producer's grid
// is smaller than the host TTY, the surrounding framing area is
// filled with this colour so the terminal's edges are visible
// against a contrasting backdrop — same visual idiom as the
// dashboard's terminal card.
private static readonly Hex1bColor s_panelColor = Hex1bColor.FromRgb(0x16, 0x1b, 0x22);
// Terminal background, matching the dashboard's --aspire-term-bg
// CSS variable (#0d1117). Applied to TerminalWidget so cells with
// default-bg own their surface; without it the surrounding
// PanelColor bleeds through every blank cell, making the terminal
// indistinguishable from its frame.
private static readonly Hex1bColor s_terminalBackground = Hex1bColor.FromRgb(0x0d, 0x11, 0x17);
private readonly string _socketPath;
private readonly string _sessionLabel;
private readonly string _displayName;
private readonly bool _viewerOnly;
private readonly ILogger _logger;
// Captured in OnConnected once the HMP1 handshake completes. Until
// then the app renders a "Connecting…" placeholder. Subsequent
// events / hotkey actions guard on null too, so a mid-session
// disconnect doesn't NRE.
private IHmp1ConnectionHandle? _connection;
private Hex1bApp? _app;
private Hex1bTerminal? _embedded;
private TerminalWidgetHandle? _handle;
private CancellationTokenSource? _embeddedCts;
// Cancels the outer Hex1bApp when the embedded HMP1 transport
// closes (clean disconnect or fault). Without this the outer app
// would sit in its read loop until the user hits Ctrl+B D.
private CancellationTokenSource? _outerCts;
// Captured if the embedded terminal's RunAsync faults during
// handshake (typical: connection refused, handshake timeout). We
// surface it from RunAsync so the caller can print a clean error
// message instead of leaving the exception unobserved.
private Exception? _embeddedFault;
// Flipped to 1 the moment the user invokes Detach (Ctrl+B D ->
// _app.RequestStop()) so the post-finally rethrow can suppress
// teardown-induced faults that aren't the *cause* of shutdown. On
// a clean detach the embedded RunAsync typically completes with a
// torn-transport SocketException/IOException as the consumer UDS
// is closed, which would otherwise surface to the user as a
// misleading "Could not connect to terminal session" error.
private int _userDetachRequested;
// Locally-tracked inner terminal dimensions. Hex1bTerminal doesn't
// expose its current grid size, so we track it here. Updated
// whenever we resize the inner terminal in response to RoleChanged
// or RemoteResized.
private int _innerWidth;
private int _innerHeight;
// Last host TTY dims we broadcast to the producer while we held
// primary. Used to detect host SIGWINCH (Windows Terminal resize,
// tmux pane resize, etc.) and re-broadcast the new dims so the
// producer's PTY follows. -1 means "no broadcast in flight"; reset
// whenever we lose the role.
private int _lastBroadcastWidth = -1;
private int _lastBroadcastHeight = -1;
// Single-flight gate so SIGWINCH bursts (typical from a mouse
// drag-resize) collapse to one in-flight RequestPrimaryAsync at a
// time. We always remember the most recent target and re-broadcast
// on the next render if the target moved while we were waiting.
private int _resizeInFlight; // 0 = idle, 1 = a request is in flight
public TerminalViewerApp(string socketPath, string sessionLabel, string displayName, bool viewerOnly, ILogger logger)
{
_socketPath = socketPath;
_sessionLabel = sessionLabel;
_displayName = displayName;
_viewerOnly = viewerOnly;
_logger = logger;
}
public async Task<int> RunAsync(CancellationToken cancellationToken)
{
// Embedded inner terminal that consumes the HMP1 byte stream.
// We use the easy-path WithHmp1UdsClient builder extension; the
// workload adapter is constructed internally and wired up to
// the terminal's pump. Initial dimensions are an arbitrary
// opener (80x24) — the embedded terminal supports dynamic
// Resize() at runtime, so OnConnected snaps it to the
// producer's actual grid the moment the handshake completes.
_embedded = Hex1bTerminal.CreateBuilder()
.WithDimensions(80, 24)
.WithHmp1UdsClient(_socketPath, opts =>
{
opts.DisplayName = _displayName;
opts.DefaultRole = _viewerOnly ? Hmp1Role.Secondary : Hmp1Role.Primary;
opts.OnConnected = async (e, ct) =>
{
_connection = e.Connection;
EnsureInnerSize(e.Width, e.Height);
_logger.LogDebug(
"Multi-head Connected: peerId={PeerId} primary={PrimaryPeerId} dims={Width}x{Height} peers={PeerCount}",
e.PeerId, e.PrimaryPeerId, e.Width, e.Height, e.Peers.Count);
// Backwards-compatible single-head behaviour: when
// the user did NOT pass --viewer, immediately
// request primary at the host TTY dims so the
// producer's PTY snaps to our terminal. Skipped in
// viewer mode — the user can always promote later
// via Ctrl+B T from the InfoBar.
if (!_viewerOnly)
{
var (cols, rows) = TryGetLocalDimensions();
try
{
await e.Connection.RequestPrimaryAsync(cols, rows, ct).ConfigureAwait(false);
_lastBroadcastWidth = cols;
_lastBroadcastHeight = rows;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Multi-head RequestPrimary failed; remaining as secondary.");
}
}
_app?.Invalidate();
};
opts.OnRoleChanged = (e, _) => { OnRoleChanged(e); return Task.CompletedTask; };
opts.OnRemoteResized = (e, _) => { OnRemoteResized(e); return Task.CompletedTask; };
opts.OnPeerJoined = (e, _) =>
{
_logger.LogDebug("Multi-head PeerJoined: peerId={PeerId} displayName={DisplayName}", e.PeerId, e.DisplayName);
_app?.Invalidate();
return Task.CompletedTask;
};
opts.OnPeerLeft = (e, _) =>
{
_logger.LogDebug("Multi-head PeerLeft: peerId={PeerId}", e.PeerId);
_app?.Invalidate();
return Task.CompletedTask;
};
opts.OnDisconnected = _ => { OnDisconnected(); return Task.CompletedTask; };
})
.WithScrollback()
.WithTerminalWidget(out var handle)
.Build();
_handle = handle;
_embeddedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var embeddedTask = _embedded.RunAsync(_embeddedCts.Token);
// Observe the embedded terminal for faults so handshake
// failures (socket connect refused, ClientHello write failure,
// malformed server Hello) cancel the outer app and bubble out
// via _embeddedFault rather than disappearing as an unobserved
// task exception. Without this observer, a connection failure
// would strand the user inside the alt-screen TUI with no
// producer bytes ever arriving.
_ = embeddedTask.ContinueWith(t =>
{
if (t.IsFaulted)
{
_embeddedFault = t.Exception?.GetBaseException();
}
try { _outerCts?.Cancel(); } catch { /* ignore */ }
}, TaskScheduler.Default);
_outerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
await using var outer = Hex1bTerminal.CreateBuilder()
.WithMouse()
.WithHex1bApp(_ => { }, (Hex1bApp app) =>
{
_app = app;
return (Func<RootContext, Hex1bWidget>)(ctx => Render(ctx));
})
.Build();
try
{
await outer.RunAsync(_outerCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (_outerCts.IsCancellationRequested)
{
// Triggered by the embedded-task observer when the
// workload disconnects or faults, or by the caller's
// cancellation token. Swallow here; the post-finally
// rethrow surfaces _embeddedFault if any, and the
// top-level catch in TerminalAttachCommand handles
// user-cancellation cleanly.
_logger.LogDebug(
"Outer Hex1bApp cancelled (embeddedFaulted={EmbeddedFaulted}, callerRequested={CallerRequested}).",
_embeddedFault is not null,
cancellationToken.IsCancellationRequested);
}
}
finally
{
try
{
if (_embeddedCts is not null)
{
await _embeddedCts.CancelAsync().ConfigureAwait(false);
_embeddedCts.Dispose();
}
}
catch (Exception ex)
{
// Cancel/Dispose on a CTS shouldn't normally throw, but a
// concurrent dispose from another teardown path could
// surface ObjectDisposedException. Log so we can spot
// teardown ordering bugs without breaking the outer flow.
_logger.LogDebug(ex, "Embedded CTS teardown failed ({ExceptionType}).", ex.GetType().FullName);
}
try
{
if (_embedded is not null)
{
var disposeTask = _embedded.DisposeAsync().AsTask();
var timeout = Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None);
await Task.WhenAny(disposeTask, timeout).ConfigureAwait(false);
}
}
catch (Exception ex)
{
// Surface failures from Hex1bTerminal.DisposeAsync (transport
// already-disposed races, pump observation faults). Logged
// at debug because the 2s timeout above also masks "stuck
// dispose" cases that we don't want to surface as errors.
_logger.LogDebug(ex, "Embedded terminal dispose failed ({ExceptionType}).", ex.GetType().FullName);
}
_outerCts?.Dispose();
}
// Surface a handshake / transport failure so the caller can
// translate it into a clean stderr message after the alt
// screen has been restored. SocketException, IOException, and
// OperationCanceledException are the typical shapes; the
// top-level TerminalAttachCommand catches each.
//
// Skip the rethrow when the user invoked Detach (Ctrl+B D) -
// tearing down the embedded transport from the outer-app
// shutdown commonly faults the embedded RunAsync with a
// SocketException/IOException, which is *not* what the user
// saw and would surface as a misleading "Could not connect"
// error.
if (_embeddedFault is not null && Volatile.Read(ref _userDetachRequested) == 0)
{
throw _embeddedFault;
}
return CliExitCodes.Success;
}
private void OnRoleChanged(RoleChangedEventArgs e)
{
_logger.LogDebug(
"Multi-head RoleChanged: primary={PrimaryPeerId} dims={Width}x{Height} reason={Reason} previously={Previously} now={Now}",
e.PrimaryPeerId, e.Width, e.Height, e.Reason, e.PreviouslyPrimary, e.NowPrimary);
// RoleChange always carries the current dims. Resize the inner
// terminal to match; this is the cleanest signal we get for
// "producer's PTY is now N x M".
EnsureInnerSize(e.Width, e.Height);
// If we no longer hold the primary role, drop the broadcast
// tracker so a future re-take starts from scratch and
// immediately resyncs.
if (_connection is { IsPrimary: false })
{
_lastBroadcastWidth = -1;
_lastBroadcastHeight = -1;
}
_app?.Invalidate();
}
private void OnRemoteResized(RemoteResizedEventArgs e)
{
// Producer's PTY just changed dims (either we requested it as
// primary or another peer is driving it). Resize the embedded
// terminal and re-render so viewer-fit / doesn't-fit recomputes.
EnsureInnerSize(e.Width, e.Height);
_app?.Invalidate();
}
private void OnDisconnected()
{
// Producer hung up. Cancel the outer app so RunAsync returns
// cleanly; without this the user would have to hit Ctrl+B D
// even though there's nothing left to view.
try { _outerCts?.Cancel(); } catch { /* ignore */ }
}
private void EnsureInnerSize(int width, int height)
{
var w = Math.Max(1, width);
var h = Math.Max(1, height);
if (_innerWidth == w && _innerHeight == h)
{
return;
}
_embedded?.Resize(w, h);
_innerWidth = w;
_innerHeight = h;
}
private Hex1bWidget Render<TParent>(WidgetContext<TParent> ctx)
where TParent : Hex1bWidget
{
// Until the handshake completes we have no dims and no role to
// render. Show a placeholder; OnConnected calls Invalidate to
// re-trigger this method as soon as the connection lands.
if (_connection is not { } connection)
{
return new BackgroundPanelWidget(
s_panelColor,
ctx.Center(ctx.Text($" Connecting to {_sessionLabel}… ")).Fill());
}
// Available widget space ~= host TTY minus the InfoBar (1 row).
// Console.WindowWidth / WindowHeight reflect the live host TTY
// size including SIGWINCH; the outer Hex1bTerminal is bound to
// those dims when running interactively. Hex1b doesn't expose
// the terminal size on RootContext, so we read it from the
// BCL - guarded against IOException because Console raises it
// when there is no controlling TTY (redirected stdout, CI,
// detached process). Fall back to the producer's default 80x24
// in that case; the user won't see this branch interactively.
int availW;
int availH;
try
{
availW = Math.Max(1, Console.WindowWidth);
availH = Math.Max(1, Console.WindowHeight - 1);
}
catch (IOException)
{
availW = 80;
availH = 23;
}
var producerW = connection.RemoteWidth;
var producerH = connection.RemoteHeight;
var isPrimary = connection.IsPrimary;
var fits = producerW <= availW && producerH <= availH;
var showTerminal = isPrimary || fits;
// While we hold the primary role, follow host SIGWINCH:
// re-broadcast the new dims so the producer's PTY grows or
// shrinks with the host terminal (Windows Terminal, iTerm2,
// tmux pane, ...). Without this a host grow leaves the
// producer pinned at the original dims and the terminal sits
// with empty padding around it forever.
if (isPrimary && (availW != _lastBroadcastWidth || availH != _lastBroadcastHeight))
{
BroadcastResize(connection, availW, availH);
}
Hex1bWidget body = showTerminal
? BuildTerminalView(ctx)
: BuildDoesntFitView(ctx, producerW, producerH, availW, availH);
var info = BuildInfoBar(ctx, connection, isPrimary, producerW, producerH);
// Wrap the body+infobar in a BackgroundPanelWidget so the
// framing area around a smaller producer grid fills with the
// panel colour (mirrors the dashboard's terminal card). The
// InfoBar paints its own background on top, so the visible
// grey appears only in the empty space around the centred
// terminal grid.
var content = new BackgroundPanelWidget(s_panelColor, ctx.VStack(v => [body, info]));
return content.InputBindings(bindings =>
{
// Detach: works in any mode.
bindings.Ctrl().Key(Hex1bKey.B).Then().Key(Hex1bKey.D)
.OverridesCapture()
.Action(_ =>
{
// Mark this as user-initiated *before* requesting stop so the
// post-finally fault rethrow can distinguish a clean detach
// from a real handshake/transport failure. See _embeddedFault
// handling in RunAsync.
Interlocked.Exchange(ref _userDetachRequested, 1);
_app?.RequestStop();
}, "Detach");
// Take control: only when we're not already primary.
if (!isPrimary)
{
bindings.Ctrl().Key(Hex1bKey.B).Then().Key(Hex1bKey.T)
.OverridesCapture()
.Action(async _ => await TakeControlAsync(connection, availW, availH).ConfigureAwait(false),
"Take Control");
}
});
}
private Hex1bWidget BuildTerminalView<TParent>(WidgetContext<TParent> ctx)
where TParent : Hex1bWidget
{
if (_handle is null)
{
return ctx.Align(Alignment.Center, ctx.Text("(initialising terminal)")).Fill();
}
// Pin the Terminal to the producer's grid dims so AlignNode
// can actually centre it. Without FixedWidth/Height,
// TerminalNode happily claims the full bounded constraint and
// the Align centring becomes a no-op — the grid just paints
// at top-left with blank padding around it.
// Apply an explicit terminal Background so cells with
// default-bg don't inherit the surrounding PanelColor.
return ctx.Align(
Alignment.Center,
ctx.Terminal(_handle)
.Background(s_terminalBackground)
.FixedWidth(Math.Max(1, _innerWidth))
.FixedHeight(Math.Max(1, _innerHeight))
).Fill();
}
private static Hex1bWidget BuildDoesntFitView<TParent>(
WidgetContext<TParent> ctx,
int producerW, int producerH,
int availW, int availH)
where TParent : Hex1bWidget
{
// .Fill() on the Center makes VStack hand it all the remaining
// body space so the panel can centre vertically and the
// InfoBar is pushed to the actual bottom of the screen.
return ctx.Center(
ctx.Border(b =>
[
b.VStack(v =>
[
v.Text(""),
v.Text($" Producer terminal: {producerW}\u00d7{producerH} "),
v.Text($" Your terminal: {availW}\u00d7{availH} "),
v.Text(""),
v.Text(" Press Ctrl+B T to take control "),
v.Text(" (resizes producer to your terminal) "),
v.Text(""),
v.Text(" Press Ctrl+B D to detach "),
v.Text(""),
])
]).Title(" doesn't fit ")).Fill();
}
private Hex1bWidget BuildInfoBar<TParent>(
WidgetContext<TParent> ctx,
IHmp1ConnectionHandle connection,
bool isPrimary,
int producerW, int producerH)
where TParent : Hex1bWidget
{
var role = isPrimary ? "PRIMARY" : "viewer";
// +1 to include ourselves in the "peers" total; matches the
// dashboard chrome's status pill semantics.
var peers = connection.Peers.Count + 1;
var dims = $"{producerW}\u00d7{producerH}";
return ctx.InfoBar(s =>
[
s.Section("Ctrl+B T"),
s.Section(isPrimary ? "(primary)" : "Take"),
s.Spacer(),
s.Section("Ctrl+B D"),
s.Section("Detach"),
s.Spacer(),
s.Section(_sessionLabel),
s.Section(role),
s.Section($"peers:{peers}"),
s.Section(dims),
]).Divider(" ");
}
private async Task TakeControlAsync(IHmp1ConnectionHandle connection, int availW, int availH)
{
try
{
// Request producer to resize PTY to our available widget
// area (host TTY minus InfoBar). Producer broadcasts
// RoleChange + implicit Resize; our RoleChanged handler
// updates the inner terminal grid + invalidates the app.
await connection.RequestPrimaryAsync(availW, availH, CancellationToken.None).ConfigureAwait(false);
// Seed the SIGWINCH tracker so the render-time host-resize
// poll doesn't immediately re-broadcast the dims we just
// set.
_lastBroadcastWidth = availW;
_lastBroadcastHeight = availH;
}
catch (Exception ex)
{
// Best-effort; if the producer is gone we'll see
// OnDisconnected shortly. Don't escalate — Hex1bApp
// surface should never unwind a binding action with an
// exception.
_logger.LogDebug(ex, "Multi-head Take Control failed; remaining as secondary.");
}
}
private void BroadcastResize(IHmp1ConnectionHandle connection, int width, int height)
{
// Record the target dims up-front so we don't loop on the next
// Render(): if multiple SIGWINCH events fire while a request
// is in flight, only the latest pair persists in
// _lastBroadcastWidth/H.
_lastBroadcastWidth = width;
_lastBroadcastHeight = height;
// Single-flight: bail if a broadcast is already in flight.
// Future renders will re-detect drift if the host kept
// resizing while the request was in flight (because the
// in-flight call captured an older target) and trigger a
// fresh broadcast then.
if (Interlocked.CompareExchange(ref _resizeInFlight, 1, 0) != 0)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await connection.RequestPrimaryAsync(width, height, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
// Best-effort; producer may have gone away mid-resize.
_logger.LogDebug(ex, "Multi-head SIGWINCH re-broadcast failed.");
}
finally
{
Volatile.Write(ref _resizeInFlight, 0);
_app?.Invalidate();
}
});
}
private static (int Cols, int Rows) TryGetLocalDimensions()
{
// Prefer the live console size when available. Fall back to
// the producer's default 80x24 if the CLI is being invoked in
// a non-console context — in that case the request still
// succeeds and the producer keeps its current size if both
// dimensions match.
try
{
var cols = Console.WindowWidth;
var rows = Console.WindowHeight;
if (cols > 0 && rows > 0)
{
return (cols, rows);
}
}
catch (IOException)
{
}
return (80, 24);
}
}