From 77ce1a7e34fb4807da867e9f50fc2d42beb6b924 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 26 Aug 2026 18:14:16 -0500 Subject: [PATCH 1/9] fix(prompt): put the prompt back on the screen it was dropped from WorldSession.OnOutputReceived's prompt branch set CurrentPrompt and raised PromptChanged, then returned before the line reached ProcessOutputLine - 22bd97f deleted the only render of CurrentPrompt, and PromptChanged's sole remaining subscriber rebuilds the input-bar label without reading it. A prompt now also runs through ProcessOutputLine (pre-emoji raw line, since ProcessOutputLine applies StripIncomingColour/ExpandTabs/triggers/ApplyEmoji itself), landing it in the pane exactly once alongside CurrentPrompt - where it's searchable and trigger-visible, matching Mudlet. Also updates WorldSessionTests' prompt test, which asserted the prompt was absent from scrollback - that was the bug being fixed. Co-Authored-By: Claude Opus 5 (1M context) --- src/SharpMUTerm.Core/Session/WorldSession.cs | 10 ++++ .../Session/WorldSessionPromptTests.cs | 50 +++++++++++++++++++ .../Session/WorldSessionTests.cs | 4 +- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 54273ac0..5233e5ef 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -410,6 +410,16 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) var prompt = ApplyEmoji(_text?.StripIncomingColour == true ? StyledText.StripColour(raw) : raw); CurrentPrompt = prompt; PromptChanged?.Invoke(this, prompt); + + // And into the pane, which is where a reader actually looks for it. Before this, a prompt + // was stored and shown nowhere: 22bd97f deleted the status-bar render that was the only + // consumer of CurrentPrompt, and PromptChanged's one remaining subscriber rebuilds the + // input-bar label from the character and world without ever reading the prompt. A prompt + // is a line the server sent — it belongs in the scrollback, in the search index and in + // front of the triggers, which is where Mudlet puts it too. `raw`, not `prompt`: this + // takes `ProcessOutputLine` through StripIncomingColour/ExpandTabs/triggers/ApplyEmoji + // itself, and handing it the already-substituted line would substitute twice. + ProcessOutputLine(raw); return; } diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs new file mode 100644 index 00000000..ced207ba --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs @@ -0,0 +1,50 @@ +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Core.Tests.Session; + +public class WorldSessionPromptTests +{ + private static (WorldSession session, FakeTelnetSession telnet) Create( + WorldDefinition world, + TriggerSet? set = null) + { + var telnet = new FakeTelnetSession(); + var sets = set is null ? null : new[] { set }; + var session = new WorldSession(world, triggerSets: sets, sessionFactory: _ => telnet); + return (session, telnet); + } + + private static WorldDefinition World() => new() { Name = "T", Host = "h", Port = 1, LocalEcho = true }; + + [Test] + public async Task APromptIsAddedToTheSessionsScrollbackAsWellAsCurrentPrompt() + { + var (session, telnet) = Create(World()); + await session.ConnectAsync(); + + telnet.EmitPrompt("HP:100>"); + + await Assert.That(session.CurrentPrompt).IsNotNull(); + await Assert.That(session.CurrentPrompt!.Text).IsEqualTo("HP:100>"); + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "HP:100>")).IsTrue(); + } + + [Test] + public async Task APromptRunsThroughTheTriggersLikeAnyOtherLine() + { + var set = new TriggerSet(); + set.Triggers.Add(new Trigger { Pattern = @"^HP:", Actions = new TriggerActions { SpawnTarget = "Vitals" } }); + var (session, telnet) = Create(World(), set); + SpawnLineEventArgs? spawned = null; + session.SpawnLine += (_, e) => spawned = e; + await session.ConnectAsync(); + + telnet.EmitPrompt("HP:100>"); + + await Assert.That(spawned).IsNotNull(); + await Assert.That(spawned!.Target).IsEqualTo("Vitals"); + await Assert.That(spawned!.Line.Text).IsEqualTo("HP:100>"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionTests.cs index 709e18c4..b43e04f4 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionTests.cs @@ -142,7 +142,7 @@ public async Task Trigger_Spawn_RoutesLineToSpawnEvent() } [Test] - public async Task Prompt_UpdatesCurrentPrompt_WithoutScrollback() + public async Task Prompt_UpdatesCurrentPromptAndLandsInScrollback() { var (session, telnet) = Create(World()); StyledLine? promptEvt = null; @@ -154,7 +154,7 @@ public async Task Prompt_UpdatesCurrentPrompt_WithoutScrollback() await Assert.That(session.CurrentPrompt).IsNotNull(); await Assert.That(session.CurrentPrompt!.Text).IsEqualTo("HP:100 >"); await Assert.That(promptEvt).IsNotNull(); - await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "HP:100 >")).IsFalse(); + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "HP:100 >")).IsTrue(); } /// From 2211c437794105106f980a34ab036944304d711a Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 26 Aug 2026 18:24:58 -0500 Subject: [PATCH 2/9] chore: TelnetNegotiationCore 2.8.1 to 2.12.0, for the prompt boundary RESTORE WARNING: the committed pin (2.12.0) is not yet on nuget.org. A bare `dotnet restore`/`build` of this commit will fail until TelnetNegotiationCore 2.12.0 is published there; the local feed used to build and test it was scaffolding, removed before this commit. Pulls in PacketPatchProtocol (2.12.0), which is what actually delivers the connect-screen prompt on tdome.nukefire.org and starwars.d20mud.com - neither negotiates GA, EOR or SUPPRESS-GO-AHEAD. Verified live against both: the prompt arrives as one complete line, ~700-790ms after the last banner line (the plugin's 500ms hold time plus round-trips), confirming the packet-patch heuristic rather than a real marker. A loopback test against a real server-mode TelnetInterpreter additionally confirms this app's own wiring, not just the library: a GA-marked prompt is delivered in ~2ms, and a fragment sent after the GA is never reported by the packet-patch heuristic, i.e. the real marker retires it end-to-end. Checked every intervening CHANGELOG entry (2.8.2-2.11.0) against what TelnetSession.cs uses: nothing else applies. CallbackOnByteAsync, reached by reflection, is unchanged; IProtocolContext's breaking additions don't apply since nothing here implements that interface directly. Also drops a history-lesson comment in WorldSession.cs (commit hash and narrative) per the repo's established rule, keeping only the load-bearing raw-vs-prompt explanation, and corrects a stale CLAUDE.md claim that TelnetInterpreter.CurrentEncoding defaults to Encoding.ASCII - it is Encoding.UTF8 as of this version (inert here either way, since TelnetSession seeds it before any byte is processed). Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 27 +++++++++++++++----- Directory.Packages.props | 2 +- src/SharpMUTerm.Core/Session/WorldSession.cs | 13 ++++------ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3941b1a1..a48cb938 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1278,7 +1278,19 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] permitted transitions from `StartNegotiation`; before it each one hit `OnUnhandledTriggerAsync`, logging Critical and recovering through `Trigger.Error`, which on some interleavings ate the sequence behind it. **2.10.0** carries the raw bytes of each MSSP value beside the decoded - string. **2.11.0** is the prompt-marker fix below. + string. **2.11.0** is the prompt-marker fix below. **2.12.0** is the one that actually motivated + a further jump: it ships **`PacketPatchProtocol`**, included in `AddDefaultMUDProtocols` + automatically (an appended, defaulted `packetPatchHoldTime` parameter this codebase doesn't set), + which infers a prompt boundary from 500ms of silence for the servers that mark none at all, and is + what delivers the connect-screen prompt on both `tdome.nukefire.org` and `starwars.d20mud.com` — + neither negotiates GA, EOR or SUPPRESS-GO-AHEAD, so both go through the packet-patch heuristic, + confirmed by a ~700–790ms gap between the last banner line and the prompt (the 500ms hold time plus + network round-trips), not an instant marker. `IProtocolContext` gained three members in 2.12.0, one + of them binary-breaking (`TakePartialLineAsPrompt` now returns `bool`) — this codebase never + implements that interface directly, only consumes the library's own `TelnetInterpreter`, so it + doesn't apply. The `CallbackOnByteAsync` property this file reaches by reflection is unchanged + across the whole jump: still `public Func? CallbackOnByteAsync { get; init; }` + on `TelnetInterpreter`, confirmed by reading the 2.12.0 source rather than assumed. - **A prompt ends with `IAC EOR` or `IAC GA`, and until 2.11.0 this client could only see the first of them.** `TelnetSession.OnPromptAsync` is the only thing that flushes `_pending` — the unterminated line `CallbackOnByteAsync` accumulates — so a server whose prompt boundary the library @@ -1446,11 +1458,14 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] 2066 settles on — and naming one is an *override*: still offered at the head of the order so a cooperative server agrees, but used regardless of what it says. Four things about this library will bite you, and all four already have: - - **`TelnetInterpreter.CurrentEncoding` defaults to `Encoding.ASCII`**, and that default is not inert: - it is handed to `CallbackOnByteAsync`/`CallbackOnSubmitAsync` for every byte and used for GMCP, MSDP - and everything we send. On a server that never negotiates CHARSET — most MU\* servers — every - byte above 0x7F became `?`. `TelnetSession` seeds that property (reflectively, `internal set`, the - same way `CharsetProtocol` itself writes it) with the head of the stated order. + - **`TelnetInterpreter.CurrentEncoding` defaults to `Encoding.UTF8`** (`TelnetStandardInterpreter.cs:46`), + and that default is not something to rely on unseeded: it is handed to + `CallbackOnByteAsync`/`CallbackOnSubmitAsync` for every byte and used for GMCP, MSDP and everything + we send, before this session's own encoding decision (an override, or the head of `CharsetOrder`) + has had any say. `TelnetSession` seeds that property (reflectively, `internal set`, the same way + `CharsetProtocol` itself writes it) with the head of the stated order — otherwise a world pinned to + a non-UTF-8 override, or a server that never negotiates CHARSET at all, would decode against + whichever encoding the library happens to default to rather than what this session decided. **MSSP fields were decoded as `Encoding.ASCII` through 2.6.x** (fixed in 2.7.0, pinned by `MsspParsingTests`); treat non-ASCII in an MSSP field from an older library as unrecoverable. Two consequences worth knowing from the pre-fix era: the plaintext `MSSP-REQUEST` fallback went diff --git a/Directory.Packages.props b/Directory.Packages.props index 0a0e38d0..261ccd62 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ - + diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 5233e5ef..4de3a504 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -411,14 +411,11 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) CurrentPrompt = prompt; PromptChanged?.Invoke(this, prompt); - // And into the pane, which is where a reader actually looks for it. Before this, a prompt - // was stored and shown nowhere: 22bd97f deleted the status-bar render that was the only - // consumer of CurrentPrompt, and PromptChanged's one remaining subscriber rebuilds the - // input-bar label from the character and world without ever reading the prompt. A prompt - // is a line the server sent — it belongs in the scrollback, in the search index and in - // front of the triggers, which is where Mudlet puts it too. `raw`, not `prompt`: this - // takes `ProcessOutputLine` through StripIncomingColour/ExpandTabs/triggers/ApplyEmoji - // itself, and handing it the already-substituted line would substitute twice. + // And into the pane, which is where a reader actually looks for it. A prompt is a line + // the server sent — it belongs in the scrollback, in the search index and in front of + // the triggers, which is where Mudlet puts it too. `raw`, not `prompt`: this takes + // `ProcessOutputLine` through StripIncomingColour/ExpandTabs/triggers/ApplyEmoji itself, + // and handing it the already-substituted line would substitute twice. ProcessOutputLine(raw); return; } From f84c1eca7be8d583e2ec81fcaf7e4283457da3a9 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 26 Aug 2026 19:27:15 -0500 Subject: [PATCH 3/9] fix(prompt): don't print a blank line for a style-only boundary, and flag prompt lines WorldSession.OnOutputReceived's prompt branch defaulted a null AnsiParser.Flush() to StyledLine.Empty and called ProcessOutputLine unconditionally, so a server that ends a burst in bare SGR (or an erase sequence) and then goes quiet for the hold printed a blank line into the pane, scrollback, spill, transcript and restore log once per prompt, forever. ProcessOutputLine now only runs when the parser actually buffered a printable run; CurrentPrompt/PromptChanged still update either way. This is the opposite case from 2.8.1's fix(line), which is a genuinely blank line the library submits and must keep printing. Also wires the prompt flag the plan called for and Task 5 dropped: StyledLine gains IsPrompt (a WithPrompt(bool) builder, mirroring WithRule), set once at the end of ProcessOutputLine on the line actually delivered rather than threaded through StripColour/ExpandTabs/the trigger engine/emoji substitution. It reaches LinePrinted and SpawnLineEventArgs for free (both already carry a StyledLine) and round-trips through StyledLineCodec via a previously-unused flag bit, so RestoreLog needs no format-version bump. Wired, not acted on: nothing yet gags, logs, restores, searches or badges a prompt differently because of this bit. Co-Authored-By: Claude Opus 5 (1M context) --- src/SharpMUTerm.Core/Session/WorldSession.cs | 33 +++++++++-- src/SharpMUTerm.Core/Text/StyledLine.cs | 23 +++++++- src/SharpMUTerm.Core/Text/StyledLineCodec.cs | 18 ++++-- .../Session/WorldSessionPromptTests.cs | 56 +++++++++++++++++++ .../Text/StyledLineCodecTests.cs | 15 +++++ 5 files changed, 134 insertions(+), 11 deletions(-) diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 4de3a504..96504d7d 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -406,8 +406,10 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) if (e.IsPrompt) { _parser.Feed(e.Text); - var raw = _parser.Flush() ?? StyledLine.Empty; - var prompt = ApplyEmoji(_text?.StripIncomingColour == true ? StyledText.StripColour(raw) : raw); + var flushed = _parser.Flush(); + var raw = flushed ?? StyledLine.Empty; + var prompt = ApplyEmoji(_text?.StripIncomingColour == true ? StyledText.StripColour(raw) : raw) + .WithPrompt(true); CurrentPrompt = prompt; PromptChanged?.Invoke(this, prompt); @@ -416,7 +418,21 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) // the triggers, which is where Mudlet puts it too. `raw`, not `prompt`: this takes // `ProcessOutputLine` through StripIncomingColour/ExpandTabs/triggers/ApplyEmoji itself, // and handing it the already-substituted line would substitute twice. - ProcessOutputLine(raw); + // + // Only when the parser actually buffered a printable run. A prompt boundary can arrive + // with nothing but style codes ahead of it — a bare `ESC[0m`, an erase sequence, a burst + // that ends in SGR and then goes quiet for the hold — and `AnsiParser.Flush()` returns + // null for exactly that: nothing was ever appended to `_lineSpans`. That is a boundary, + // not a blank line the server sent, so nothing is printed for it (CurrentPrompt and + // PromptChanged still update above). This is the opposite case from `fix(line)` + // (TelnetNegotiationCore 2.8.1, see CLAUDE.md): that one is the library submitting a + // genuinely blank *line*, which does belong in the pane via the + // `ProcessOutputLine(StyledLine.Empty)` call below — do not merge the two. + if (flushed is not null) + { + ProcessOutputLine(raw, isPrompt: true); + } + return; } @@ -449,7 +465,14 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) 0, TextSettings.MaxTabWidth); - private void ProcessOutputLine(StyledLine line) + /// + /// Runs one line through colour-stripping, tab expansion, the trigger engine, link detection and + /// emoji substitution, then delivers it to whatever destinations the triggers decided on. + /// is not threaded through any of those steps — none of them + /// are asked to preserve — it is applied once, at the end, to + /// the line actually delivered, so it survives regardless of what the pipeline did internally. + /// + private void ProcessOutputLine(StyledLine line, bool isPrompt = false) { // Colour is stripped from what the *server* sent, before the triggers run: a highlight rule // and this client's own system/echo lines are not "incoming ANSI colour" and keep theirs. @@ -477,7 +500,7 @@ private void ProcessOutputLine(StyledLine line) // rather than before it, so a link's target is exactly the text under it — a span whose visible // text and destination disagree is the shape of a phishing link, and this client should not be // in the business of manufacturing one. - var shown = ApplyLinks(ApplyEmoji(result.Line)); + var shown = ApplyLinks(ApplyEmoji(result.Line)).WithPrompt(isPrompt); foreach (var target in result.SpawnTargets) { diff --git a/src/SharpMUTerm.Core/Text/StyledLine.cs b/src/SharpMUTerm.Core/Text/StyledLine.cs index eb89d4fd..a73d66cd 100644 --- a/src/SharpMUTerm.Core/Text/StyledLine.cs +++ b/src/SharpMUTerm.Core/Text/StyledLine.cs @@ -11,11 +11,12 @@ public sealed class StyledLine private readonly StyledSpan[] _spans; private string? _text; - public StyledLine(IEnumerable spans, TerminalColor? ruleColor = null) + public StyledLine(IEnumerable spans, TerminalColor? ruleColor = null, bool isPrompt = false) { ArgumentNullException.ThrowIfNull(spans); _spans = spans.Where(s => s.Length > 0).ToArray(); RuleColor = ruleColor; + IsPrompt = isPrompt; } /// @@ -24,8 +25,26 @@ public StyledLine(IEnumerable spans, TerminalColor? ruleColor = null /// public TerminalColor? RuleColor { get; } + /// + /// True when this line is a prompt — a telnet IAC GA/EOR boundary, or + /// TelnetNegotiationCore's packet-patch silence heuristic, rather than a line the server + /// terminated with its own newline (). + /// + /// Set once, at the end of WorldSession.ProcessOutputLine, on the line actually delivered + /// to LinePrinted/SpawnLine — not threaded through StripColour, + /// ExpandTabs, the trigger engine or emoji substitution, none of which are asked to + /// preserve it. A consumer can tell a prompt from an ordinary line; as of this flag's + /// introduction nothing yet treats one differently (what gags, logs, restores, searches or + /// badges is unchanged). + /// + /// + public bool IsPrompt { get; } + /// Returns a copy of this line carrying the given trigger-highlight rule colour. - public StyledLine WithRule(TerminalColor color) => new(_spans, color); + public StyledLine WithRule(TerminalColor color) => new(_spans, color, IsPrompt); + + /// Returns a copy of this line with set to . + public StyledLine WithPrompt(bool isPrompt) => isPrompt == IsPrompt ? this : new(_spans, RuleColor, isPrompt); /// An empty line (a blank row of output). public static StyledLine Empty { get; } = new(Array.Empty()); diff --git a/src/SharpMUTerm.Core/Text/StyledLineCodec.cs b/src/SharpMUTerm.Core/Text/StyledLineCodec.cs index 0588877a..55b7133f 100644 --- a/src/SharpMUTerm.Core/Text/StyledLineCodec.cs +++ b/src/SharpMUTerm.Core/Text/StyledLineCodec.cs @@ -18,7 +18,7 @@ namespace SharpMUTerm.Core.Text; /// 7-bit-length-prefixed UTF-8, so text needs no escaping and may contain any character including /// control bytes and newlines): /// -/// byte flags bit 0: a RuleColor follows +/// byte flags bit 0: a RuleColor follows; bit 1: line.IsPrompt /// colour ruleColor (present only when bit 0 is set) /// 7bit spanCount /// spanCount times: @@ -44,6 +44,14 @@ public static class StyledLineCodec { private const byte FlagHasRuleColor = 1 << 0; + /// + /// A previously-unused bit added for . Backward-compatible by + /// construction, so this did not need a RestoreLog format-version bump: a record written + /// before this bit existed always has it clear, and reading that as "not a prompt" is simply + /// correct — that data genuinely predates the concept, not a value being reinterpreted. + /// + private const byte FlagIsPrompt = 1 << 1; + /// /// The encoding used for every string field. Replacement (not exception) fallback, so an /// unpaired surrogate — which nothing in the inbound pipeline can produce, since text is decoded @@ -57,7 +65,7 @@ public static void Write(BinaryWriter writer, StyledLine line) ArgumentNullException.ThrowIfNull(writer); ArgumentNullException.ThrowIfNull(line); - var flags = (byte)(line.RuleColor is null ? 0 : FlagHasRuleColor); + var flags = (byte)((line.RuleColor is null ? 0 : FlagHasRuleColor) | (line.IsPrompt ? FlagIsPrompt : 0)); writer.Write(flags); if (line.RuleColor is { } rule) { @@ -102,6 +110,7 @@ public static StyledLine Read(BinaryReader reader) var flags = reader.ReadByte(); TerminalColor? rule = (flags & FlagHasRuleColor) != 0 ? ReadColor(reader) : null; + var isPrompt = (flags & FlagIsPrompt) != 0; var spanCount = reader.Read7BitEncodedInt(); if (spanCount < 0) @@ -111,7 +120,8 @@ public static StyledLine Read(BinaryReader reader) if (spanCount == 0) { - return rule is null ? StyledLine.Empty : StyledLine.Empty.WithRule(rule.Value); + var empty = rule is null ? StyledLine.Empty : StyledLine.Empty.WithRule(rule.Value); + return empty.WithPrompt(isPrompt); } var spans = new StyledSpan[spanCount]; @@ -136,7 +146,7 @@ public static StyledLine Read(BinaryReader reader) spans[i] = new StyledSpan(text, style, interaction); } - return new StyledLine(spans, rule); + return new StyledLine(spans, rule, isPrompt); } /// Encodes one line's payload to a fresh array (convenience for tests and callers off the hot path). diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs index ced207ba..34aa712b 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs @@ -1,6 +1,7 @@ using SharpMUTerm.Core.Automation; using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Text; namespace SharpMUTerm.Core.Tests.Session; @@ -47,4 +48,59 @@ public async Task APromptRunsThroughTheTriggersLikeAnyOtherLine() await Assert.That(spawned!.Target).IsEqualTo("Vitals"); await Assert.That(spawned!.Line.Text).IsEqualTo("HP:100>"); } + + [Test] + public async Task APromptIsFlaggedAsAPromptAndAnOrdinaryLineIsNot() + { + var (session, telnet) = Create(World()); + StyledLine? printed = null; + session.LinePrinted += (_, l) => printed = l; + await session.ConnectAsync(); + + telnet.EmitLine("You see a troll."); + await Assert.That(printed).IsNotNull(); + await Assert.That(printed!.IsPrompt).IsFalse(); + + printed = null; + telnet.EmitPrompt("HP:100>"); + + await Assert.That(printed).IsNotNull(); + await Assert.That(printed!.IsPrompt).IsTrue(); + await Assert.That(session.CurrentPrompt!.IsPrompt).IsTrue(); + } + + [Test] + public async Task AStyleOnlyPromptUpdatesCurrentPromptButPrintsNoLine() + { + var (session, telnet) = Create(World()); + StyledLine? printed = null; + var printedCount = 0; + session.LinePrinted += (_, l) => + { + printed = l; + printedCount++; + }; + StyledLine? changed = null; + session.PromptChanged += (_, p) => changed = p; + await session.ConnectAsync(); + + // Reset past the "*** Connecting..."/"*** Connected." system lines ConnectAsync + // itself prints — those raise LinePrinted too, and would otherwise be mistaken for + // the bug under test. + printed = null; + printedCount = 0; + + // A prompt boundary that carries only an SGR reset and nothing printable — the shape + // AnsiParser.Flush() reports as "nothing buffered" (returns null), which is the case + // WorldSession.OnOutputReceived must not paper over with a blank StyledLine.Empty print. + telnet.EmitPrompt(""); + + await Assert.That(printedCount).IsEqualTo(0); + await Assert.That(printed).IsNull(); + await Assert.That(session.CurrentPrompt).IsNotNull(); + await Assert.That(session.CurrentPrompt!.Text).IsEqualTo(string.Empty); + await Assert.That(session.CurrentPrompt!.IsPrompt).IsTrue(); + await Assert.That(changed).IsNotNull(); + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == string.Empty)).IsFalse(); + } } diff --git a/tests/SharpMUTerm.Core.Tests/Text/StyledLineCodecTests.cs b/tests/SharpMUTerm.Core.Tests/Text/StyledLineCodecTests.cs index 41aad2cd..9fa347c0 100644 --- a/tests/SharpMUTerm.Core.Tests/Text/StyledLineCodecTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Text/StyledLineCodecTests.cs @@ -21,6 +21,7 @@ private static async Task AssertRoundTrips(StyledLine line) } await Assert.That(decoded.RuleColor).IsEqualTo(line.RuleColor); + await Assert.That(decoded.IsPrompt).IsEqualTo(line.IsPrompt); } [Test] @@ -37,6 +38,20 @@ public async Task EmptyLineWithRule_KeepsTheRule() await AssertRoundTrips(line); } + [Test] + public async Task PromptFlag_RoundTrips() + { + await AssertRoundTrips(StyledLine.FromText("HP:100>", TextStyle.Default).WithPrompt(true)); + } + + [Test] + public async Task PromptFlag_RoundTripsOnAnEmptyLineWithARule() + { + var line = StyledLine.Empty.WithRule(TerminalColor.FromRgb(1, 2, 3)).WithPrompt(true); + await AssertRoundTrips(line); + await Assert.That(StyledLineCodec.Decode(StyledLineCodec.Encode(line)).IsEmpty).IsTrue(); + } + [Test] public async Task EveryColourKindAndAttributeCombination_RoundTrips() { From caf0db5fd86918ce9b36c4c3ac28a73393d9a9fa Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 26 Aug 2026 19:27:21 -0500 Subject: [PATCH 4/9] fix(telnet): correct stale Encoding.ASCII claims to Encoding.UTF8 04e0e26 corrected CLAUDE.md's claim about TelnetInterpreter.CurrentEncoding's default (verified against TelnetStandardInterpreter.cs:46: it's Encoding.UTF8, not ASCII) and left five comments and a user-visible warning string in TelnetSession.cs still asserting ASCII. The branch touched the fact; it owns fixing the source that already contradicted it. Co-Authored-By: Claude Opus 5 (1M context) --- src/SharpMUTerm.Core/Telnet/TelnetSession.cs | 33 ++++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index d198feae..43c2aac9 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -242,12 +242,13 @@ public sealed class TelnetSession : ITelnetSession // TelnetInterpreter.CurrentEncoding has an internal setter, which CharsetProtocol itself writes // through reflection once negotiation settles. We seed it the same way at connect time, for two - // reasons. It *defaults to Encoding.ASCII*, and that default is not inert: it is handed to - // CallbackOnByteAsync/CallbackOnSubmitAsync for every byte, and used to decode GMCP, MSDP and MSSP - // payloads and to encode everything we send — so before negotiation, or on the many MU* servers - // that never implement RFC 2066 at all, every byte above 0x7F became '?'. And because the seed is - // an instance nothing else can produce, "has CHARSET settled?" becomes an exact reference - // comparison rather than a guess about a value that might legitimately be ASCII. + // reasons. It *defaults to Encoding.UTF8* (TelnetStandardInterpreter.cs:46), and that default is + // not inert: it is handed to CallbackOnByteAsync/CallbackOnSubmitAsync for every byte, and used to + // decode GMCP, MSDP and MSSP payloads and to encode everything we send — so before negotiation, a + // world pinned to a non-UTF-8 override, or a server that never implements RFC 2066 at all, would + // decode against UTF-8 regardless of what this session decided. And because the seed is an + // instance nothing else can produce, "has CHARSET settled?" becomes an exact reference comparison + // rather than a guess about a value that might legitimately be UTF-8. private static readonly PropertyInfo? InterpreterEncodingProperty = typeof(TelnetInterpreter).GetProperty(nameof(TelnetInterpreter.CurrentEncoding)) is { CanWrite: true } p ? p @@ -291,8 +292,10 @@ public TelnetSession(ITransport transport, ILogger? logger = null, TelnetSession /// /// Precedence is override, then negotiation, then the head of the stated preference order. The /// last arm is what a server that never speaks CHARSET lands on: the app's first preference, - /// normally UTF-8. It is emphatically not the interpreter's own Encoding.ASCII - /// default, which is what used to reach the decode path and mangle every non-ASCII byte. + /// normally UTF-8. It is emphatically not the interpreter's own unseeded + /// Encoding.UTF8 default (TelnetStandardInterpreter.cs:46) reached before this + /// session's own decision — an override to a non-UTF-8 charset, or the head of a non-default + /// — has had any say. /// /// public SessionEncoding CurrentEncoding @@ -423,17 +426,21 @@ private Task BuildInterpreterAsync() } /// - /// Replaces the interpreter's default with what this session would - /// otherwise assume, so nothing decodes as ASCII merely because negotiation hasn't happened yet — - /// including the GMCP/MSDP/MSSP payloads and outbound bytes the library encodes for itself. The - /// library overwrites this the moment CHARSET settles. + /// Replaces the interpreter's unseeded default + /// (TelnetStandardInterpreter.cs:46) with what this session would otherwise assume, so + /// nothing decodes against that default merely because negotiation hasn't happened yet — + /// including the GMCP/MSDP/MSSP payloads and outbound bytes the library encodes for itself. A + /// world pinned to a non-UTF-8 override is exactly the case this protects: without seeding, its + /// bytes would decode as UTF-8 until CHARSET settles or forever on a server that never negotiates + /// it. The library overwrites this the moment CHARSET settles. /// private void SeedInterpreterEncoding(TelnetInterpreter interpreter) { if (InterpreterEncodingProperty is null) { _logger.LogWarning( - "TelnetInterpreter.CurrentEncoding is not writable; pre-negotiation payloads will decode as ASCII."); + "TelnetInterpreter.CurrentEncoding is not writable; pre-negotiation payloads will decode as UTF-8 " + + "regardless of this session's own encoding decision."); return; } From bb52d9b7b92ecd2c9931f8a603ceea50715212f1 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 26 Aug 2026 19:27:31 -0500 Subject: [PATCH 5/9] docs: the prompt render reaches every surface a line does, and two nuget traps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md gained a paragraph about the TelnetNegotiationCore 2.12.0 bump and not one word about the prompt render it was for, despite the branch changing what reaches the scrollback, FileScrollbackSpill, the transcript sinks, RestoreLog, OutputSearch, the automation engines and the unread/activity/away bars — every one of which has a dedicated bullet here that was wrong by omission. Adds a Repository state bullet: what 22bd97f broke (CurrentPrompt rendered nowhere), what it cost (a prompt reached none of the above surfaces), what cd06e8b changed (a prompt is now an ordinary line through ProcessOutputLine, reaching all of them — one extra line per command on a MUD that prompts per command, stated rather than hidden), and the decisions not to relitigate (lands exactly once; a printable-free boundary prints no line). Also: a line beside the TNC 2.12.0 bullet saying cd06e8b and 04e0e26 must not be separated (reverting the render alone while 2.12.0 stays pinned silently drops every packet-patch-inferred prompt fragment instead of merely mis-wrapping it), and corrects CLAUDE.md:44's claim that a clean checkout builds and passes — true only on this machine, where a local feed populated the 2.12.0 package cache; a clean checkout fails NU1101 until TNC 2.12.0 is on nuget.org, and a mismatched publish afterwards fails NU1403 until that cache directory is deleted (not worked around with a nuget.config pointing at /tmp). Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a48cb938..e4c441be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,11 @@ fallbacks) for inline images/maps. ## Repository state **M1 delivered, plus substantial M2–M4 work.** `SharpMUTerm.slnx` builds all ten projects on -`net10.0`, with the full test suite passing. In place: +`net10.0`, with the full test suite passing — **on this machine.** On a clean checkout that is not +yet true: TelnetNegotiationCore 2.12.0 is not published to nuget.org as of this writing, and restore +fails `NU1101` until it is. This machine only builds because `~/.nuget/packages/telnetnegotiationcore/2.12.0/` +was populated from a local feed; see the TNC 2.12.0 entry under *Other dependency notes* for the +publish-day trap that follows from that. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model (a capped in-memory ring plus a **file-backed spill**, `FileScrollbackSpill`, so history deeper than memory is @@ -520,6 +524,36 @@ fallbacks) for inline images/maps. - **A highlight rule needs no route to reach the pane a capture rule sent the line to.** There is one line and one set of destinations, and every matched rule's highlight is on it. Do not "fix" that into a per-rule delivery. +- **A prompt reaches the pane again, and everywhere a line reaches** (`WorldSession.OnOutputReceived`, + Core; `fix(prompt)`, `cd06e8b`). `22bd97f`'s header/status-bar rewrite deleted the one render that ever + read `CurrentPrompt` and left `PromptChanged`'s sole remaining subscriber rebuilding the input-bar label + from the character and world, never from the prompt it was handed — so from that commit until this fix, + every prompt a server sent (an `IAC GA`/`EOR`, or TelnetNegotiationCore's packet-patch silence heuristic) + was parsed, stored in `CurrentPrompt`, and shown nowhere: not the pane, not the scrollback, not + `FileScrollbackSpill`, not a transcript, not `RestoreLog`, not `⌃F`'s search index, and no trigger ever + saw it. **A prompt is now an ordinary line through `ProcessOutputLine`**, the same function every other + line of output runs through, so it reaches all of those surfaces exactly as a line the server terminated + with its own newline would — scrollback and the spill, `PlainTextLogSink`/`HtmlLogSink`, `RestoreLog`, + `OutputSearch`, the trigger/alias/macro engines, and the unread/activity/away bars, which see it as + arriving content like any other. **On a MUD that prompts after every command, this is one extra line per + command** — stated rather than hidden, because that is the cost of the fix and not a defect in it; Mudlet + does the same and it is what "searchable and trigger-visible" requires. Two decisions not to relitigate. + **It lands exactly once.** `ProcessOutputLine` is handed the pre-emoji `raw` line, not the + already-substituted one built for `CurrentPrompt`/`PromptChanged` — `ProcessOutputLine` runs + `StripIncomingColour`/`ExpandTabs`/triggers/`ApplyEmoji` itself, and handing it the substituted line + would substitute twice. **A prompt boundary with no printable run prints no line.** A burst that ends in + bare SGR (`ESC[0m`) or an erase sequence and then goes quiet for the hold buffers nothing in + `AnsiParser`, so `Flush()` returns null; `CurrentPrompt`/`PromptChanged` still update (to an empty, + prompt-flagged line), but `ProcessOutputLine` is not called — that is a boundary, not a blank line the + server sent, and is the opposite case from `fix(line)`'s genuinely blank line above, which does print. + Getting this backwards means a blank line lands in every buffer once per prompt, forever. + - **The line carries `StyledLine.IsPrompt`**, set once at the end of `ProcessOutputLine` on the line + actually delivered — not threaded through `StripColour`/`ExpandTabs`/the trigger engine/emoji + substitution, none of which are asked to preserve it. It travels through `LinePrinted` and + `SpawnLineEventArgs` for free, since both already carry a `StyledLine`, and round-trips through + `StyledLineCodec` (a previously-unused flag bit, so no `RestoreLog` format-version bump). **Wired, + not yet acted on**: nothing today gags, logs, restores, searches or badges a prompt any differently + from an ordinary line because of this bit. That is a decision the owner has not made. ## Building and testing @@ -1291,6 +1325,18 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] doesn't apply. The `CallbackOnByteAsync` property this file reaches by reflection is unchanged across the whole jump: still `public Func? CallbackOnByteAsync { get; init; }` on `TelnetInterpreter`, confirmed by reading the 2.12.0 source rather than assumed. + - **`cd06e8b` (the prompt render) and `04e0e26` (this 2.12.0 bump) must not be separated.** Reverting + the render alone while 2.12.0 stays pinned sends every packet-patch-inferred prompt fragment to + `CurrentPrompt`, which nothing renders — the text is simply gone, silently. Before 2.12.0 that same + fragment was merely glued to the head of the next line, which reads as a cosmetic wrap issue rather + than lost text. Revert both together or neither. + - **Not yet published to nuget.org as of this writing, and there is a publish-day trap.** A clean + checkout's restore fails `NU1101` until 2.12.0 is published — this machine only builds because + `~/.nuget/packages/telnetnegotiationcore/2.12.0/` was populated from a local feed. Once the real + package is published, if its bits differ at all from the locally-built nupkg this cache holds, restore + on this machine fails `NU1403` (package content mismatch) until that cache directory is deleted; a + clean checkout with no such cache is unaffected. **Do not commit a `nuget.config` pointing at `/tmp`** + or any other local-feed workaround as a fix for either failure — delete the stale cache instead. - **A prompt ends with `IAC EOR` or `IAC GA`, and until 2.11.0 this client could only see the first of them.** `TelnetSession.OnPromptAsync` is the only thing that flushes `_pending` — the unterminated line `CallbackOnByteAsync` accumulates — so a server whose prompt boundary the library From 88c33e57887e74fe115f0506f4b085e8d38a75a8 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 26 Aug 2026 19:40:11 -0500 Subject: [PATCH 6/9] docs: strip investigation narration from prompt-render comments Trim the branch's code comments and CLAUDE.md entries down to the load-bearing rule: drop measurements, server names, and blow-by-blow history of what broke and how it was found; keep the constraints a future edit must not violate (raw vs. prompt, the null-Flush boundary case, cd06e8b/04e0e26 shipping together). Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 62 ++++++++------------ src/SharpMUTerm.Core/Session/WorldSession.cs | 27 +++------ 2 files changed, 34 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e4c441be..838a7463 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -526,34 +526,27 @@ publish-day trap that follows from that. In place: a per-rule delivery. - **A prompt reaches the pane again, and everywhere a line reaches** (`WorldSession.OnOutputReceived`, Core; `fix(prompt)`, `cd06e8b`). `22bd97f`'s header/status-bar rewrite deleted the one render that ever - read `CurrentPrompt` and left `PromptChanged`'s sole remaining subscriber rebuilding the input-bar label - from the character and world, never from the prompt it was handed — so from that commit until this fix, - every prompt a server sent (an `IAC GA`/`EOR`, or TelnetNegotiationCore's packet-patch silence heuristic) - was parsed, stored in `CurrentPrompt`, and shown nowhere: not the pane, not the scrollback, not - `FileScrollbackSpill`, not a transcript, not `RestoreLog`, not `⌃F`'s search index, and no trigger ever - saw it. **A prompt is now an ordinary line through `ProcessOutputLine`**, the same function every other - line of output runs through, so it reaches all of those surfaces exactly as a line the server terminated - with its own newline would — scrollback and the spill, `PlainTextLogSink`/`HtmlLogSink`, `RestoreLog`, - `OutputSearch`, the trigger/alias/macro engines, and the unread/activity/away bars, which see it as - arriving content like any other. **On a MUD that prompts after every command, this is one extra line per - command** — stated rather than hidden, because that is the cost of the fix and not a defect in it; Mudlet - does the same and it is what "searchable and trigger-visible" requires. Two decisions not to relitigate. + read `CurrentPrompt`, so a prompt was parsed and stored but shown nowhere. **A prompt is now an ordinary + line through `ProcessOutputLine`**, the same function every other line of output runs through, so it + reaches scrollback and the spill, `PlainTextLogSink`/`HtmlLogSink`, `RestoreLog`, `OutputSearch`, the + trigger/alias/macro engines, and the unread/activity/away bars, exactly as a line the server terminated + with its own newline would. **On a MUD that prompts after every command, this is one extra line per + command** — the cost of the fix, not a defect in it; Mudlet does the same, and it is what + "searchable and trigger-visible" requires. Two decisions not to relitigate. **It lands exactly once.** `ProcessOutputLine` is handed the pre-emoji `raw` line, not the - already-substituted one built for `CurrentPrompt`/`PromptChanged` — `ProcessOutputLine` runs - `StripIncomingColour`/`ExpandTabs`/triggers/`ApplyEmoji` itself, and handing it the substituted line - would substitute twice. **A prompt boundary with no printable run prints no line.** A burst that ends in - bare SGR (`ESC[0m`) or an erase sequence and then goes quiet for the hold buffers nothing in - `AnsiParser`, so `Flush()` returns null; `CurrentPrompt`/`PromptChanged` still update (to an empty, - prompt-flagged line), but `ProcessOutputLine` is not called — that is a boundary, not a blank line the - server sent, and is the opposite case from `fix(line)`'s genuinely blank line above, which does print. - Getting this backwards means a blank line lands in every buffer once per prompt, forever. + already-substituted one built for `CurrentPrompt`/`PromptChanged` — handing it the substituted line + would run emoji substitution twice. **A prompt boundary with no printable run prints no line.** A burst + that ends in bare SGR (`ESC[0m`) or an erase sequence and then goes quiet for the hold buffers nothing in + `AnsiParser`, so `Flush()` returns null; `CurrentPrompt`/`PromptChanged` still update, but + `ProcessOutputLine` is not called — that is a boundary, not a blank line the server sent, and is the + opposite case from `fix(line)`'s genuinely blank line, which does print. Getting this backwards means a + blank line lands in every buffer once per prompt, forever. - **The line carries `StyledLine.IsPrompt`**, set once at the end of `ProcessOutputLine` on the line actually delivered — not threaded through `StripColour`/`ExpandTabs`/the trigger engine/emoji - substitution, none of which are asked to preserve it. It travels through `LinePrinted` and - `SpawnLineEventArgs` for free, since both already carry a `StyledLine`, and round-trips through - `StyledLineCodec` (a previously-unused flag bit, so no `RestoreLog` format-version bump). **Wired, - not yet acted on**: nothing today gags, logs, restores, searches or badges a prompt any differently - from an ordinary line because of this bit. That is a decision the owner has not made. + substitution. It travels through `LinePrinted` and `SpawnLineEventArgs` for free, since both already + carry a `StyledLine`, and round-trips through `StyledLineCodec` (a previously-unused flag bit, so no + `RestoreLog` format-version bump). **Wired, not yet acted on**: nothing today gags, logs, restores, + searches or badges a prompt any differently from an ordinary line because of this bit. ## Building and testing @@ -1314,17 +1307,14 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] sequence behind it. **2.10.0** carries the raw bytes of each MSSP value beside the decoded string. **2.11.0** is the prompt-marker fix below. **2.12.0** is the one that actually motivated a further jump: it ships **`PacketPatchProtocol`**, included in `AddDefaultMUDProtocols` - automatically (an appended, defaulted `packetPatchHoldTime` parameter this codebase doesn't set), - which infers a prompt boundary from 500ms of silence for the servers that mark none at all, and is - what delivers the connect-screen prompt on both `tdome.nukefire.org` and `starwars.d20mud.com` — - neither negotiates GA, EOR or SUPPRESS-GO-AHEAD, so both go through the packet-patch heuristic, - confirmed by a ~700–790ms gap between the last banner line and the prompt (the 500ms hold time plus - network round-trips), not an instant marker. `IProtocolContext` gained three members in 2.12.0, one - of them binary-breaking (`TakePartialLineAsPrompt` now returns `bool`) — this codebase never - implements that interface directly, only consumes the library's own `TelnetInterpreter`, so it - doesn't apply. The `CallbackOnByteAsync` property this file reaches by reflection is unchanged - across the whole jump: still `public Func? CallbackOnByteAsync { get; init; }` - on `TelnetInterpreter`, confirmed by reading the 2.12.0 source rather than assumed. + automatically (an appended, defaulted `packetPatchHoldTime` parameter this codebase doesn't set) + and infers a prompt boundary from 500ms of silence for servers that mark none at all. + `IProtocolContext` gained three members in 2.12.0, one of them binary-breaking + (`TakePartialLineAsPrompt` now returns `bool`) — this codebase never implements that interface + directly, only consumes the library's own `TelnetInterpreter`, so it doesn't apply. The + `CallbackOnByteAsync` property this file reaches by reflection is unchanged across the whole jump: + still `public Func? CallbackOnByteAsync { get; init; }` on + `TelnetInterpreter`. - **`cd06e8b` (the prompt render) and `04e0e26` (this 2.12.0 bump) must not be separated.** Reverting the render alone while 2.12.0 stays pinned sends every packet-patch-inferred prompt fragment to `CurrentPrompt`, which nothing renders — the text is simply gone, silently. Before 2.12.0 that same diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 96504d7d..8faa309c 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -413,21 +413,13 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) CurrentPrompt = prompt; PromptChanged?.Invoke(this, prompt); - // And into the pane, which is where a reader actually looks for it. A prompt is a line - // the server sent — it belongs in the scrollback, in the search index and in front of - // the triggers, which is where Mudlet puts it too. `raw`, not `prompt`: this takes - // `ProcessOutputLine` through StripIncomingColour/ExpandTabs/triggers/ApplyEmoji itself, - // and handing it the already-substituted line would substitute twice. + // Uses raw, not prompt: ProcessOutputLine runs StripIncomingColour/ExpandTabs/triggers/ + // ApplyEmoji itself, so the already-substituted line would double-substitute. // - // Only when the parser actually buffered a printable run. A prompt boundary can arrive - // with nothing but style codes ahead of it — a bare `ESC[0m`, an erase sequence, a burst - // that ends in SGR and then goes quiet for the hold — and `AnsiParser.Flush()` returns - // null for exactly that: nothing was ever appended to `_lineSpans`. That is a boundary, - // not a blank line the server sent, so nothing is printed for it (CurrentPrompt and - // PromptChanged still update above). This is the opposite case from `fix(line)` - // (TelnetNegotiationCore 2.8.1, see CLAUDE.md): that one is the library submitting a - // genuinely blank *line*, which does belong in the pane via the - // `ProcessOutputLine(StyledLine.Empty)` call below — do not merge the two. + // Only when flushed is not null. A prompt boundary with no printable run (a bare SGR + // reset, an erase sequence) makes Flush() return null — that is a boundary, not a blank + // line the server sent, unlike the deliberate ProcessOutputLine(StyledLine.Empty) call + // below. Do not merge the two. if (flushed is not null) { ProcessOutputLine(raw, isPrompt: true); @@ -466,11 +458,8 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) TextSettings.MaxTabWidth); /// - /// Runs one line through colour-stripping, tab expansion, the trigger engine, link detection and - /// emoji substitution, then delivers it to whatever destinations the triggers decided on. - /// is not threaded through any of those steps — none of them - /// are asked to preserve — it is applied once, at the end, to - /// the line actually delivered, so it survives regardless of what the pipeline did internally. + /// is not threaded through the pipeline below (colour-strip, tab + /// expand, triggers, emoji) — it is applied once, at the end, to the line actually delivered. /// private void ProcessOutputLine(StyledLine line, bool isPrompt = false) { From 21d3a91d0eca01309439641f0145349fe42a1651 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 27 Aug 2026 13:47:59 -0500 Subject: [PATCH 7/9] docs: 2.12.0 is published; keep the local-feed trap as a rule Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 838a7463..fc05b398 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,11 +42,7 @@ fallbacks) for inline images/maps. ## Repository state **M1 delivered, plus substantial M2–M4 work.** `SharpMUTerm.slnx` builds all ten projects on -`net10.0`, with the full test suite passing — **on this machine.** On a clean checkout that is not -yet true: TelnetNegotiationCore 2.12.0 is not published to nuget.org as of this writing, and restore -fails `NU1101` until it is. This machine only builds because `~/.nuget/packages/telnetnegotiationcore/2.12.0/` -was populated from a local feed; see the TNC 2.12.0 entry under *Other dependency notes* for the -publish-day trap that follows from that. In place: +`net10.0`, with the full test suite passing. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model (a capped in-memory ring plus a **file-backed spill**, `FileScrollbackSpill`, so history deeper than memory is @@ -1320,13 +1316,11 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] `CurrentPrompt`, which nothing renders — the text is simply gone, silently. Before 2.12.0 that same fragment was merely glued to the head of the next line, which reads as a cosmetic wrap issue rather than lost text. Revert both together or neither. - - **Not yet published to nuget.org as of this writing, and there is a publish-day trap.** A clean - checkout's restore fails `NU1101` until 2.12.0 is published — this machine only builds because - `~/.nuget/packages/telnetnegotiationcore/2.12.0/` was populated from a local feed. Once the real - package is published, if its bits differ at all from the locally-built nupkg this cache holds, restore - on this machine fails `NU1403` (package content mismatch) until that cache directory is deleted; a - clean checkout with no such cache is unaffected. **Do not commit a `nuget.config` pointing at `/tmp`** - or any other local-feed workaround as a fix for either failure — delete the stale cache instead. + - **Never pin a version that is not on nuget.org, and never work around it with a local feed.** A + pin that only resolves from a local `nuget.config` builds here and fails `NU1101` everywhere else; + if a locally-built nupkg of that version is already in `~/.nuget/packages/`, the real package + publishing later fails `NU1403` (content mismatch) until that cache directory is deleted. Delete + the stale cache; do not commit a `nuget.config` pointing at `/tmp`. - **A prompt ends with `IAC EOR` or `IAC GA`, and until 2.11.0 this client could only see the first of them.** `TelnetSession.OnPromptAsync` is the only thing that flushes `_pending` — the unterminated line `CallbackOnByteAsync` accumulates — so a server whose prompt boundary the library From 7fb26cf1bdd4b2799ea628f9aed5dfae50fd46e8 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 27 Aug 2026 14:09:56 -0500 Subject: [PATCH 8/9] fix(telnet): narrow the CurrentEncoding-not-writable warning to what it actually risks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged the warning as overstating the blast radius: it read as if ordinary output lines could decode wrong. They can't — OnSubmitAsync decodes newline-terminated lines with this session's own CurrentEncoding.Encoding, not the interpreter's. What the unwritable property actually leaves exposed is interpreter-managed traffic: pre-negotiation GMCP/MSDP/MSSP payloads and the bytes the library encodes outbound for itself, both of which fall back to the interpreter's unseeded UTF-8 default. Reworded to name that instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/SharpMUTerm.Core/Telnet/TelnetSession.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index 43c2aac9..e8dd4c91 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -439,8 +439,10 @@ private void SeedInterpreterEncoding(TelnetInterpreter interpreter) if (InterpreterEncodingProperty is null) { _logger.LogWarning( - "TelnetInterpreter.CurrentEncoding is not writable; pre-negotiation payloads will decode as UTF-8 " - + "regardless of this session's own encoding decision."); + "TelnetInterpreter.CurrentEncoding is not writable; pre-negotiation GMCP/MSDP/MSSP payloads and " + + "outbound bytes the library encodes for itself will decode/encode against its unseeded UTF-8 " + + "default regardless of this session's own encoding decision. Ordinary output lines are " + + "unaffected — OnSubmitAsync decodes those with this session's own CurrentEncoding.Encoding."); return; } From d88b731c1ecd56d0f924e0d79482c0bac19b374e Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 27 Aug 2026 14:32:42 -0500 Subject: [PATCH 9/9] docs: correct the 2.12.0 IProtocolContext note and the callback frequencies Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 7 ++++--- src/SharpMUTerm.Core/Telnet/TelnetSession.cs | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fc05b398..3351bc22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1305,9 +1305,10 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] a further jump: it ships **`PacketPatchProtocol`**, included in `AddDefaultMUDProtocols` automatically (an appended, defaulted `packetPatchHoldTime` parameter this codebase doesn't set) and infers a prompt boundary from 500ms of silence for servers that mark none at all. - `IProtocolContext` gained three members in 2.12.0, one of them binary-breaking - (`TakePartialLineAsPrompt` now returns `bool`) — this codebase never implements that interface - directly, only consumes the library's own `TelnetInterpreter`, so it doesn't apply. The + `IProtocolContext` is unchanged in 2.12.0: the prompt-boundary members + (`TakePartialLineAsPrompt`, `HasPartialLine`, `HasSeenMarkedPrompt`) are public on + `TelnetInterpreter`, reachable through the interface's existing `Interpreter` property, so there + is no public break to migrate for. The `CallbackOnByteAsync` property this file reaches by reflection is unchanged across the whole jump: still `public Func? CallbackOnByteAsync { get; init; }` on `TelnetInterpreter`. diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index e8dd4c91..cd289a25 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -243,8 +243,9 @@ public sealed class TelnetSession : ITelnetSession // TelnetInterpreter.CurrentEncoding has an internal setter, which CharsetProtocol itself writes // through reflection once negotiation settles. We seed it the same way at connect time, for two // reasons. It *defaults to Encoding.UTF8* (TelnetStandardInterpreter.cs:46), and that default is - // not inert: it is handed to CallbackOnByteAsync/CallbackOnSubmitAsync for every byte, and used to - // decode GMCP, MSDP and MSSP payloads and to encode everything we send — so before negotiation, a + // not inert: it is handed to CallbackOnByteAsync once per byte and to CallbackOnSubmitAsync once + // per submitted line, and used to decode GMCP, MSDP and MSSP payloads and to encode everything we + // send — so before negotiation, a // world pinned to a non-UTF-8 override, or a server that never implements RFC 2066 at all, would // decode against UTF-8 regardless of what this session decided. And because the seed is an // instance nothing else can produce, "has CHARSET settled?" becomes an exact reference comparison