diff --git a/src/SharpClient.App/SharpClient.App.csproj b/src/SharpClient.App/SharpClient.App.csproj
index 5c4764d..c185067 100644
--- a/src/SharpClient.App/SharpClient.App.csproj
+++ b/src/SharpClient.App/SharpClient.App.csproj
@@ -48,16 +48,12 @@
10.0.17763.0
-
+
falsefalse
@@ -119,8 +115,11 @@
2.5.3 fixes client NAWS (RFC 1073): the client now offers WILL NAWS and only sends
an SB NAWS window-size update once the server enables it (DO NAWS). Previously it sent
an unsolicited SB NAWS that made SharpMUSH swallow the next line, so typed logins
- intermittently bounced back to the login screen. -->
-
+ intermittently bounced back to the login screen.
+ 4.6.1: SendNAWS moved to NAWSProtocol.SendWindowSizeAsync, the client names itself
+ only through WithClientIdentity (TelnetConnection sets it), and FSharp.Core is gone,
+ so AOT and trimming can be turned back on once a trimmed APK is checked on a device. -->
+
diff --git a/src/SharpClient.Core/Connection/ITelnetConnection.cs b/src/SharpClient.Core/Connection/ITelnetConnection.cs
index 46f26a9..e2d3b90 100644
--- a/src/SharpClient.Core/Connection/ITelnetConnection.cs
+++ b/src/SharpClient.Core/Connection/ITelnetConnection.cs
@@ -13,6 +13,12 @@ public interface ITelnetConnection : IAsyncDisposable
/// Raised when MXP (telnet option 91) negotiation completes successfully.
public event Action? MxpEnabled;
+ ///
+ /// Raised when the server switches this connection into Pueblo HTML mode, which it does only after
+ /// its own hello and our PUEBLOCLIENT answer. A hello quoted in a player's text starts nothing.
+ ///
+ public event Action? PuebloEnabled;
+
public ConnectionState State { get; }
public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default);
diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs
index 163453d..77f8560 100644
--- a/src/SharpClient.Core/Connection/TelnetConnection.cs
+++ b/src/SharpClient.Core/Connection/TelnetConnection.cs
@@ -3,6 +3,7 @@
using TelnetNegotiationCore.Builders;
using TelnetNegotiationCore.Interpreters;
using TelnetNegotiationCore.Models;
+using TelnetNegotiationCore.Protocols;
namespace SharpClient.Core.Connection;
@@ -20,6 +21,7 @@ public sealed class TelnetConnection : ITelnetConnection
private string? _host;
private int _port;
private bool _intentionalDisconnect;
+ private (int Width, int Height)? _windowSize;
private CancellationTokenSource? _heartbeatCts;
private Task? _heartbeatTask;
@@ -32,6 +34,14 @@ public sealed class TelnetConnection : ITelnetConnection
// long-running (e.g. in-car) connection, without meaningful battery cost.
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(45);
+ // TNC names nobody unless told to: without this, TTYPE answers UNKNOWN and NEW-ENVIRON sends no
+ // CLIENT_NAME. MTTS claims what AnsiParser renders: 16, 256 and 24-bit colour over UTF-8.
+ private static ClientIdentity Identity { get; } = new("SharpClient")
+ {
+ TerminalType = "XTERM",
+ Mtts = MttsCapabilities.Ansi | MttsCapabilities.Utf8 | MttsCapabilities.Colors256 | MttsCapabilities.Truecolor,
+ };
+
public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? reconnectOptions = null)
{
_factory = factory;
@@ -43,6 +53,7 @@ public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? rec
public event Action? GmcpReceived;
public event Action? NegotiationReceived;
public event Action? MxpEnabled;
+ public event Action? PuebloEnabled;
public ConnectionState State { get; private set; } = ConnectionState.Disconnected;
@@ -134,12 +145,17 @@ public async Task SendAsync(string line)
public async Task SendNawsAsync(int width, int height)
{
+ // Kept across reconnects: each new interpreter reports it once the server sends DO NAWS.
+ _windowSize = (width, height);
if (_interpreter is null)
{
return;
}
- await _interpreter.SendNAWS((short)width, (short)height);
+ if (_interpreter.PluginManager?.GetPlugin() is WindowSizeProtocol naws)
+ {
+ await naws.ReportAsync(width, height);
+ }
}
public async Task DisconnectAsync()
@@ -287,13 +303,29 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT
TrySetTcpUserTimeout(client.Client);
await client.ConnectAsync(host, port, cancellationToken);
+ // The protocols AddDefaultMUDProtocols registers (NAWS, GMCP, MSDP, MSSP, TTYPE, CHARSET, EOR,
+ // SGA, MXP), spelled out so NAWS can be WindowSizeProtocol: the helper always adds the base
+ // NAWSProtocol, and a second plugin for the same option is rejected. Plus Pueblo.
+ // Kept as locals because the server's banner (and so the Pueblo hello) can arrive before
+ // BuildAndStartAsync returns and _interpreter is set.
+ var mxp = new MXPProtocol();
+ mxp.OnMXPEnabled(OnMxpEnabledAsync);
+ var pueblo = new PuebloProtocol();
+ pueblo.OnPuebloOffered(() => OnPuebloOfferedAsync(pueblo, mxp)).OnPuebloEnabled(OnPuebloEnabledAsync);
+
var (interpreter, readTask) = await _factory.CreateBuilder()
.OnSubmit(OnSubmitAsync)
- .AddDefaultMUDProtocols(
- onGMCPMessage: OnGmcpMessageAsync,
- onMSSP: OnMsspAsync,
- onMXPEnabled: OnMxpEnabledAsync,
- charsetOrder: CharsetPreference)
+ .WithClientIdentity(Identity)
+ .AddPlugin(new WindowSizeProtocol(_windowSize))
+ .AddPlugin().OnGMCPMessage(OnGmcpMessageAsync)
+ .AddPlugin()
+ .AddPlugin().OnMSSP(OnMsspAsync)
+ .AddPlugin()
+ .AddPlugin().WithCharsetOrder(CharsetPreference)
+ .AddPlugin()
+ .AddPlugin()
+ .AddPlugin(mxp)
+ .AddPlugin(pueblo)
.BuildAndStartAsync(client, cancellationToken);
_client = client;
@@ -393,6 +425,33 @@ private ValueTask OnMxpEnabledAsync()
return ValueTask.CompletedTask;
}
+ // SharpMUSH offers MXP and Pueblo both. Answering the Pueblo hello switches the connection to
+ // Pueblo, and the server then sends no MXP, so a server that agreed MXP is left on MXP. That is
+ // the option (WILL MXP), which arrives before the banner; MXP mode itself may start later.
+ private static async ValueTask OnPuebloOfferedAsync(PuebloProtocol pueblo, MXPProtocol mxp)
+ {
+ if (mxp.IsMXPActive)
+ {
+ return;
+ }
+
+ try
+ {
+ await pueblo.AnnounceAsync();
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException)
+ {
+ // The connection went away under the handshake; the reconnect starts it over.
+ }
+ }
+
+ private ValueTask OnPuebloEnabledAsync(PuebloClient client)
+ {
+ PuebloEnabled?.Invoke();
+ NegotiationReceived?.Invoke(new NegotiationEvent("Pueblo", $"enabled ({client.Version})"));
+ return ValueTask.CompletedTask;
+ }
+
private ValueTask OnMsspAsync(MSSPConfig cfg)
{
var parts = new List();
diff --git a/src/SharpClient.Core/Connection/WindowSizeProtocol.cs b/src/SharpClient.Core/Connection/WindowSizeProtocol.cs
new file mode 100644
index 0000000..2ec5d1c
--- /dev/null
+++ b/src/SharpClient.Core/Connection/WindowSizeProtocol.cs
@@ -0,0 +1,75 @@
+using TelnetNegotiationCore.Protocols;
+
+namespace SharpClient.Core.Connection;
+
+///
+/// NAWS that keeps the latest window size until the server enables NAWS, then reports it. The base
+/// plugin drops a report made before DO NAWS (RFC 1073 forbids sending it), and nothing would
+/// report again until the next resize: the size the UI measured at connect, or the one carried over an
+/// auto-reconnect, never reached the server. Registers under 's type, so
+/// GetPlugin<NAWSProtocol>() finds it.
+///
+internal sealed class WindowSizeProtocol : NAWSProtocol
+{
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private (int Width, int Height)? _requested;
+ private (int Width, int Height)? _reported;
+
+ public WindowSizeProtocol((int Width, int Height)? initial = null) =>
+ _requested = initial is { } size ? (Clamp(size.Width), Clamp(size.Height)) : null;
+
+ public async ValueTask ReportAsync(int width, int height)
+ {
+ await _gate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ _requested = (Clamp(width), Clamp(height));
+ await ReportIfReadyAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ protected override async ValueTask OnNegotiationChangedAsync(bool isNegotiated)
+ {
+ await _gate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ // A fresh agreement needs its own initial report even if the size has not changed.
+ if (!isNegotiated)
+ {
+ _reported = null;
+ }
+ else
+ {
+ await ReportIfReadyAsync().ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ protected override async ValueTask OnDisposeAsync()
+ {
+ await base.OnDisposeAsync().ConfigureAwait(false);
+ _gate.Dispose();
+ }
+
+ // RFC 1073 fields are 16-bit unsigned; TNC throws outside that range rather than wrapping.
+ private static int Clamp(int value) => Math.Clamp(value, 0, MaxWindowDimension);
+
+ private async ValueTask ReportIfReadyAsync()
+ {
+ if (!IsNegotiated || !WindowSizeReportingEnabled || _requested is not { } size || _reported == size)
+ {
+ return;
+ }
+
+ await SendWindowSizeAsync(size.Width, size.Height).ConfigureAwait(false);
+ _reported = size;
+ }
+}
diff --git a/src/SharpClient.Core/Rendering/AnsiColor.cs b/src/SharpClient.Core/Rendering/AnsiColor.cs
index d1d15b5..9a9a2c2 100644
--- a/src/SharpClient.Core/Rendering/AnsiColor.cs
+++ b/src/SharpClient.Core/Rendering/AnsiColor.cs
@@ -4,6 +4,9 @@ public enum AnsiColorKind
{
Default,
Indexed,
+
+ /// 24-bit colour (SGR 38;2 / 48;2). holds 0xRRGGBB.
+ Rgb,
}
public readonly record struct AnsiColor(AnsiColorKind Kind, int Index)
@@ -11,4 +14,7 @@ public readonly record struct AnsiColor(AnsiColorKind Kind, int Index)
public static AnsiColor Default => new(AnsiColorKind.Default, 0);
public static AnsiColor Indexed(int index) => new(AnsiColorKind.Indexed, index);
+
+ public static AnsiColor Rgb(int r, int g, int b) =>
+ new(AnsiColorKind.Rgb, (Math.Clamp(r, 0, 255) << 16) | (Math.Clamp(g, 0, 255) << 8) | Math.Clamp(b, 0, 255));
}
diff --git a/src/SharpClient.Core/Rendering/AnsiParser.cs b/src/SharpClient.Core/Rendering/AnsiParser.cs
index c2f2ffc..b0407d3 100644
--- a/src/SharpClient.Core/Rendering/AnsiParser.cs
+++ b/src/SharpClient.Core/Rendering/AnsiParser.cs
@@ -36,6 +36,22 @@ void Flush()
text.Clear();
}
+ void AddImage(string? url, string label)
+ {
+ if (url is null)
+ {
+ return;
+ }
+
+ Flush();
+ segments.Add(new StyledSegment(label, style)
+ {
+ ImageUrl = url,
+ Command = pendingCommand,
+ Hint = pendingHint,
+ });
+ }
+
void HandleTag(string raw)
{
var body = raw.Trim();
@@ -85,6 +101,21 @@ void HandleTag(string raw)
inAnchor = true;
}
}
+ else if (name == "image" && mxp!.IsMxpActive && mxp.IsSecure)
+ {
+ // IMAGE is not an open tag, so like SEND it needs a Secure line.
+ var fileName = GetAttr(attrs, "fname") ?? FirstPositional(attrs);
+ var url = fileName is null
+ ? null
+ : ImageSource.ResolveMxp(fileName, GetAttr(attrs, "url"), GetAttr(attrs, "t"));
+ AddImage(url, ImageSource.DisplayName(fileName));
+ }
+ else if (name == "img" && mxp!.IsPuebloActive)
+ {
+ // is Pueblo's mode switch, not a picture: it has no src.
+ var src = GetAttr(attrs, "src");
+ AddImage(src is null ? null : ImageSource.ResolvePueblo(src), GetAttr(attrs, "alt") ?? ImageSource.DisplayName(src));
+ }
// Any other tag (or a non-command anchor) is stripped silently.
}
else
@@ -249,6 +280,71 @@ void HandleTag(string raw)
return null;
}
+ /// The first attribute given without a name (MXP allows <IMAGE map.png URL=...>).
+ private static string? FirstPositional(string body)
+ {
+ var i = 0;
+ while (i < body.Length)
+ {
+ while (i < body.Length && body[i] is ' ' or '\t')
+ {
+ i++;
+ }
+
+ if (i >= body.Length)
+ {
+ return null;
+ }
+
+ var quoted = body[i] is '"' or '\'';
+ var start = i;
+ if (quoted)
+ {
+ var quote = body[i++];
+ start = i;
+ while (i < body.Length && body[i] != quote)
+ {
+ i++;
+ }
+
+ var value = body[start..i];
+ i++;
+ return DecodeEntities(value);
+ }
+
+ var isNamed = false;
+ while (i < body.Length && body[i] is not (' ' or '\t'))
+ {
+ if (body[i] == '=')
+ {
+ isNamed = true;
+ i++;
+ if (i < body.Length && body[i] is '"' or '\'')
+ {
+ var quote = body[i++];
+ while (i < body.Length && body[i] != quote)
+ {
+ i++;
+ }
+
+ i++;
+ }
+
+ continue;
+ }
+
+ i++;
+ }
+
+ if (!isNamed)
+ {
+ return DecodeEntities(body[start..i]);
+ }
+ }
+
+ return null;
+ }
+
///
/// Scan from for the '>' that closes a tag, skipping over single- or
/// double-quoted attribute values so a literal '>' inside an attribute doesn't end the tag early
@@ -475,9 +571,17 @@ private static bool TryReadExtended(string[] codes, ref int i, out AnsiColor col
if (codes[i + 1] == "2")
{
- // Truecolor (24-bit): consume "2" + R + G + B components.
- // Phase-1 scope: graceful degradation — colour stays AnsiColor.Default.
+ // Truecolor: "2" + R + G + B. A short or malformed sequence is still consumed, so its
+ // components are not read as SGR codes of their own, and leaves the colour at default.
var remaining = codes.Length - 1 - i;
+ if (remaining >= 4
+ && int.TryParse(codes[i + 2], out var r)
+ && int.TryParse(codes[i + 3], out var g)
+ && int.TryParse(codes[i + 4], out var b))
+ {
+ colour = AnsiColor.Rgb(r, g, b);
+ }
+
i += Math.Min(4, remaining);
return true;
}
diff --git a/src/SharpClient.Core/Rendering/ImageSource.cs b/src/SharpClient.Core/Rendering/ImageSource.cs
new file mode 100644
index 0000000..a12fae3
--- /dev/null
+++ b/src/SharpClient.Core/Rendering/ImageSource.cs
@@ -0,0 +1,95 @@
+namespace SharpClient.Core.Rendering;
+
+///
+/// Turns MXP and Pueblo image references into a URL the output view may load. Only http, https and
+/// data:image are accepted: any other scheme (javascript:, file:, content:) would be the world choosing
+/// what the client opens.
+///
+public static class ImageSource
+{
+ private const int MaxNameLength = 48;
+
+ ///
+ /// MXP <IMAGE>: "The classname is appended to the URL, along with the name of the
+ /// graphics file itself", so URL=http://h/img/ T=maps map.png is http://h/img/maps/map.png.
+ /// Without URL= the name must itself be absolute; a bare file name refers to a local media
+ /// directory this client does not have.
+ ///
+ public static string? ResolveMxp(string fileName, string? url, string? type)
+ {
+ var name = fileName.Trim();
+ if (name.Length == 0)
+ {
+ return null;
+ }
+
+ if (IsDataImage(name))
+ {
+ return string.IsNullOrWhiteSpace(url) ? name : null;
+ }
+
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ return Absolute(name);
+ }
+
+ var basePath = EnsureSlash(url.Trim());
+ if (!string.IsNullOrWhiteSpace(type))
+ {
+ basePath = EnsureSlash(basePath + type.Trim().Trim('/'));
+ }
+
+ if (!Uri.TryCreate(basePath, UriKind.Absolute, out var baseUri) || !IsWeb(baseUri))
+ {
+ return null;
+ }
+
+ return Uri.TryCreate(baseUri, name, out var full) && IsWeb(full) ? full.AbsoluteUri : null;
+ }
+
+ /// Pueblo <img src>, which is plain HTML: an absolute URL or a data:image URI.
+ public static string? ResolvePueblo(string src)
+ {
+ var value = src.Trim();
+ return IsDataImage(value) ? value : Absolute(value);
+ }
+
+ /// What to show in place of the picture: the last path segment of its name, bounded.
+ public static string DisplayName(string? fileName)
+ {
+ var name = fileName?.Trim() ?? string.Empty;
+ if (name.Length == 0 || name.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ {
+ return "image";
+ }
+
+ var query = name.IndexOfAny(['?', '#']);
+ if (query >= 0)
+ {
+ name = name[..query];
+ }
+
+ var slash = name.TrimEnd('/').LastIndexOf('/');
+ if (slash >= 0)
+ {
+ name = name[(slash + 1)..].TrimEnd('/');
+ }
+
+ if (name.Length == 0)
+ {
+ return "image";
+ }
+
+ return name.Length <= MaxNameLength ? name : name[..(MaxNameLength - 1)] + "…";
+ }
+
+ private static bool IsDataImage(string value) =>
+ value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase);
+
+ private static string? Absolute(string value) =>
+ Uri.TryCreate(value, UriKind.Absolute, out var uri) && IsWeb(uri) ? uri.AbsoluteUri : null;
+
+ private static string EnsureSlash(string value) => value.EndsWith('/') ? value : value + "/";
+
+ private static bool IsWeb(Uri uri) => uri.Scheme is "http" or "https";
+}
diff --git a/src/SharpClient.Core/Rendering/MxpParserState.cs b/src/SharpClient.Core/Rendering/MxpParserState.cs
index fce2d0f..1cad530 100644
--- a/src/SharpClient.Core/Rendering/MxpParserState.cs
+++ b/src/SharpClient.Core/Rendering/MxpParserState.cs
@@ -10,7 +10,7 @@ public sealed class MxpParserState
/// Whether MXP telnet option 91 was successfully negotiated.
public bool IsMxpActive { get; set; }
- /// Whether the Pueblo banner was seen and the PUEBLOCLIENT handshake sent.
+ /// Whether the server switched this connection into Pueblo HTML mode.
public bool IsPuebloActive { get; set; }
///
diff --git a/src/SharpClient.Core/Rendering/SegmentStyle.cs b/src/SharpClient.Core/Rendering/SegmentStyle.cs
index 74a45e1..72478be 100644
--- a/src/SharpClient.Core/Rendering/SegmentStyle.cs
+++ b/src/SharpClient.Core/Rendering/SegmentStyle.cs
@@ -11,9 +11,9 @@ public static string ToCss(StyledSegment segment)
{
var style = segment.Style;
var fg = Resolve(style.Foreground, AnsiPalette.PhosphorDefault);
- string? bg = style.Background.Kind == AnsiColorKind.Indexed
- ? AnsiPalette.ToHex(style.Background.Index)
- : null;
+ string? bg = style.Background.Kind == AnsiColorKind.Default
+ ? null
+ : Resolve(style.Background, AnsiPalette.PhosphorDefault);
if (style.Inverse)
{
@@ -41,8 +41,10 @@ public static string ToCss(StyledSegment segment)
return sb.ToString();
}
- private static string Resolve(AnsiColor color, string fallback) =>
- color.Kind == AnsiColorKind.Indexed
- ? AnsiPalette.ToHex(color.Index)
- : fallback;
+ private static string Resolve(AnsiColor color, string fallback) => color.Kind switch
+ {
+ AnsiColorKind.Indexed => AnsiPalette.ToHex(color.Index),
+ AnsiColorKind.Rgb => $"#{color.Index & 0xFFFFFF:x6}",
+ _ => fallback,
+ };
}
diff --git a/src/SharpClient.Core/Rendering/StyledSegment.cs b/src/SharpClient.Core/Rendering/StyledSegment.cs
index f7f0621..1ed1b15 100644
--- a/src/SharpClient.Core/Rendering/StyledSegment.cs
+++ b/src/SharpClient.Core/Rendering/StyledSegment.cs
@@ -10,4 +10,10 @@ public readonly record struct StyledSegment(string Text, TextStyle Style)
/// Optional tooltip for a command link. Null if unspecified.
public string? Hint { get; init; }
+
+ ///
+ /// If non-null, this segment is an inline image (MXP <IMAGE> or Pueblo <img>) and
+ /// is its alternative text. Always an absolute http(s) URL or a data:image URI.
+ ///
+ public string? ImageUrl { get; init; }
}
diff --git a/src/SharpClient.Core/Sessions/Session.cs b/src/SharpClient.Core/Sessions/Session.cs
index 3995cd1..f1da93d 100644
--- a/src/SharpClient.Core/Sessions/Session.cs
+++ b/src/SharpClient.Core/Sessions/Session.cs
@@ -67,6 +67,7 @@ public Session(
_connection.GmcpReceived += OnGmcpReceived;
_connection.NegotiationReceived += OnNegotiationReceived;
_connection.MxpEnabled += OnMxpEnabled;
+ _connection.PuebloEnabled += OnPuebloEnabled;
}
public IReadOnlyList Scrollback
@@ -144,9 +145,16 @@ public async ValueTask DisposeAsync()
_connection.GmcpReceived -= OnGmcpReceived;
_connection.NegotiationReceived -= OnNegotiationReceived;
_connection.MxpEnabled -= OnMxpEnabled;
+ _connection.PuebloEnabled -= OnPuebloEnabled;
await _connection.DisposeAsync();
}
+ private void OnPuebloEnabled()
+ {
+ _mxp.IsPuebloActive = true;
+ _protocolChanged?.Invoke();
+ }
+
private void OnMxpEnabled()
{
_mxp.IsMxpActive = true;
@@ -155,15 +163,6 @@ private void OnMxpEnabled()
private async void OnLineReceived(string raw)
{
- // Pueblo negotiation is banner-based (no telnet option): the server announces
- // itself in plain text and the client replies with PUEBLOCLIENT over the data channel.
- if (!_mxp.IsPuebloActive && raw.Contains("This world is Pueblo", StringComparison.OrdinalIgnoreCase))
- {
- _mxp.IsPuebloActive = true;
- _protocolChanged?.Invoke();
- await _connection.SendAsync("PUEBLOCLIENT 2.01");
- }
-
// Reset per-line MXP mode to the negotiated default before parsing this line.
_mxp.BeginLine();
@@ -216,6 +215,17 @@ private void OnStateChanged(ConnectionState state)
{
var previous = _lastState;
_lastState = state;
+
+ // MXP and Pueblo belong to one connection: a reconnect negotiates them again, and the
+ // server it reaches may not offer either.
+ if (previous == ConnectionState.Connected && state != ConnectionState.Connected)
+ {
+ _mxp.IsMxpActive = false;
+ _mxp.IsPuebloActive = false;
+ _mxp.DefaultLineMode = 0;
+ _mxp.LineMode = 0;
+ }
+
StateChanged?.Invoke(state);
// On every transition into Connected — the initial connect AND each automatic reconnect —
diff --git a/src/SharpClient.Core/SharpClient.Core.csproj b/src/SharpClient.Core/SharpClient.Core.csproj
index 6b83780..d58c403 100644
--- a/src/SharpClient.Core/SharpClient.Core.csproj
+++ b/src/SharpClient.Core/SharpClient.Core.csproj
@@ -7,8 +7,8 @@
-
-
+
+
diff --git a/src/SharpClient.UI/Components/OutputView.razor b/src/SharpClient.UI/Components/OutputView.razor
index dfabf90..90390be 100644
--- a/src/SharpClient.UI/Components/OutputView.razor
+++ b/src/SharpClient.UI/Components/OutputView.razor
@@ -7,7 +7,21 @@
@foreach (var segment in line.Segments)
{
- @if (segment.Command is not null && OnSendCommand is not null)
+ @if (segment.ImageUrl is not null)
+ {
+ @* no-referrer: the image host learns nothing about where it was shown. *@
+ @if (segment.Command is not null && OnSendCommand is not null)
+ {
+
+ }
+ else
+ {
+
+ }
+ }
+ else if (segment.Command is not null && OnSendCommand is not null)
{