From e495a80dc2ba538a83353977428418bac7ca3f3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 23:54:18 +0000 Subject: [PATCH 01/68] M5: MXP + Pueblo parsers, emoji, clickable links, packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advance MuGlyph into milestone M5 (full parity & polish) with tested, UI-agnostic protocol work plus TUI integration and packaging. Protocols & text (Core) - MxpParser and PuebloParser: incremental, line-oriented parsers that turn markup into styled spans — formatting, colours (WebColors), entities, line breaks, and clickable SEND/A links/commands. Unified behind ILineParser alongside AnsiParser. - SpanInteraction: an optional click behaviour (send-command / hyperlink) on StyledSpan, produced by the parsers and realised by the UI. - EmojiSubstitutor: opt-in emoticon (:) -> emoji) and :shortcode: substitution. - WorldDefinition gains ContentFormat (Ansi/Mxp/Pueblo) and Emoji settings; WorldSession selects the parser and applies emoji per world. TUI (M5 integration) - OutputView tracks per-cell interactions and activates command/link spans on click; MuGlyphApp sends commands / opens links, shows a GMCP-driven stat line, and captures spawn-routed output. Deferred review fixes - TelnetSession: clean up on connect failure and clear the interpreter on disconnect so reconnect works; honour cancellation on send. - WorldSession: dispose a prior session before reconnecting. - KittyGraphicsProtocol: reject placeholder image ids above 24 bits. - ScriptException: also parse the "[source]:LINE:" runtime-error format. Packaging - Self-contained single-file publish profiles (linux-x64, win-x64) and a tagged release workflow; docs/PACKAGING.md. Publish knobs are profile-scoped so ordinary build/run/CI never require a RID. 299 tests pass (Core 200, Graphics 57, Scripting 42). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB --- .github/workflows/release.yml | 64 ++ CLAUDE.md | 2 +- README.md | 15 +- docs/PACKAGING.md | 43 ++ docs/PLAN.md | 17 + .../Configuration/WorldDefinition.cs | 29 + src/MuClient.Core/Protocols/MxpParser.cs | 715 ++++++++++++++++++ src/MuClient.Core/Protocols/PuebloParser.cs | 602 +++++++++++++++ src/MuClient.Core/Session/WorldSession.cs | 50 +- src/MuClient.Core/Telnet/TelnetSession.cs | 20 +- src/MuClient.Core/Text/AnsiParser.cs | 2 +- src/MuClient.Core/Text/EmojiSubstitutor.cs | 155 ++++ src/MuClient.Core/Text/ILineParser.cs | 24 + src/MuClient.Core/Text/SpanInteraction.cs | 31 + src/MuClient.Core/Text/StyledSpan.cs | 15 +- src/MuClient.Core/Text/WebColors.cs | 87 +++ .../KittyGraphicsProtocol.cs | 8 + src/MuClient.Scripting/ScriptException.cs | 24 +- src/MuClient.Tui/GmcpStats.cs | 100 +++ src/MuClient.Tui/MuClient.Tui.csproj | 9 + src/MuClient.Tui/MuGlyphApp.cs | 56 ++ .../PublishProfiles/linux-x64.pubxml | 13 + .../Properties/PublishProfiles/win-x64.pubxml | 13 + src/MuClient.Tui/Views/OutputView.cs | 85 ++- .../Protocols/MxpParserTests.cs | 395 ++++++++++ .../Protocols/PuebloParserTests.cs | 323 ++++++++ .../Session/WorldSessionContentTests.cs | 99 +++ .../Text/EmojiSubstitutorTests.cs | 73 ++ .../Text/WebColorsAndInteractionTests.cs | 73 ++ .../KittyImageIdValidationTests.cs | 30 + 30 files changed, 3127 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/PACKAGING.md create mode 100644 src/MuClient.Core/Protocols/MxpParser.cs create mode 100644 src/MuClient.Core/Protocols/PuebloParser.cs create mode 100644 src/MuClient.Core/Text/EmojiSubstitutor.cs create mode 100644 src/MuClient.Core/Text/ILineParser.cs create mode 100644 src/MuClient.Core/Text/SpanInteraction.cs create mode 100644 src/MuClient.Core/Text/WebColors.cs create mode 100644 src/MuClient.Tui/GmcpStats.cs create mode 100644 src/MuClient.Tui/Properties/PublishProfiles/linux-x64.pubxml create mode 100644 src/MuClient.Tui/Properties/PublishProfiles/win-x64.pubxml create mode 100644 tests/MuClient.Core.Tests/Protocols/MxpParserTests.cs create mode 100644 tests/MuClient.Core.Tests/Protocols/PuebloParserTests.cs create mode 100644 tests/MuClient.Core.Tests/Session/WorldSessionContentTests.cs create mode 100644 tests/MuClient.Core.Tests/Text/EmojiSubstitutorTests.cs create mode 100644 tests/MuClient.Core.Tests/Text/WebColorsAndInteractionTests.cs create mode 100644 tests/MuClient.Graphics.Tests/KittyImageIdValidationTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7525509 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + name: publish (${{ matrix.rid }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, rid: linux-x64, profile: linux-x64 } + - { os: windows-latest, rid: win-x64, profile: win-x64 } + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up .NET 10 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Publish single-file + shell: bash + run: > + dotnet publish src/MuClient.Tui/MuClient.Tui.csproj + -p:PublishProfile=${{ matrix.profile }} + -o publish/${{ matrix.rid }} + + - name: Package (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + cd publish/${{ matrix.rid }} + tar -czf ../../muglyph-${{ matrix.rid }}.tar.gz muglyph + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cd publish/${{ matrix.rid }} + 7z a ../../muglyph-${{ matrix.rid }}.zip muglyph.exe + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: muglyph-${{ matrix.rid }} + path: muglyph-${{ matrix.rid }}.* + + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: muglyph-${{ matrix.rid }}.* diff --git a/CLAUDE.md b/CLAUDE.md index 0778faa..8bfda9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ fallbacks) for inline images/maps. ## Repository state **M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all seven projects on -`net10.0`; the solution has **195 passing tests**. In place: +`net10.0`; the solution has **299 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), diff --git a/README.md b/README.md index 024709e..a8acd29 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ client: rich truecolor text, inline graphics, powerful automation, and full MU\* > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (195 tests across the solution). See +> `MuClient.Core` is fully unit-tested (299 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI @@ -53,7 +53,11 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. - **Scrollback** — bounded, thread-safe styled-line model with change events. - **Automation** — regex **triggers** (gag / highlight / rewrite / respond / spawn-route / script), **aliases** (capture-group expansion, multi-command), **macros/keybinds**, and a - recurring/one-shot **timer** scheduler. + recurring/one-shot **timer** scheduler. User regexes run with a ReDoS match-timeout guard. +- **MXP & Pueblo** — first-class parsers for both markup protocols: tags → styled spans, with + **clickable** ``/`` links and commands (`SpanInteraction`), colours, entities, and + line breaks. Selectable per world. +- **Emoji** — optional emoticon (`:)` → 🙂) and `:shortcode:` (`:fire:` → 🔥) substitution. - **Logging** — plain-text and styled **HTML** session logs. - **Config** — a fresh JSON schema plus a best-effort **BeipMU importer**. - **Inline graphics** — Kitty graphics-protocol encoder (incl. Unicode placeholders), Sixel and @@ -62,8 +66,11 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. `timer`/`gmcp`/`log`, with hot-reload. - **Theming** — yazi-style named themes (Dark / Light / Solarized Dark) with a 16-colour palette override and semantic UI colours, serialised to the config as hex. -- **TUI** — a Terminal.Gui v2 app: truecolor output pane with wrapping/scrollback, command input - with history and tab-completion, status line, and key routing. +- **TUI** — a Terminal.Gui v2 app: truecolor output pane with wrapping/scrollback, clickable + MXP/Pueblo links, command input with history and tab-completion, a GMCP-driven stat line, and + key routing. +- **Packaging** — self-contained single-file publishing for Linux/Windows/macOS (see + [`docs/PACKAGING.md`](docs/PACKAGING.md)); a tagged release workflow builds the binaries. ## Building diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..abd40f4 --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,43 @@ +# Packaging MuGlyph + +MuGlyph publishes as a **self-contained, single-file** executable — no .NET runtime required on +the target machine. + +## Supported runtimes + +`linux-x64`, `linux-arm64`, `win-x64`, `osx-x64`, `osx-arm64` (declared in +`src/MuClient.Tui/MuClient.Tui.csproj`). + +## Local publish + +Publish profiles live in `src/MuClient.Tui/Properties/PublishProfiles/`: + +```bash +# Linux x64 → publish output contains a single `muglyph` binary +dotnet publish src/MuClient.Tui -p:PublishProfile=linux-x64 -o out/linux-x64 + +# Windows x64 → `muglyph.exe` +dotnet publish src/MuClient.Tui -p:PublishProfile=win-x64 -o out/win-x64 +``` + +For a RID without a profile, pass the flags directly: + +```bash +dotnet publish src/MuClient.Tui -c Release -r osx-arm64 \ + --self-contained -p:PublishSingleFile=true -o out/osx-arm64 +``` + +The profiles enable single-file extraction, compression, and ReadyToRun for faster startup. +These knobs live **only** in the publish profiles, so ordinary `dotnet build` / `dotnet run` +(and CI) are unaffected — they never require a runtime identifier. + +## CI / releases + +`.github/workflows/release.yml` runs on `v*` tags (and manual dispatch): it publishes +`linux-x64` and `win-x64`, packages them (`.tar.gz` / `.zip`), uploads them as workflow +artifacts, and — on a tag — attaches them to the GitHub release. + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` diff --git a/docs/PLAN.md b/docs/PLAN.md index 1a5c7d8..8beef5f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -107,6 +107,23 @@ MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscri **M5 — Full parity & polish** **Spawns** (route matched output to named windows), **puppets**, **multiple input windows**, **MXP + Pueblo** parsers (clickable links/commands/``, inline images via graphics layer), MSDP, **BeipMU config importer**, Unicode emoji + `:)`→🙂, smooth-scroll/appearance options, theming, packaging (dotnet single-file for Windows + Linux; optional distro packages). +### M5 progress (delivered) +- **MXP** and **Pueblo** parsers in `Core` (`ILineParser`), selectable per world via + `WorldDefinition.ContentFormat`; links/commands surface as `SpanInteraction` and are clickable + in the TUI. **Emoji** substitution (`EmojiSubstitutor`), opt-in per world. GMCP-driven **stat + line**, **spawn** capture, ReDoS-guarded regex engines, and self-contained **single-file + packaging** (`docs/PACKAGING.md`) + a tagged release workflow. + +### Still open (M5+) +- Dedicated **spawn windows** and **multiple input windows** (capture + routing hooks exist), + **puppets**, MSDP-driven stat panes, and the **map** view. +- **In-TUI web view.** MXP/Pueblo already produce clickable links. Plan: a `WebViewPane` that + fetches a URL and renders HTML → `StyledLine`s via a managed parser (AngleSharp), reusing + `SpanInteraction` for in-pane navigation, with `` shown through the existing + `InlineImageRenderer` (Kitty → Sixel → half-block). Optional high-fidelity mode: snapshot the + page with headless Chromium (Playwright) and display the image via the graphics layer. Text + mode works in any terminal; images need a graphics-capable one. + --- ## Key risks & mitigations diff --git a/src/MuClient.Core/Configuration/WorldDefinition.cs b/src/MuClient.Core/Configuration/WorldDefinition.cs index a25ec37..ddb7444 100644 --- a/src/MuClient.Core/Configuration/WorldDefinition.cs +++ b/src/MuClient.Core/Configuration/WorldDefinition.cs @@ -12,6 +12,29 @@ public enum LogFormat Both, } +/// How a world's inbound text is interpreted for styling and interaction. +public enum ContentFormat +{ + /// Plain text with ANSI SGR colour (the default). + Ansi, + + /// MXP markup (tags, SEND/A links) over ANSI. + Mxp, + + /// Pueblo HTML-subset markup. + Pueblo, +} + +/// Emoji/emoticon substitution options. +public sealed class EmojiSettings +{ + public bool Enabled { get; set; } + + public bool Emoticons { get; set; } = true; + + public bool Shortcodes { get; set; } = true; +} + /// Per-world logging configuration. public sealed class LoggingSettings { @@ -40,6 +63,12 @@ public sealed class WorldDefinition /// Echo typed commands locally into the output window. public bool LocalEcho { get; set; } = true; + /// How inbound text is parsed for styling and interactive links. + public ContentFormat ContentFormat { get; set; } = ContentFormat.Ansi; + + /// Emoji/emoticon substitution for inbound text. + public EmojiSettings Emoji { get; set; } = new(); + public List Triggers { get; set; } = new(); public List Aliases { get; set; } = new(); diff --git a/src/MuClient.Core/Protocols/MxpParser.cs b/src/MuClient.Core/Protocols/MxpParser.cs new file mode 100644 index 0000000..1107c10 --- /dev/null +++ b/src/MuClient.Core/Protocols/MxpParser.cs @@ -0,0 +1,715 @@ +using System.Globalization; +using System.Text; +using MuClient.Core.Text; + +namespace MuClient.Core.Protocols; + +/// +/// Incremental, stateful, line-oriented parser for the MUD eXtension Protocol (MXP). +/// Feed it decoded text (any number of chunks); it emits fully-terminated +/// s and retains the in-progress line, the current +/// , and the stack of open tags across calls, so a tag +/// (<...>) or entity (&...;) may straddle a chunk boundary. +/// +/// Mirrors the shape of : returns the +/// lines completed within the chunk, yields a buffered partial line +/// (e.g. an unterminated prompt), and colour/attribute state is preserved between calls. +/// +/// Scope notes for v1: +/// +/// Input is treated as MXP "secure/open" line mode — every tag is processed. The +/// telnet MXP line-mode security state machine (mode-change tags <RESET>, +/// line tags [1z] etc.) is not implemented here. +/// Inline <IMG> rendering is out of scope (graphics live elsewhere); the +/// tag is parsed and ignored gracefully. +/// Raw ANSI SGR is assumed to have been handled upstream. An ESC byte (0x1b) seen +/// here is passed through untouched into the span text. +/// Unknown/unsupported tags (<VAR>, <EXPIRE>, <H1>, +/// <P>, …) are consumed and discarded — they never leak into the output. +/// A stray < that cannot begin a tag, or a stray & that cannot begin +/// an entity, is emitted literally (xterm-like leniency). Tag/entity buffers are length +/// capped to avoid runaway on malformed input. +/// +/// +public sealed class MxpParser : MuClient.Core.Text.ILineParser +{ + private const int MaxTagLength = 4096; + private const int MaxEntityLength = 32; + + private enum Mode + { + Text, + Tag, + Entity, + } + + /// A single open MXP element on the tag stack. + private sealed class Frame + { + public required string Name { get; init; } + public TextStyle SavedStyle { get; init; } + public SpanInteraction? SavedInteraction { get; init; } + public bool IsInteraction { get; init; } + public bool IsLink { get; init; } + public bool DeferCommand { get; init; } + public string? Hint { get; init; } + public bool PromptOnly { get; init; } + public int SpanStart { get; init; } + } + + private Mode _mode = Mode.Text; + private TextStyle _current = TextStyle.Default; + private SpanInteraction? _interaction; + private readonly StringBuilder _run = new(); + private readonly StringBuilder _tag = new(); + private readonly StringBuilder _entity = new(); + private readonly List _lineSpans = new(); + private readonly List _stack = new(); + private List? _emit; + + /// The rendition state that will apply to the next printed character. + public TextStyle CurrentStyle => _current; + + /// True when a partial line, an open tag/entity, or unclosed markup is buffered. + public bool HasPendingContent => + _run.Length > 0 || _lineSpans.Count > 0 || _mode != Mode.Text; + + /// Feeds a chunk of text, returning every line completed by a newline (or <BR>) within it. + public IReadOnlyList Feed(string text) + { + ArgumentNullException.ThrowIfNull(text); + return Feed(text.AsSpan()); + } + + /// Feeds a chunk of text, returning every line completed by a newline (or <BR>) within it. + public IReadOnlyList Feed(ReadOnlySpan text) + { + _emit = null; + foreach (var ch in text) + { + Process(ch); + } + + var result = (IReadOnlyList?)_emit ?? Array.Empty(); + _emit = null; + return result; + } + + /// + /// Returns the buffered partial line (e.g. a prompt not terminated by a newline) and + /// clears it, or null if nothing is buffered. Any open interaction is finalised + /// so a flushed prompt's clickable spans carry their command; colour/attribute state and + /// formatting tags are preserved. + /// + public StyledLine? Flush() + { + FlushRun(); + CloseInteractionsAtBoundary(); + if (_lineSpans.Count == 0) + { + return null; + } + + var line = new StyledLine(_lineSpans); + _lineSpans.Clear(); + return line; + } + + /// Resets all parser state, including the current style and the open-tag stack. + public void Reset() + { + _mode = Mode.Text; + _current = TextStyle.Default; + _interaction = null; + _run.Clear(); + _tag.Clear(); + _entity.Clear(); + _lineSpans.Clear(); + _stack.Clear(); + } + + private void Process(char ch) + { + switch (_mode) + { + case Mode.Text: + ProcessText(ch); + break; + + case Mode.Tag: + ProcessTagChar(ch); + break; + + case Mode.Entity: + ProcessEntityChar(ch); + break; + } + } + + private void ProcessText(char ch) + { + switch (ch) + { + case '<': + _tag.Clear(); + _mode = Mode.Tag; + break; + + case '&': + _entity.Clear(); + _mode = Mode.Entity; + break; + + case '\n': + CompleteLine(); + break; + + case '\r': + // Carriage returns are dropped; scrollback is line-based. + break; + + default: + // Anything else (including a passed-through ESC 0x1b) is literal text. + _run.Append(ch); + break; + } + } + + private void ProcessTagChar(char ch) + { + if (_tag.Length == 0) + { + // Decide whether the '<' actually begins a tag. + if (ch == '>') + { + // Empty "<>" — not a tag; emit literally. + _run.Append('<').Append('>'); + _mode = Mode.Text; + return; + } + + if (!(char.IsLetter(ch) || ch == '/' || ch == '!')) + { + // Stray '<' (e.g. "a < b") — emit it literally and reprocess this char. + _run.Append('<'); + _mode = Mode.Text; + ProcessText(ch); + return; + } + + _tag.Append(ch); + return; + } + + switch (ch) + { + case '>': + ProcessTag(_tag.ToString()); + _tag.Clear(); + _mode = Mode.Text; + break; + + case '\n': + // A newline inside a "tag" means it was never a tag; bail out literally. + _run.Append('<').Append(_tag); + _tag.Clear(); + _mode = Mode.Text; + ProcessText('\n'); + break; + + default: + if (_tag.Length >= MaxTagLength) + { + _run.Append('<').Append(_tag); + _tag.Clear(); + _mode = Mode.Text; + ProcessText(ch); + return; + } + + _tag.Append(ch); + break; + } + } + + private void ProcessEntityChar(char ch) + { + if (ch == ';') + { + var content = _entity.ToString(); + _entity.Clear(); + _mode = Mode.Text; + + var replacement = ResolveEntity(content); + if (replacement is not null) + { + _run.Append(replacement); + } + else + { + // Unknown entity — emit the raw text so nothing is lost. + _run.Append('&').Append(content).Append(';'); + } + + return; + } + + if ((char.IsLetterOrDigit(ch) || ch == '#') && _entity.Length < MaxEntityLength) + { + _entity.Append(ch); + return; + } + + // Not a valid entity (stray '&' or malformed) — emit literally and reprocess. + _run.Append('&').Append(_entity); + _entity.Clear(); + _mode = Mode.Text; + ProcessText(ch); + } + + private static string? ResolveEntity(string content) + { + if (content.Length == 0) + { + return null; + } + + if (content[0] == '#') + { + var digits = content.AsSpan(1); + int code; + if (digits.Length > 1 && (digits[0] == 'x' || digits[0] == 'X')) + { + if (!int.TryParse(digits[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out code)) + { + return null; + } + } + else if (!int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out code)) + { + return null; + } + + if (code < 0 || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) + { + return null; + } + + return char.ConvertFromUtf32(code); + } + + return content.ToLowerInvariant() switch + { + "lt" => "<", + "gt" => ">", + "amp" => "&", + "quot" => "\"", + "apos" => "'", + "nbsp" => " ", + _ => null, + }; + } + + private void ProcessTag(string raw) + { + var trimmed = raw.Trim(); + if (trimmed.Length == 0) + { + return; + } + + if (trimmed[0] == '/') + { + CloseTag(Canonical(trimmed[1..].Trim())); + return; + } + + // Tolerate a self-closing slash, e.g. "
". + if (trimmed[^1] == '/') + { + trimmed = trimmed[..^1].TrimEnd(); + if (trimmed.Length == 0) + { + return; + } + } + + // Split "NAME rest-of-attributes". + var nameEnd = 0; + while (nameEnd < trimmed.Length && !char.IsWhiteSpace(trimmed[nameEnd])) + { + nameEnd++; + } + + var name = Canonical(trimmed[..nameEnd]); + var attrs = nameEnd < trimmed.Length ? trimmed[(nameEnd + 1)..] : string.Empty; + HandleOpener(name, attrs); + } + + private void HandleOpener(string name, string attrs) + { + switch (name) + { + case "B": + OpenFormatting(name, TextAttributes.Bold); + break; + case "I": + OpenFormatting(name, TextAttributes.Italic); + break; + case "U": + OpenFormatting(name, TextAttributes.Underline); + break; + case "S": + OpenFormatting(name, TextAttributes.Strikethrough); + break; + case "COLOR": + case "FONT": + OpenColor(name, attrs); + break; + case "SEND": + OpenSend(attrs); + break; + case "A": + OpenLink(attrs); + break; + case "BR": + CompleteLine(); + break; + default: + // Unknown/unsupported tag (VAR, EXPIRE, IMG, H1, P, …) — consumed and ignored. + break; + } + } + + private void OpenFormatting(string name, TextAttributes attribute) + { + PushFrame(name, isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false); + _current = _current.AddAttribute(attribute); + } + + private void OpenColor(string name, string attrs) + { + var parsed = ParseAttributes(attrs); + // FONT carries its foreground on COLOR=; COLOR/C use FORE=. Positional first = fore. + var fore = GetAttr(parsed, "FORE") ?? GetAttr(parsed, "COLOR") ?? Positional(parsed, 0); + var back = GetAttr(parsed, "BACK") ?? Positional(parsed, 1); + + PushFrame(name, isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false); + + if (fore is not null && WebColors.TryParse(fore, out var fg)) + { + _current = _current.WithForeground(fg); + } + + if (back is not null && WebColors.TryParse(back, out var bg)) + { + _current = _current.WithBackground(bg); + } + } + + private void OpenSend(string attrs) + { + var parsed = ParseAttributes(attrs); + var href = GetAttr(parsed, "HREF") ?? Positional(parsed, 0); + var hint = GetAttr(parsed, "HINT") ?? Positional(parsed, 1); + var prompt = HasFlag(parsed, "PROMPT"); + + if (href is not null) + { + // HREF may hold several '|'-separated commands; the first is the primary command. + var primary = href.Split('|', 2)[0]; + PushFrame("SEND", isInteraction: true, isLink: false, deferCommand: false, hint: hint, promptOnly: prompt); + _interaction = SpanInteraction.Command(primary, hint, prompt); + } + else + { + // No HREF: the enclosed text is itself the command (resolved when the tag closes). + PushFrame("SEND", isInteraction: true, isLink: false, deferCommand: true, hint: hint, promptOnly: prompt); + _interaction = SpanInteraction.Command(string.Empty, hint, prompt); + } + } + + private void OpenLink(string attrs) + { + var parsed = ParseAttributes(attrs); + var href = GetAttr(parsed, "HREF") ?? Positional(parsed, 0); + var hint = GetAttr(parsed, "HINT") ?? Positional(parsed, 1); + + if (href is not null) + { + PushFrame("A", isInteraction: true, isLink: true, deferCommand: false, hint: hint, promptOnly: false); + _interaction = SpanInteraction.Link(href, hint); + } + else + { + PushFrame("A", isInteraction: true, isLink: true, deferCommand: true, hint: hint, promptOnly: false); + _interaction = SpanInteraction.Link(string.Empty, hint); + } + } + + private void PushFrame(string name, bool isInteraction, bool isLink, bool deferCommand, string? hint, bool promptOnly) + { + FlushRun(); + _stack.Add(new Frame + { + Name = name, + SavedStyle = _current, + SavedInteraction = _interaction, + IsInteraction = isInteraction, + IsLink = isLink, + DeferCommand = deferCommand, + Hint = hint, + PromptOnly = promptOnly, + SpanStart = _lineSpans.Count, + }); + } + + private void CloseTag(string name) + { + var idx = -1; + for (var i = _stack.Count - 1; i >= 0; i--) + { + if (_stack[i].Name == name) + { + idx = i; + break; + } + } + + if (idx < 0) + { + // Stray/unbalanced closer — ignored (does not throw). + return; + } + + FlushRun(); + + var matched = _stack[idx]; + for (var i = _stack.Count - 1; i >= idx; i--) + { + var frame = _stack[i]; + if (frame.IsInteraction && frame.DeferCommand) + { + FinalizeDeferredInteraction(frame); + } + + _stack.RemoveAt(i); + } + + // Restoring the matched frame's saved state reverts everything opened at or after it. + _current = matched.SavedStyle; + _interaction = matched.SavedInteraction; + } + + /// + /// Rewrites the spans emitted since a deferred <SEND>/<A> opened, + /// using their concatenated text as the command/URL target. + /// + private void FinalizeDeferredInteraction(Frame frame) + { + if (frame.SpanStart >= _lineSpans.Count) + { + return; + } + + var sb = new StringBuilder(); + for (var i = frame.SpanStart; i < _lineSpans.Count; i++) + { + sb.Append(_lineSpans[i].Text); + } + + var target = sb.ToString(); + var interaction = frame.IsLink + ? SpanInteraction.Link(target, frame.Hint) + : SpanInteraction.Command(target, frame.Hint, frame.PromptOnly); + + for (var i = frame.SpanStart; i < _lineSpans.Count; i++) + { + var span = _lineSpans[i]; + _lineSpans[i] = new StyledSpan(span.Text, span.Style, interaction); + } + } + + /// + /// Finalises and removes any open interaction (<SEND>/<A>) frames at a + /// line/prompt boundary so a bare, unclosed interaction never leaks to the next line. + /// Formatting/colour frames persist across the boundary (MXP tags may span lines). + /// + private void CloseInteractionsAtBoundary() + { + for (var i = _stack.Count - 1; i >= 0; i--) + { + var frame = _stack[i]; + if (!frame.IsInteraction) + { + continue; + } + + if (frame.DeferCommand) + { + FinalizeDeferredInteraction(frame); + } + + _stack.RemoveAt(i); + } + + _interaction = null; + } + + private void CompleteLine() + { + FlushRun(); + CloseInteractionsAtBoundary(); + + var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans); + _lineSpans.Clear(); + (_emit ??= new List()).Add(line); + } + + private void FlushRun() + { + if (_run.Length == 0) + { + return; + } + + _lineSpans.Add(new StyledSpan(_run.ToString(), _current, _interaction)); + _run.Clear(); + } + + private static string Canonical(string name) => name.ToUpperInvariant() switch + { + "B" or "BOLD" or "STRONG" => "B", + "I" or "ITALIC" or "EM" => "I", + "U" or "UNDERLINE" => "U", + "S" or "STRIKEOUT" => "S", + "C" or "COLOR" => "COLOR", + var other => other, + }; + + // ----- Attribute parsing -------------------------------------------------- + + /// + /// Tokenises an attribute string into (Key, Value) pairs. A KEY=VALUE pair has a + /// non-null key; a bare token (positional value or flag) has a null key. Values may be quoted + /// with " or ', or be a bare whitespace-delimited token. + /// + private static List<(string? Key, string Value)> ParseAttributes(string s) + { + var result = new List<(string?, string)>(); + var i = 0; + var n = s.Length; + + while (i < n) + { + while (i < n && char.IsWhiteSpace(s[i])) + { + i++; + } + + if (i >= n) + { + break; + } + + var quoted = s[i] == '"' || s[i] == '\''; + var token = ReadToken(s, ref i, stopAtEquals: !quoted); + + if (!quoted && i < n && s[i] == '=') + { + i++; // consume '=' + var value = ReadToken(s, ref i, stopAtEquals: false); + result.Add((token, value)); + } + else + { + result.Add((null, token)); + } + } + + return result; + } + + private static string ReadToken(string s, ref int i, bool stopAtEquals) + { + var n = s.Length; + if (i < n && (s[i] == '"' || s[i] == '\'')) + { + var quote = s[i++]; + var start = i; + while (i < n && s[i] != quote) + { + i++; + } + + var value = s[start..i]; + if (i < n) + { + i++; // consume closing quote + } + + return value; + } + + var tokenStart = i; + while (i < n && !char.IsWhiteSpace(s[i]) && !(stopAtEquals && s[i] == '=')) + { + i++; + } + + return s[tokenStart..i]; + } + + private static string? GetAttr(List<(string? Key, string Value)> attrs, string key) + { + foreach (var (k, v) in attrs) + { + if (k is not null && string.Equals(k, key, StringComparison.OrdinalIgnoreCase)) + { + return v; + } + } + + return null; + } + + private static bool HasFlag(List<(string? Key, string Value)> attrs, string flag) + { + foreach (var (k, v) in attrs) + { + if (k is not null && string.Equals(k, flag, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (k is null && string.Equals(v, flag, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// Returns the -th positional (unkeyed) value, skipping the PROMPT flag. + private static string? Positional(List<(string? Key, string Value)> attrs, int index) + { + var seen = 0; + foreach (var (k, v) in attrs) + { + if (k is not null || string.Equals(v, "PROMPT", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (seen == index) + { + return v; + } + + seen++; + } + + return null; + } +} diff --git a/src/MuClient.Core/Protocols/PuebloParser.cs b/src/MuClient.Core/Protocols/PuebloParser.cs new file mode 100644 index 0000000..d5fd013 --- /dev/null +++ b/src/MuClient.Core/Protocols/PuebloParser.cs @@ -0,0 +1,602 @@ +using System.Text; +using MuClient.Core.Text; + +namespace MuClient.Core.Protocols; + +/// +/// Incremental, line-oriented parser for Pueblo markup — the HTML subset spoken by some +/// MU* servers (a predecessor and cousin of MXP). Feed it decoded text (any number of chunks); +/// it emits fully-terminated s and retains the in-progress line, the +/// current , and the open-tag stack across calls, so a tag +/// (<...>) or entity (&...;) may span chunk boundaries. +/// +/// Supported: the formatting tags (B/STRONG, I/EM, U, STRIKE/S), +/// <FONT COLOR BGCOLOR>, the Pueblo command/link anchor +/// (<A XCH_CMD> / <A HREF>, plus <SEND>), the break tags +/// (BR, HR, P), preformatted passthrough (PRE), graceful +/// <IMG> discard, and the common named/numeric entities. Unknown tags are consumed +/// and never leak into the output. +/// +/// Out of scope: the Pueblo activation handshake (the <!-- --> / +/// "This world is Pueblo …" enabling exchange). This class parses the markup only; +/// deciding whether a world is Pueblo-enabled is a session concern handled elsewhere. ANSI/VT +/// escape sequences are handled upstream, so an ESC (0x1b) byte is passed through untouched into +/// the span text rather than being interpreted here. +/// +public sealed class PuebloParser : MuClient.Core.Text.ILineParser +{ + private const int MaxTagLength = 4096; + private const int MaxEntityLength = 32; + + private enum State + { + Ground, + Tag, + Entity, + } + + /// A saved rendition frame, pushed when an element opens and restored when it closes. + private readonly struct Frame + { + public Frame(string name, TextStyle style, SpanInteraction? interaction) + { + Name = name; + Style = style; + Interaction = interaction; + } + + public string Name { get; } + + public TextStyle Style { get; } + + public SpanInteraction? Interaction { get; } + } + + private State _state = State.Ground; + private TextStyle _current = TextStyle.Default; + private SpanInteraction? _interaction; + private readonly StringBuilder _run = new(); + private readonly List _lineSpans = new(); + private readonly StringBuilder _tag = new(); + private readonly StringBuilder _entity = new(); + private readonly List _stack = new(); + + /// The rendition state that will apply to the next printed character. + public TextStyle CurrentStyle => _current; + + /// True when a partial line, tag, or entity is buffered. + public bool HasPendingContent => + _run.Length > 0 || _lineSpans.Count > 0 || _state != State.Ground; + + /// Feeds a chunk of text, returning every line completed within it. + public IReadOnlyList Feed(string text) + { + ArgumentNullException.ThrowIfNull(text); + return Feed(text.AsSpan()); + } + + /// Feeds a chunk of text, returning every line completed within it. + public IReadOnlyList Feed(ReadOnlySpan text) + { + List? lines = null; + foreach (var ch in text) + { + Process(ch, ref lines); + } + + return (IReadOnlyList?)lines ?? Array.Empty(); + } + + /// + /// Returns the buffered partial line (e.g. a prompt not terminated by a break) and clears it, + /// or null if nothing is buffered. Style and open-tag state are preserved. + /// + public StyledLine? Flush() + { + FlushRun(); + if (_lineSpans.Count == 0) + { + return null; + } + + var line = new StyledLine(_lineSpans); + _lineSpans.Clear(); + return line; + } + + /// Resets all parser state, including the current style and open-tag stack. + public void Reset() + { + _state = State.Ground; + _current = TextStyle.Default; + _interaction = null; + _run.Clear(); + _lineSpans.Clear(); + _tag.Clear(); + _entity.Clear(); + _stack.Clear(); + } + + private void Process(char ch, ref List? lines) + { + switch (_state) + { + case State.Ground: + ProcessGround(ch, ref lines); + break; + + case State.Tag: + ProcessTag(ch, ref lines); + break; + + case State.Entity: + ProcessEntity(ch, ref lines); + break; + } + } + + private void ProcessGround(char ch, ref List? lines) + { + switch (ch) + { + case '<': + _tag.Clear(); + _state = State.Tag; + break; + + case '&': + _entity.Clear(); + _state = State.Entity; + break; + + case '\n': + CompleteLine(ref lines); + break; + + case '\r': + // Carriage returns are dropped; scrollback is line-based. + break; + + default: + _run.Append(ch); + break; + } + } + + private void ProcessTag(char ch, ref List? lines) + { + switch (ch) + { + case '>': + HandleTag(_tag.ToString(), ref lines); + _tag.Clear(); + _state = State.Ground; + break; + + case '<': + // The previous '<' plus what we buffered was not a tag — emit it literally and + // begin a fresh tag with this '<'. + _run.Append('<').Append(_tag); + _tag.Clear(); + break; + + case '\n': + // A newline inside a "tag" means it never closed — treat it as literal text. + _run.Append('<').Append(_tag); + _tag.Clear(); + _state = State.Ground; + CompleteLine(ref lines); + break; + + default: + if (_tag.Length >= MaxTagLength) + { + // Runaway: give up on the tag and emit what we have as literal text. + _run.Append('<').Append(_tag); + _tag.Clear(); + _state = State.Ground; + ProcessGround(ch, ref lines); + } + else + { + _tag.Append(ch); + } + + break; + } + } + + private void ProcessEntity(char ch, ref List? lines) + { + // Entity names are alphanumeric, optionally prefixed with '#' (and 'x'/'X' for hex). + if (ch == ';') + { + if (TryResolveEntity(_entity.ToString(), out var resolved)) + { + _run.Append(resolved); + } + else + { + // Not a recognised entity — emit it verbatim so nothing is lost. + _run.Append('&').Append(_entity).Append(';'); + } + + _entity.Clear(); + _state = State.Ground; + return; + } + + if (ch == '&') + { + // A second '&' means the first was a literal ampersand. + _run.Append('&').Append(_entity); + _entity.Clear(); + return; + } + + if ((char.IsLetterOrDigit(ch) || ch == '#') && _entity.Length < MaxEntityLength) + { + _entity.Append(ch); + return; + } + + // Any other character (whitespace, '<', overflow, …) aborts the entity: emit the buffered + // text literally, then reprocess this character from the Ground state. + _run.Append('&').Append(_entity); + _entity.Clear(); + _state = State.Ground; + ProcessGround(ch, ref lines); + } + + private void HandleTag(string raw, ref List? lines) + { + var tag = PuebloTag.Parse(raw); + if (tag.Name.Length == 0) + { + // Empty, comment (), or declaration () — nothing to render. + return; + } + + if (tag.IsClose) + { + CloseTag(tag.Name); + return; + } + + switch (tag.Name) + { + case "b": + case "strong": + OpenAttribute(tag.Name, TextAttributes.Bold); + break; + case "i": + case "em": + OpenAttribute(tag.Name, TextAttributes.Italic); + break; + case "u": + OpenAttribute(tag.Name, TextAttributes.Underline); + break; + case "strike": + case "s": + OpenAttribute(tag.Name, TextAttributes.Strikethrough); + break; + + case "font": + OpenFont(tag); + break; + + case "a": + case "send": + OpenAnchor(tag); + break; + + case "br": + CompleteLine(ref lines); + break; + case "hr": + // A rule renders as a break followed by a blank separating line. + CompleteLine(ref lines); + CompleteLine(ref lines); + break; + case "p": + // Paragraph boundaries (open and close) act as a single line break. + CompleteLine(ref lines); + break; + + case "pre": + case "img": + //
 content is already preformatted;  is handled by the graphics layer.
+                // Both are consumed here so they never leak into the text.
+                break;
+
+            default:
+                // Unknown tag — consumed and ignored.
+                break;
+        }
+    }
+
+    private void OpenAttribute(string name, TextAttributes attribute)
+    {
+        FlushRun();
+        _stack.Add(new Frame(name, _current, _interaction));
+        _current = _current.AddAttribute(attribute);
+    }
+
+    private void OpenFont(PuebloTag tag)
+    {
+        FlushRun();
+        _stack.Add(new Frame("font", _current, _interaction));
+
+        if (tag.TryGet("color", out var fg) && WebColors.TryParse(fg, out var fgColor))
+        {
+            _current = _current.WithForeground(fgColor);
+        }
+
+        if (tag.TryGet("bgcolor", out var bg) && WebColors.TryParse(bg, out var bgColor))
+        {
+            _current = _current.WithBackground(bgColor);
+        }
+    }
+
+    private void OpenAnchor(PuebloTag tag)
+    {
+        FlushRun();
+        _stack.Add(new Frame(tag.Name, _current, _interaction));
+
+        tag.TryGet("xch_hint", out var hint);
+        if (string.IsNullOrEmpty(hint))
+        {
+            tag.TryGet("title", out hint);
+        }
+
+        if (tag.TryGet("xch_cmd", out var cmd) && !string.IsNullOrEmpty(cmd))
+        {
+            var promptOnly = tag.TryGet("xch_mode", out var mode) &&
+                             string.Equals(mode, "prompt", StringComparison.OrdinalIgnoreCase);
+            _interaction = SpanInteraction.Command(cmd, NullIfEmpty(hint), promptOnly);
+        }
+        else if (tag.TryGet("href", out var href) && !string.IsNullOrEmpty(href))
+        {
+            //  carries a command;  without XCH_CMD is a hyperlink.
+            _interaction = tag.Name == "send"
+                ? SpanInteraction.Command(href, NullIfEmpty(hint))
+                : SpanInteraction.Link(href, NullIfEmpty(hint));
+        }
+
+        // With neither XCH_CMD nor HREF the anchor carries no interaction; the frame still keeps
+        // the stack balanced so its closer restores state cleanly.
+    }
+
+    private void CloseTag(string name)
+    {
+        // Pop down to (and including) the nearest matching open frame, restoring its saved state.
+        // Unbalanced closers with no matching open frame are ignored.
+        for (var i = _stack.Count - 1; i >= 0; i--)
+        {
+            if (_stack[i].Name != name && !IsAlias(_stack[i].Name, name))
+            {
+                continue;
+            }
+
+            FlushRun();
+            _current = _stack[i].Style;
+            _interaction = _stack[i].Interaction;
+            _stack.RemoveRange(i, _stack.Count - i);
+            return;
+        }
+    }
+
+    /// True when two tag names denote the same rendition (e.g. b/strong).
+    private static bool IsAlias(string open, string close) => (open, close) switch
+    {
+        ("b", "strong") or ("strong", "b") => true,
+        ("i", "em") or ("em", "i") => true,
+        ("strike", "s") or ("s", "strike") => true,
+        _ => false,
+    };
+
+    private void CompleteLine(ref List? lines)
+    {
+        FlushRun();
+        var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans);
+        _lineSpans.Clear();
+        (lines ??= new List()).Add(line);
+    }
+
+    private void FlushRun()
+    {
+        if (_run.Length == 0)
+        {
+            return;
+        }
+
+        _lineSpans.Add(new StyledSpan(_run.ToString(), _current, _interaction));
+        _run.Clear();
+    }
+
+    private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
+
+    private static bool TryResolveEntity(string name, out string value)
+    {
+        switch (name.ToLowerInvariant())
+        {
+            case "lt": value = "<"; return true;
+            case "gt": value = ">"; return true;
+            case "amp": value = "&"; return true;
+            case "quot": value = "\""; return true;
+            case "apos": value = "'"; return true;
+            case "nbsp": value = " "; return true;
+        }
+
+        if (name.Length >= 2 && name[0] == '#')
+        {
+            var isHex = name[1] is 'x' or 'X';
+            var digits = isHex ? name.AsSpan(2) : name.AsSpan(1);
+            var style = isHex
+                ? System.Globalization.NumberStyles.HexNumber
+                : System.Globalization.NumberStyles.Integer;
+
+            if (digits.Length > 0 &&
+                int.TryParse(digits, style, null, out var code) &&
+                code is > 0 and <= 0x10FFFF &&
+                !(code >= 0xD800 && code <= 0xDFFF))
+            {
+                value = char.ConvertFromUtf32(code);
+                return true;
+            }
+        }
+
+        value = string.Empty;
+        return false;
+    }
+
+    /// A parsed tag: its lower-cased name, whether it is a closer, and its attributes.
+    private readonly struct PuebloTag
+    {
+        private readonly Dictionary? _attributes;
+
+        private PuebloTag(bool isClose, string name, Dictionary? attributes)
+        {
+            IsClose = isClose;
+            Name = name;
+            _attributes = attributes;
+        }
+
+        public bool IsClose { get; }
+
+        public string Name { get; }
+
+        public bool TryGet(string key, out string value)
+        {
+            if (_attributes is not null && _attributes.TryGetValue(key, out var v))
+            {
+                value = v;
+                return true;
+            }
+
+            value = string.Empty;
+            return false;
+        }
+
+        /// Parses the raw text between < and > into name + attributes.
+        public static PuebloTag Parse(string raw)
+        {
+            var i = 0;
+            var len = raw.Length;
+
+            while (i < len && char.IsWhiteSpace(raw[i]))
+            {
+                i++;
+            }
+
+            if (i >= len)
+            {
+                return new PuebloTag(false, string.Empty, null);
+            }
+
+            // Comments () and declarations () carry no renderable name.
+            if (raw[i] == '!' || raw[i] == '?')
+            {
+                return new PuebloTag(false, string.Empty, null);
+            }
+
+            var isClose = false;
+            if (raw[i] == '/')
+            {
+                isClose = true;
+                i++;
+            }
+
+            var nameStart = i;
+            while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '/' && raw[i] != '=')
+            {
+                i++;
+            }
+
+            var name = raw[nameStart..i].ToLowerInvariant();
+            if (name.Length == 0)
+            {
+                return new PuebloTag(isClose, string.Empty, null);
+            }
+
+            if (isClose)
+            {
+                // Closers carry no meaningful attributes.
+                return new PuebloTag(true, name, null);
+            }
+
+            Dictionary? attributes = null;
+
+            while (i < len)
+            {
+                while (i < len && (char.IsWhiteSpace(raw[i]) || raw[i] == '/'))
+                {
+                    i++;
+                }
+
+                if (i >= len)
+                {
+                    break;
+                }
+
+                var keyStart = i;
+                while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '=' && raw[i] != '/')
+                {
+                    i++;
+                }
+
+                var key = raw[keyStart..i].ToLowerInvariant();
+
+                // Skip whitespace before a possible '='.
+                var j = i;
+                while (j < len && char.IsWhiteSpace(raw[j]))
+                {
+                    j++;
+                }
+
+                var value = string.Empty;
+                if (j < len && raw[j] == '=')
+                {
+                    i = j + 1;
+                    while (i < len && char.IsWhiteSpace(raw[i]))
+                    {
+                        i++;
+                    }
+
+                    if (i < len && (raw[i] == '"' || raw[i] == '\''))
+                    {
+                        var quote = raw[i++];
+                        var valStart = i;
+                        while (i < len && raw[i] != quote)
+                        {
+                            i++;
+                        }
+
+                        value = raw[valStart..i];
+                        if (i < len)
+                        {
+                            i++; // consume the closing quote
+                        }
+                    }
+                    else
+                    {
+                        var valStart = i;
+                        while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '/')
+                        {
+                            i++;
+                        }
+
+                        value = raw[valStart..i];
+                    }
+                }
+
+                if (key.Length > 0)
+                {
+                    (attributes ??= new Dictionary(StringComparer.OrdinalIgnoreCase))[key] = value;
+                }
+            }
+
+            return new PuebloTag(false, name, attributes);
+        }
+    }
+}
diff --git a/src/MuClient.Core/Session/WorldSession.cs b/src/MuClient.Core/Session/WorldSession.cs
index 5ba616e..10f38d9 100644
--- a/src/MuClient.Core/Session/WorldSession.cs
+++ b/src/MuClient.Core/Session/WorldSession.cs
@@ -1,6 +1,7 @@
 using MuClient.Core.Automation;
 using MuClient.Core.Configuration;
 using MuClient.Core.Logging;
+using MuClient.Core.Protocols;
 using MuClient.Core.Telnet;
 using MuClient.Core.Text;
 using MuClient.Core.Transport;
@@ -22,7 +23,8 @@ public sealed class WorldSession : IAsyncDisposable
         new(TerminalColor.FromIndex(6), TerminalColor.Default, TextAttributes.Italic);
 
     private readonly Func _sessionFactory;
-    private readonly AnsiParser _parser = new();
+    private readonly ILineParser _parser;
+    private readonly EmojiSubstitutor? _emoji;
     private readonly ILogSink? _log;
     private ITelnetSession? _telnet;
 
@@ -35,12 +37,23 @@ public WorldSession(
         World = world ?? throw new ArgumentNullException(nameof(world));
         _sessionFactory = sessionFactory ?? DefaultSessionFactory;
         _log = log;
+        _parser = CreateParser(world.ContentFormat);
+        _emoji = world.Emoji.Enabled
+            ? new EmojiSubstitutor(world.Emoji.Emoticons, world.Emoji.Shortcodes)
+            : null;
         Scrollback = new ScrollbackBuffer(scrollbackCapacity);
         Triggers = new TriggerEngine(world.Triggers);
         Aliases = new AliasEngine(world.Aliases);
         Macros = new MacroEngine(world.Macros);
     }
 
+    private static ILineParser CreateParser(ContentFormat format) => format switch
+    {
+        ContentFormat.Mxp => new MxpParser(),
+        ContentFormat.Pueblo => new PuebloParser(),
+        _ => new AnsiParser(),
+    };
+
     public WorldDefinition World { get; }
 
     public ScrollbackBuffer Scrollback { get; }
@@ -84,6 +97,14 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
             return;
         }
 
+        // A prior faulted/disconnected session may still be referenced; dispose it before
+        // reconnecting so its read loop and transport are released (its events won't fire again).
+        if (_telnet is not null)
+        {
+            await _telnet.DisposeAsync().ConfigureAwait(false);
+            _telnet = null;
+        }
+
         SetState(ConnectionState.Connecting, null);
         PrintSystem($"*** Connecting to {World.Host}:{World.Port}...");
 
@@ -114,7 +135,7 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e)
         if (e.IsPrompt)
         {
             _parser.Feed(e.Text);
-            var prompt = _parser.Flush() ?? StyledLine.Empty;
+            var prompt = ApplyEmoji(_parser.Flush() ?? StyledLine.Empty);
             CurrentPrompt = prompt;
             PromptChanged?.Invoke(this, prompt);
             return;
@@ -153,10 +174,33 @@ private void ProcessOutputLine(StyledLine line)
 
         if (!result.Suppress)
         {
-            Print(result.Line);
+            Print(ApplyEmoji(result.Line));
         }
     }
 
+    /// Substitutes emoji in each span's text when enabled for this world; a no-op otherwise.
+    private StyledLine ApplyEmoji(StyledLine line)
+    {
+        if (_emoji is null || line.IsEmpty)
+        {
+            return line;
+        }
+
+        StyledSpan[]? rebuilt = null;
+        for (var i = 0; i < line.Spans.Count; i++)
+        {
+            var span = line.Spans[i];
+            var replaced = _emoji.Apply(span.Text);
+            if (!ReferenceEquals(replaced, span.Text) && replaced != span.Text)
+            {
+                rebuilt ??= line.Spans.ToArray();
+                rebuilt[i] = new StyledSpan(replaced, span.Style, span.Interaction);
+            }
+        }
+
+        return rebuilt is null ? line : new StyledLine(rebuilt);
+    }
+
     /// Handles a line of user input: alias expansion, local echo, and send.
     public async Task SendUserInputAsync(string input, CancellationToken cancellationToken = default)
     {
diff --git a/src/MuClient.Core/Telnet/TelnetSession.cs b/src/MuClient.Core/Telnet/TelnetSession.cs
index 42ededa..ab9d748 100644
--- a/src/MuClient.Core/Telnet/TelnetSession.cs
+++ b/src/MuClient.Core/Telnet/TelnetSession.cs
@@ -77,8 +77,19 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
         // (e.g. WILL NAWS) during BuildAsync, which is written straight to the transport.
         await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
 
-        _interpreter = await BuildInterpreterAsync().ConfigureAwait(false);
-        ByteCallbackProperty.SetValue(_interpreter, new Func(OnByteAsync));
+        try
+        {
+            _interpreter = await BuildInterpreterAsync().ConfigureAwait(false);
+            ByteCallbackProperty.SetValue(_interpreter, new Func(OnByteAsync));
+        }
+        catch
+        {
+            // Building/wiring the interpreter failed after the socket opened; close it so we
+            // don't leak a half-open connection, and stay in the not-connected state.
+            _interpreter = null;
+            await _transport.CloseAsync().ConfigureAwait(false);
+            throw;
+        }
 
         _loopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
         _readLoop = Task.Run(() => ReadLoopAsync(_loopCts.Token), CancellationToken.None);
@@ -212,12 +223,14 @@ public ValueTask SendLineAsync(string text, CancellationToken cancellationToken
 
     public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default)
     {
+        cancellationToken.ThrowIfCancellationRequested();
         var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected.");
         return interpreter.SendAsync(data.ToArray());
     }
 
     public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default)
     {
+        cancellationToken.ThrowIfCancellationRequested();
         var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected.");
         return interpreter.SendGMCPCommand(package, json ?? string.Empty);
     }
@@ -249,6 +262,9 @@ public async Task DisconnectAsync()
             }
         }
 
+        // Clear the interpreter so a subsequent ConnectAsync can reconnect and the "not
+        // connected" guards on the send methods observe the disconnected state.
+        _interpreter = null;
         RaiseDisconnected(null);
     }
 
diff --git a/src/MuClient.Core/Text/AnsiParser.cs b/src/MuClient.Core/Text/AnsiParser.cs
index 0403dc6..a228258 100644
--- a/src/MuClient.Core/Text/AnsiParser.cs
+++ b/src/MuClient.Core/Text/AnsiParser.cs
@@ -14,7 +14,7 @@ namespace MuClient.Core.Text;
 /// OSC strings, and other escapes are recognised and discarded rather than leaking
 /// into the output as stray text.
 /// 
-public sealed class AnsiParser
+public sealed class AnsiParser : ILineParser
 {
     private const int MaxSequenceLength = 128;
 
diff --git a/src/MuClient.Core/Text/EmojiSubstitutor.cs b/src/MuClient.Core/Text/EmojiSubstitutor.cs
new file mode 100644
index 0000000..51b9358
--- /dev/null
+++ b/src/MuClient.Core/Text/EmojiSubstitutor.cs
@@ -0,0 +1,155 @@
+using System.Text;
+
+namespace MuClient.Core.Text;
+
+/// 
+/// Substitutes text emoticons (:) → 🙂) and :shortcode: names (:fire: → 🔥)
+/// with emoji, BeipMU-style. Emoticons are only replaced when flanked by whitespace or line
+/// edges so they don't fire inside words or URLs (e.g. http://).
+/// 
+public sealed class EmojiSubstitutor
+{
+    private static readonly IReadOnlyDictionary DefaultEmoticons = new Dictionary
+    {
+        [":)"] = "🙂", [":-)"] = "🙂", [":("] = "🙁", [":-("] = "🙁",
+        [";)"] = "😉", [";-)"] = "😉", [":D"] = "😃", [":-D"] = "😃",
+        [":P"] = "😛", [":-P"] = "😛", [":p"] = "😛", [":o"] = "😮", [":O"] = "😮",
+        [":'("] = "😢", [":|"] = "😐", ["<3"] = "❤️", [":*"] = "😘",
+        ["8)"] = "😎", ["B)"] = "😎", [">:("] = "😠", [":/"] = "😕",
+    };
+
+    private static readonly IReadOnlyDictionary DefaultShortcodes = new Dictionary(StringComparer.OrdinalIgnoreCase)
+    {
+        ["smile"] = "😄", ["grin"] = "😁", ["laugh"] = "😆", ["wink"] = "😉",
+        ["heart"] = "❤️", ["fire"] = "🔥", ["star"] = "⭐", ["check"] = "✅",
+        ["cross"] = "❌", ["thumbsup"] = "👍", ["thumbsdown"] = "👎", ["skull"] = "💀",
+        ["sword"] = "⚔️", ["shield"] = "🛡️", ["dragon"] = "🐉", ["sparkles"] = "✨",
+        ["wave"] = "👋", ["eyes"] = "👀", ["thinking"] = "🤔", ["tada"] = "🎉",
+    };
+
+    private readonly IReadOnlyDictionary _emoticons;
+    private readonly IReadOnlyDictionary _shortcodes;
+
+    public EmojiSubstitutor(
+        bool emoticons = true,
+        bool shortcodes = true,
+        IReadOnlyDictionary? extraShortcodes = null)
+    {
+        EmoticonsEnabled = emoticons;
+        ShortcodesEnabled = shortcodes;
+        _emoticons = DefaultEmoticons;
+
+        if (extraShortcodes is null || extraShortcodes.Count == 0)
+        {
+            _shortcodes = DefaultShortcodes;
+        }
+        else
+        {
+            var merged = new Dictionary(DefaultShortcodes, StringComparer.OrdinalIgnoreCase);
+            foreach (var (key, value) in extraShortcodes)
+            {
+                merged[key] = value;
+            }
+
+            _shortcodes = merged;
+        }
+    }
+
+    public bool EmoticonsEnabled { get; }
+
+    public bool ShortcodesEnabled { get; }
+
+    /// Returns  with emoticons and shortcodes replaced by emoji.
+    public string Apply(string text)
+    {
+        ArgumentNullException.ThrowIfNull(text);
+        if (text.Length == 0)
+        {
+            return text;
+        }
+
+        var result = text;
+        if (ShortcodesEnabled)
+        {
+            result = ReplaceShortcodes(result);
+        }
+
+        if (EmoticonsEnabled)
+        {
+            result = ReplaceEmoticons(result);
+        }
+
+        return result;
+    }
+
+    private string ReplaceShortcodes(string text)
+    {
+        if (text.IndexOf(':') < 0)
+        {
+            return text;
+        }
+
+        var sb = new StringBuilder(text.Length);
+        var i = 0;
+        while (i < text.Length)
+        {
+            if (text[i] == ':')
+            {
+                var end = text.IndexOf(':', i + 1);
+                if (end > i + 1)
+                {
+                    var name = text[(i + 1)..end];
+                    if (IsShortcodeName(name) && _shortcodes.TryGetValue(name, out var emoji))
+                    {
+                        sb.Append(emoji);
+                        i = end + 1;
+                        continue;
+                    }
+                }
+            }
+
+            sb.Append(text[i]);
+            i++;
+        }
+
+        return sb.ToString();
+    }
+
+    private static bool IsShortcodeName(string name) =>
+        name.Length is > 0 and <= 32 && name.All(c => char.IsLetterOrDigit(c) || c is '_' or '+' or '-');
+
+    private string ReplaceEmoticons(string text)
+    {
+        var sb = new StringBuilder(text.Length);
+        var i = 0;
+        while (i < text.Length)
+        {
+            var matched = false;
+
+            // Only attempt a match at a token boundary (start, or after whitespace).
+            if (i == 0 || char.IsWhiteSpace(text[i - 1]))
+            {
+                foreach (var (token, emoji) in _emoticons)
+                {
+                    if (i + token.Length <= text.Length &&
+                        string.CompareOrdinal(text, i, token, 0, token.Length) == 0 &&
+                        (i + token.Length == text.Length || char.IsWhiteSpace(text[i + token.Length])))
+                    {
+                        sb.Append(emoji);
+                        i += token.Length;
+                        matched = true;
+                        break;
+                    }
+                }
+            }
+
+            if (!matched)
+            {
+                sb.Append(text[i]);
+                i++;
+            }
+        }
+
+        return sb.ToString();
+    }
+}
diff --git a/src/MuClient.Core/Text/ILineParser.cs b/src/MuClient.Core/Text/ILineParser.cs
new file mode 100644
index 0000000..287b064
--- /dev/null
+++ b/src/MuClient.Core/Text/ILineParser.cs
@@ -0,0 +1,24 @@
+namespace MuClient.Core.Text;
+
+/// 
+/// An incremental, line-oriented parser that turns decoded server text into styled lines.
+/// Implemented by  and the MXP/Pueblo markup parsers so a session can
+/// select the content format uniformly.
+/// 
+public interface ILineParser
+{
+    /// Feeds a chunk of text, returning every line completed within it.
+    IReadOnlyList Feed(string text);
+
+    /// Returns and clears any buffered partial line (e.g. a prompt), or null.
+    StyledLine? Flush();
+
+    /// Resets all parser state, including the current style.
+    void Reset();
+
+    /// The rendition state that will apply to the next printed character.
+    TextStyle CurrentStyle { get; }
+
+    /// True when a partial line or markup sequence is buffered.
+    bool HasPendingContent { get; }
+}
diff --git a/src/MuClient.Core/Text/SpanInteraction.cs b/src/MuClient.Core/Text/SpanInteraction.cs
new file mode 100644
index 0000000..f17624f
--- /dev/null
+++ b/src/MuClient.Core/Text/SpanInteraction.cs
@@ -0,0 +1,31 @@
+namespace MuClient.Core.Text;
+
+/// What activating an interactive span does.
+public enum InteractionKind
+{
+    /// Send a command to the server (MXP <SEND>, Pueblo xch_cmd).
+    SendCommand,
+
+    /// Open a hyperlink (MXP <A HREF>, Pueblo <A>).
+    Hyperlink,
+}
+
+/// 
+/// Makes a  clickable: a command to send or a URL to open, plus an
+/// optional hint (tooltip). Produced by the MXP and Pueblo parsers and realised by the UI
+/// (mouse/keyboard activation). UI-agnostic.
+/// 
+public sealed record SpanInteraction(
+    InteractionKind Kind,
+    string Target,
+    string? Hint = null,
+    bool PromptOnly = false)
+{
+    /// A clickable command.  puts it on the input line instead of sending.
+    public static SpanInteraction Command(string command, string? hint = null, bool promptOnly = false) =>
+        new(InteractionKind.SendCommand, command, hint, promptOnly);
+
+    /// A hyperlink.
+    public static SpanInteraction Link(string url, string? hint = null) =>
+        new(InteractionKind.Hyperlink, url, hint);
+}
diff --git a/src/MuClient.Core/Text/StyledSpan.cs b/src/MuClient.Core/Text/StyledSpan.cs
index 7ec15b3..9653a23 100644
--- a/src/MuClient.Core/Text/StyledSpan.cs
+++ b/src/MuClient.Core/Text/StyledSpan.cs
@@ -1,25 +1,32 @@
 namespace MuClient.Core.Text;
 
-/// A run of text sharing a single .
+/// A run of text sharing a single , optionally interactive.
 public readonly struct StyledSpan : IEquatable
 {
-    public StyledSpan(string text, TextStyle style)
+    public StyledSpan(string text, TextStyle style, SpanInteraction? interaction = null)
     {
         Text = text ?? throw new ArgumentNullException(nameof(text));
         Style = style;
+        Interaction = interaction;
     }
 
     public string Text { get; }
 
     public TextStyle Style { get; }
 
+    /// Optional click behaviour (a command to send or a link to open); null for plain text.
+    public SpanInteraction? Interaction { get; }
+
     public int Length => Text.Length;
 
-    public bool Equals(StyledSpan other) => Text == other.Text && Style.Equals(other.Style);
+    public bool IsInteractive => Interaction is not null;
+
+    public bool Equals(StyledSpan other) =>
+        Text == other.Text && Style.Equals(other.Style) && Equals(Interaction, other.Interaction);
 
     public override bool Equals(object? obj) => obj is StyledSpan other && Equals(other);
 
-    public override int GetHashCode() => HashCode.Combine(Text, Style);
+    public override int GetHashCode() => HashCode.Combine(Text, Style, Interaction);
 
     public override string ToString() => Text;
 }
diff --git a/src/MuClient.Core/Text/WebColors.cs b/src/MuClient.Core/Text/WebColors.cs
new file mode 100644
index 0000000..c206d5a
--- /dev/null
+++ b/src/MuClient.Core/Text/WebColors.cs
@@ -0,0 +1,87 @@
+namespace MuClient.Core.Text;
+
+/// 
+/// Resolves the named colours used by MXP and Pueblo markup (a subset of CSS/HTML colour names,
+/// plus #rrggbb) to a .
+/// 
+public static class WebColors
+{
+    private static readonly IReadOnlyDictionary Named = new Dictionary(StringComparer.OrdinalIgnoreCase)
+    {
+        ["black"] = new(0x00, 0x00, 0x00),
+        ["silver"] = new(0xc0, 0xc0, 0xc0),
+        ["gray"] = new(0x80, 0x80, 0x80),
+        ["grey"] = new(0x80, 0x80, 0x80),
+        ["white"] = new(0xff, 0xff, 0xff),
+        ["maroon"] = new(0x80, 0x00, 0x00),
+        ["red"] = new(0xff, 0x00, 0x00),
+        ["purple"] = new(0x80, 0x00, 0x80),
+        ["fuchsia"] = new(0xff, 0x00, 0xff),
+        ["magenta"] = new(0xff, 0x00, 0xff),
+        ["green"] = new(0x00, 0x80, 0x00),
+        ["lime"] = new(0x00, 0xff, 0x00),
+        ["olive"] = new(0x80, 0x80, 0x00),
+        ["yellow"] = new(0xff, 0xff, 0x00),
+        ["navy"] = new(0x00, 0x00, 0x80),
+        ["blue"] = new(0x00, 0x00, 0xff),
+        ["teal"] = new(0x00, 0x80, 0x80),
+        ["aqua"] = new(0x00, 0xff, 0xff),
+        ["cyan"] = new(0x00, 0xff, 0xff),
+        ["orange"] = new(0xff, 0xa5, 0x00),
+        ["brown"] = new(0xa5, 0x2a, 0x2a),
+        ["pink"] = new(0xff, 0xc0, 0xcb),
+        ["gold"] = new(0xff, 0xd7, 0x00),
+    };
+
+    /// Tries to resolve a colour name or #rrggbb/#rgb literal.
+    public static bool TryParse(string? value, out TerminalColor color)
+    {
+        color = TerminalColor.Default;
+        if (string.IsNullOrWhiteSpace(value))
+        {
+            return false;
+        }
+
+        value = value.Trim();
+
+        if (value[0] == '#')
+        {
+            return TryParseHex(value.AsSpan(1), out color);
+        }
+
+        if (Named.TryGetValue(value, out var rgb))
+        {
+            color = TerminalColor.FromRgb(rgb.R, rgb.G, rgb.B);
+            return true;
+        }
+
+        return false;
+    }
+
+    private static bool TryParseHex(ReadOnlySpan hex, out TerminalColor color)
+    {
+        color = TerminalColor.Default;
+        const System.Globalization.NumberStyles h = System.Globalization.NumberStyles.HexNumber;
+
+        if (hex.Length == 6 &&
+            byte.TryParse(hex[..2], h, null, out var r) &&
+            byte.TryParse(hex[2..4], h, null, out var g) &&
+            byte.TryParse(hex[4..6], h, null, out var b))
+        {
+            color = TerminalColor.FromRgb(r, g, b);
+            return true;
+        }
+
+        // Short form #rgb → #rrggbb.
+        if (hex.Length == 3 &&
+            byte.TryParse(hex[..1], h, null, out var sr) &&
+            byte.TryParse(hex[1..2], h, null, out var sg) &&
+            byte.TryParse(hex[2..3], h, null, out var sb))
+        {
+            color = TerminalColor.FromRgb((byte)(sr * 17), (byte)(sg * 17), (byte)(sb * 17));
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/src/MuClient.Graphics/KittyGraphicsProtocol.cs b/src/MuClient.Graphics/KittyGraphicsProtocol.cs
index b01f10c..e4b7aaa 100644
--- a/src/MuClient.Graphics/KittyGraphicsProtocol.cs
+++ b/src/MuClient.Graphics/KittyGraphicsProtocol.cs
@@ -152,6 +152,14 @@ public IReadOnlyList BuildPlaceholder(int imageId, int cols, int row
                 $"Placeholder grid up to {RowColumnDiacritics.Length}x{RowColumnDiacritics.Length} is supported.");
         }
 
+        // The placeholder carries the image id in a 24-bit foreground colour, so ids above
+        // 0xFFFFFF cannot round-trip. Reject them rather than silently truncating.
+        if (imageId is < 0 or > 0xFFFFFF)
+        {
+            throw new ArgumentOutOfRangeException(
+                nameof(imageId), imageId, "Placeholder image id must fit in 24 bits (0-0xFFFFFF).");
+        }
+
         // Carry the 24-bit image id in the foreground colour, per the Kitty spec.
         var idColor = TerminalColor.FromRgb(
             (byte)((imageId >> 16) & 0xFF),
diff --git a/src/MuClient.Scripting/ScriptException.cs b/src/MuClient.Scripting/ScriptException.cs
index 4650a02..a94c560 100644
--- a/src/MuClient.Scripting/ScriptException.cs
+++ b/src/MuClient.Scripting/ScriptException.cs
@@ -26,25 +26,35 @@ internal static ScriptException FromInterpreter(InterpreterException ex)
 
     private static int? TryExtractLine(InterpreterException ex)
     {
-        // MoonSharp encodes location as "chunk:(fromLine,fromCol-toLine,toCol)" in DecoratedMessage.
         var decorated = ex.DecoratedMessage;
         if (string.IsNullOrEmpty(decorated))
         {
             return null;
         }
 
+        // Syntax errors: "chunk:(fromLine,fromCol-toLine,toCol) message".
         var open = decorated.IndexOf('(');
-        if (open < 0 || open + 1 >= decorated.Length)
+        if (open >= 0 && open + 1 < decorated.Length)
         {
-            return null;
+            var comma = decorated.IndexOf(',', open);
+            if (comma > open && int.TryParse(decorated.AsSpan(open + 1, comma - open - 1), out var parenLine))
+            {
+                return parenLine;
+            }
         }
 
-        var comma = decorated.IndexOf(',', open);
-        if (comma < 0)
+        // Runtime errors: "[string \"chunk\"]:LINE: message" (no parenthesised range).
+        var bracket = decorated.LastIndexOf(']');
+        var start = bracket >= 0 ? bracket + 1 : 0;
+        if (start < decorated.Length && decorated[start] == ':')
         {
-            return null;
+            var colon = decorated.IndexOf(':', start + 1);
+            if (colon > start && int.TryParse(decorated.AsSpan(start + 1, colon - start - 1), out var runtimeLine))
+            {
+                return runtimeLine;
+            }
         }
 
-        return int.TryParse(decorated.AsSpan(open + 1, comma - open - 1), out var line) ? line : null;
+        return null;
     }
 }
diff --git a/src/MuClient.Tui/GmcpStats.cs b/src/MuClient.Tui/GmcpStats.cs
new file mode 100644
index 0000000..95f5106
--- /dev/null
+++ b/src/MuClient.Tui/GmcpStats.cs
@@ -0,0 +1,100 @@
+using System.Text;
+using System.Text.Json;
+
+namespace MuClient.Tui;
+
+/// 
+/// Accumulates GMCP character stats (e.g. Char.Vitals, Char.Stats) into a flat
+/// key/value model and renders a compact one-line summary for the stat pane. Deliberately
+/// generic: any flat JSON object's scalar fields are captured.
+/// 
+internal sealed class GmcpStats
+{
+    private static readonly string[] PreferredOrder =
+        ["hp", "maxhp", "mp", "maxmp", "sp", "maxsp", "level", "xp", "gold"];
+
+    private readonly Dictionary _values = new(StringComparer.OrdinalIgnoreCase);
+
+    /// Merges a GMCP message. Returns true if any stat changed.
+    public bool Update(string package, string json)
+    {
+        if (!package.StartsWith("Char", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(json))
+        {
+            return false;
+        }
+
+        JsonElement root;
+        try
+        {
+            using var doc = JsonDocument.Parse(json);
+            root = doc.RootElement.Clone();
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+
+        if (root.ValueKind != JsonValueKind.Object)
+        {
+            return false;
+        }
+
+        var changed = false;
+        foreach (var property in root.EnumerateObject())
+        {
+            if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
+            {
+                continue;
+            }
+
+            var value = property.Value.ToString();
+            if (!_values.TryGetValue(property.Name, out var existing) || existing != value)
+            {
+                _values[property.Name] = value;
+                changed = true;
+            }
+        }
+
+        return changed;
+    }
+
+    public bool HasData => _values.Count > 0;
+
+    /// A compact one-line summary, preferred vitals first.
+    public string Summarize()
+    {
+        if (_values.Count == 0)
+        {
+            return string.Empty;
+        }
+
+        var sb = new StringBuilder();
+        foreach (var key in PreferredOrder)
+        {
+            if (_values.TryGetValue(key, out var value))
+            {
+                Append(sb, key, value);
+            }
+        }
+
+        foreach (var (key, value) in _values)
+        {
+            if (Array.IndexOf(PreferredOrder, key.ToLowerInvariant()) < 0)
+            {
+                Append(sb, key, value);
+            }
+        }
+
+        return sb.ToString();
+    }
+
+    private static void Append(StringBuilder sb, string key, string value)
+    {
+        if (sb.Length > 0)
+        {
+            sb.Append("  ");
+        }
+
+        sb.Append(key).Append(':').Append(value);
+    }
+}
diff --git a/src/MuClient.Tui/MuClient.Tui.csproj b/src/MuClient.Tui/MuClient.Tui.csproj
index 113f9dc..a160bb3 100644
--- a/src/MuClient.Tui/MuClient.Tui.csproj
+++ b/src/MuClient.Tui/MuClient.Tui.csproj
@@ -10,6 +10,15 @@
       "legacy Application is going away" obsoletion until the replacement API stabilises.
     -->
     $(NoWarn);CS0618
+
+    
+    linux-x64;linux-arm64;win-x64;osx-x64;osx-arm64
+    en
   
 
   
diff --git a/src/MuClient.Tui/MuGlyphApp.cs b/src/MuClient.Tui/MuGlyphApp.cs
index ebb741c..8e96332 100644
--- a/src/MuClient.Tui/MuGlyphApp.cs
+++ b/src/MuClient.Tui/MuGlyphApp.cs
@@ -27,6 +27,8 @@ internal sealed class MuGlyphApp : IAsyncDisposable
     private readonly Label _status;
     private readonly OutputView _output;
     private readonly CommandInput _input;
+    private readonly GmcpStats _stats = new();
+    private readonly HashSet _spawnTargets = new(StringComparer.OrdinalIgnoreCase);
 
     private WorldSession? _active;
 
@@ -64,6 +66,8 @@ public MuGlyphApp(AppConfiguration config, TerminalCapabilities capabilities)
             Height = 1,
         };
         _input.CommandEntered += OnCommandEntered;
+        _output.CommandActivated += OnCommandActivated;
+        _output.LinkActivated += OpenLink;
 
         _window.Add(_status, _output, _input);
         _window.KeyDown += OnGlobalKey;
@@ -115,9 +119,27 @@ private void BindSession(WorldSession session)
 
         session.PromptChanged += (_, _) => Application.Invoke(() => _output.SetNeedsDraw());
         session.StateChanged += (_, _) => Application.Invoke(UpdateStatus);
+        session.GmcpReceived += (_, e) => Application.Invoke(() =>
+        {
+            if (_stats.Update(e.Package, e.Json))
+            {
+                UpdateStatus();
+            }
+        });
+        session.SpawnLine += (_, e) => Application.Invoke(() => OnSpawnLine(e.Target));
         UpdateStatus();
     }
 
+    private void OnSpawnLine(string target)
+    {
+        // Spawn output is captured; dedicated spawn windows are a follow-up. Announce a target
+        // the first time it routes so the user knows a spawn fired.
+        if (_spawnTargets.Add(target))
+        {
+            _active?.PrintSystem($"*** Spawn '{target}' is now receiving routed output.");
+        }
+    }
+
     private void OnCommandEntered(string command)
     {
         var session = _active;
@@ -129,6 +151,40 @@ private void OnCommandEntered(string command)
         _ = session.SendUserInputAsync(command);
     }
 
+    private void OnCommandActivated(string command, bool promptOnly)
+    {
+        if (promptOnly)
+        {
+            _input.Text = command;
+            _input.SetFocus();
+            return;
+        }
+
+        _ = _active?.SendRawAsync(command);
+    }
+
+    private static void OpenLink(string url)
+    {
+        // Only open well-formed http(s) links, via the OS default handler.
+        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
+            uri.Scheme is not ("http" or "https"))
+        {
+            return;
+        }
+
+        try
+        {
+            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(uri.ToString())
+            {
+                UseShellExecute = true,
+            });
+        }
+        catch
+        {
+            // No browser available (e.g. headless) — ignore.
+        }
+    }
+
     private void OnGlobalKey(object? sender, Key key)
     {
         switch (key.KeyCode)
diff --git a/src/MuClient.Tui/Properties/PublishProfiles/linux-x64.pubxml b/src/MuClient.Tui/Properties/PublishProfiles/linux-x64.pubxml
new file mode 100644
index 0000000..1fa9e5f
--- /dev/null
+++ b/src/MuClient.Tui/Properties/PublishProfiles/linux-x64.pubxml
@@ -0,0 +1,13 @@
+
+
+
+  
+    Release
+    linux-x64
+    true
+    true
+    true
+    true
+    true
+  
+
diff --git a/src/MuClient.Tui/Properties/PublishProfiles/win-x64.pubxml b/src/MuClient.Tui/Properties/PublishProfiles/win-x64.pubxml
new file mode 100644
index 0000000..d28c2b5
--- /dev/null
+++ b/src/MuClient.Tui/Properties/PublishProfiles/win-x64.pubxml
@@ -0,0 +1,13 @@
+
+
+
+  
+    Release
+    win-x64
+    true
+    true
+    true
+    true
+    true
+  
+
diff --git a/src/MuClient.Tui/Views/OutputView.cs b/src/MuClient.Tui/Views/OutputView.cs
index 4cfefcc..ad6fe5d 100644
--- a/src/MuClient.Tui/Views/OutputView.cs
+++ b/src/MuClient.Tui/Views/OutputView.cs
@@ -2,28 +2,40 @@
 using MuClient.Core.Session;
 using MuClient.Core.Text;
 using MuClient.Core.Theming;
+using Terminal.Gui.Input;
 using Terminal.Gui.ViewBase;
 
 namespace MuClient.Tui.Views;
 
 /// 
 /// Renders a 's scrollback (plus its current prompt) with truecolor
-/// styling, word/character wrapping, and vertical scrolling. Custom-drawn because Terminal.Gui's
-/// cell grid has no concept of our styled-span model.
+/// styling, wrapping, and vertical scrolling, and makes MXP/Pueblo interactive spans clickable.
+/// Custom-drawn because Terminal.Gui's cell grid has no concept of our styled-span model.
 /// 
 internal sealed class OutputView : View
 {
     private WorldSession? _session;
     private int _scrollOffset; // rows scrolled up from the bottom (0 = following the tail)
 
+    // Maps a rendered cell (screen row, col) to the interaction of the span drawn there, so a
+    // click can be resolved back to a command/link. Rebuilt on every draw.
+    private readonly Dictionary<(int Row, int Col), SpanInteraction> _cellInteractions = new();
+
     public OutputView()
     {
         CanFocus = false;
+        MouseEvent += OnMouseEvent;
     }
 
     /// The theme-aware colour mapper used to render styled spans.
     public ColorMapper Mapper { get; set; } = new(ThemeLibrary.Dark());
 
+    /// Raised when a clickable command span is activated (command, promptOnly).
+    public event Action? CommandActivated;
+
+    /// Raised when a hyperlink span is activated.
+    public event Action? LinkActivated;
+
     public WorldSession? Session
     {
         get => _session;
@@ -35,7 +47,6 @@ public WorldSession? Session
         }
     }
 
-    /// True when the view is pinned to the newest output.
     public bool AtBottom => _scrollOffset == 0;
 
     public void ScrollToBottom()
@@ -54,8 +65,37 @@ public void ScrollLines(int delta)
 
     public void PageDown() => ScrollLines(-Math.Max(1, Viewport.Height - 1));
 
+    private void OnMouseEvent(object? sender, Mouse mouse)
+    {
+        if (!mouse.IsSingleClicked || mouse.Position is not { } position)
+        {
+            return;
+        }
+
+        if (_cellInteractions.TryGetValue((position.Y, position.X), out var interaction))
+        {
+            Activate(interaction);
+            mouse.Handled = true;
+        }
+    }
+
+    private void Activate(SpanInteraction interaction)
+    {
+        switch (interaction.Kind)
+        {
+            case InteractionKind.SendCommand:
+                CommandActivated?.Invoke(interaction.Target, interaction.PromptOnly);
+                break;
+            case InteractionKind.Hyperlink:
+                LinkActivated?.Invoke(interaction.Target);
+                break;
+        }
+    }
+
     protected override bool OnDrawingContent(DrawContext? context)
     {
+        _cellInteractions.Clear();
+
         var viewport = Viewport;
         var width = Math.Max(1, viewport.Width);
         var height = Math.Max(1, viewport.Height);
@@ -66,7 +106,6 @@ protected override bool OnDrawingContent(DrawContext? context)
             return true;
         }
 
-        // Clamp scroll so we never page past the top.
         var maxOffset = Math.Max(0, rows.Count - height);
         if (_scrollOffset > maxOffset)
         {
@@ -85,30 +124,31 @@ protected override bool OnDrawingContent(DrawContext? context)
         return true;
     }
 
-    private void DrawRow(IReadOnlyList<(Rune Rune, TextStyle Style)> row, int screenRow, int width)
+    private void DrawRow(IReadOnlyList row, int screenRow, int width)
     {
         Move(0, screenRow);
         var col = 0;
-        foreach (var (rune, style) in row)
+        foreach (var cell in row)
         {
             if (col >= width)
             {
                 break;
             }
 
-            SetAttribute(Mapper.ToAttribute(style));
-            AddRune(col, screenRow, rune);
+            SetAttribute(Mapper.ToAttribute(cell.Style));
+            AddRune(col, screenRow, cell.Rune);
+            if (cell.Interaction is not null)
+            {
+                _cellInteractions[(screenRow, col)] = cell.Interaction;
+            }
+
             col++;
         }
     }
 
-    /// 
-    /// Builds up to  visual (wrapped) rows from the tail of the
-    /// scrollback and the active prompt, newest last.
-    /// 
-    private List> BuildVisualRows(int width, int maxRows)
+    private List> BuildVisualRows(int width, int maxRows)
     {
-        var result = new List>();
+        var result = new List>();
         if (_session is null)
         {
             return result;
@@ -121,7 +161,6 @@ private void DrawRow(IReadOnlyList<(Rune Rune, TextStyle Style)> row, int screen
             logical.Add(prompt);
         }
 
-        // Walk logical lines from the end, wrapping each and prepending, until we have enough.
         for (var i = logical.Count - 1; i >= 0 && result.Count < maxRows; i--)
         {
             var wrapped = WrapLine(logical[i], width);
@@ -134,10 +173,10 @@ private void DrawRow(IReadOnlyList<(Rune Rune, TextStyle Style)> row, int screen
         return result;
     }
 
-    private static List> WrapLine(StyledLine line, int width)
+    private static List> WrapLine(StyledLine line, int width)
     {
-        var rows = new List>();
-        var current = new List<(Rune, TextStyle)>();
+        var rows = new List>();
+        var current = new List();
 
         foreach (var span in line.Spans)
         {
@@ -145,27 +184,25 @@ private void DrawRow(IReadOnlyList<(Rune Rune, TextStyle Style)> row, int screen
             {
                 if (rune.Value == '\t')
                 {
-                    // Expand tabs to the next multiple of 4 columns.
                     var stop = 4 - current.Count % 4;
                     for (var s = 0; s < stop && current.Count < width; s++)
                     {
-                        current.Add((new Rune(' '), span.Style));
+                        current.Add(new Cell(new Rune(' '), span.Style, span.Interaction));
                     }
                 }
                 else
                 {
-                    current.Add((rune, span.Style));
+                    current.Add(new Cell(rune, span.Style, span.Interaction));
                 }
 
                 if (current.Count >= width)
                 {
                     rows.Add(current);
-                    current = new List<(Rune, TextStyle)>();
+                    current = new List();
                 }
             }
         }
 
-        // Always emit at least one (possibly empty) row so blank lines occupy space.
         if (current.Count > 0 || rows.Count == 0)
         {
             rows.Add(current);
@@ -173,4 +210,6 @@ private void DrawRow(IReadOnlyList<(Rune Rune, TextStyle Style)> row, int screen
 
         return rows;
     }
+
+    private readonly record struct Cell(Rune Rune, TextStyle Style, SpanInteraction? Interaction);
 }
diff --git a/tests/MuClient.Core.Tests/Protocols/MxpParserTests.cs b/tests/MuClient.Core.Tests/Protocols/MxpParserTests.cs
new file mode 100644
index 0000000..4626028
--- /dev/null
+++ b/tests/MuClient.Core.Tests/Protocols/MxpParserTests.cs
@@ -0,0 +1,395 @@
+using MuClient.Core.Protocols;
+using MuClient.Core.Text;
+
+namespace MuClient.Core.Tests.Protocols;
+
+public class MxpParserTests
+{
+    private static StyledLine ParseSingleLine(string input)
+    {
+        var parser = new MxpParser();
+        var lines = parser.Feed(input);
+        if (lines.Count != 1)
+        {
+            throw new InvalidOperationException($"Expected 1 line, got {lines.Count}.");
+        }
+
+        return lines[0];
+    }
+
+    [Test]
+    public async Task PlainText_ProducesSingleDefaultSpan()
+    {
+        var line = ParseSingleLine("hello world\n");
+        await Assert.That(line.Spans).HasSingleItem();
+        await Assert.That(line.Spans[0].Text).IsEqualTo("hello world");
+        await Assert.That(line.Spans[0].Style).IsEqualTo(TextStyle.Default);
+        await Assert.That(line.Spans[0].IsInteractive).IsFalse();
+    }
+
+    [Test]
+    public async Task Feed_SplitsOnNewlines()
+    {
+        var parser = new MxpParser();
+        var lines = parser.Feed("one\ntwo\nthree\n");
+        await Assert.That(lines).Count().IsEqualTo(3);
+        await Assert.That(lines[0].Text).IsEqualTo("one");
+        await Assert.That(lines[1].Text).IsEqualTo("two");
+        await Assert.That(lines[2].Text).IsEqualTo("three");
+    }
+
+    [Test]
+    public async Task Flush_ReturnsPartialPromptLine()
+    {
+        var parser = new MxpParser();
+        var lines = parser.Feed("Enter password:");
+        await Assert.That(lines).Count().IsEqualTo(0);
+
+        var prompt = parser.Flush();
+        await Assert.That(prompt).IsNotNull();
+        await Assert.That(prompt!.Text).IsEqualTo("Enter password:");
+    }
+
+    [Test]
+    public async Task Flush_ReturnsNullWhenNothingBuffered()
+    {
+        var parser = new MxpParser();
+        parser.Feed("done\n");
+        await Assert.That(parser.Flush()).IsNull();
+    }
+
+    [Test]
+    public async Task CarriageReturn_IsDropped()
+    {
+        var line = ParseSingleLine("prompt\r\n");
+        await Assert.That(line.Text).IsEqualTo("prompt");
+    }
+
+    [Test]
+    public async Task BlankLine_IsEmittedAsEmpty()
+    {
+        var parser = new MxpParser();
+        var lines = parser.Feed("a\n\nb\n");
+        await Assert.That(lines).Count().IsEqualTo(3);
+        await Assert.That(lines[1].IsEmpty).IsTrue();
+    }
+
+    [Test]
+    public async Task Bold_TogglesBoldAttribute()
+    {
+        var line = ParseSingleLine("bold plain\n");
+        await Assert.That(line.Spans).Count().IsEqualTo(2);
+        await Assert.That(line.Spans[0].Text).IsEqualTo("bold");
+        await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue();
+        await Assert.That(line.Spans[1].Text).IsEqualTo(" plain");
+        await Assert.That(line.Spans[1].Style.HasAttribute(TextAttributes.Bold)).IsFalse();
+    }
+
+    [Test]
+    public async Task BoldAlias_StrongProducesBold()
+    {
+        var line = ParseSingleLine("x\n");
+        await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue();
+    }
+
+    [Test]
+    public async Task Italic_And_Underline_And_Strikeout()
+    {
+        var line = ParseSingleLine("ius\n");
+        await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Italic)).IsTrue();
+        await Assert.That(line.Spans[1].Style.HasAttribute(TextAttributes.Underline)).IsTrue();
+        await Assert.That(line.Spans[2].Style.HasAttribute(TextAttributes.Strikethrough)).IsTrue();
+    }
+
+    [Test]
+    public async Task NestedFormatting_CombinesAttributes()
+    {
+        var line = ParseSingleLine("x\n");
+        await Assert.That(line.Spans).HasSingleItem();
+        await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue();
+        await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Italic)).IsTrue();
+    }
+
+    [Test]
+    public async Task NestedFormatting_CloseRevertsInnerOnly()
+    {
+        var line = ParseSingleLine("abc\n");
+        await Assert.That(line.Spans).Count().IsEqualTo(3);
+        await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Italic)).IsFalse();
+        await Assert.That(line.Spans[1].Style.HasAttribute(TextAttributes.Italic)).IsTrue();
+        await Assert.That(line.Spans[2].Style.HasAttribute(TextAttributes.Bold)).IsTrue();
+        await Assert.That(line.Spans[2].Style.HasAttribute(TextAttributes.Italic)).IsFalse();
+    }
+
+    [Test]
+    public async Task UnbalancedCloser_DoesNotThrow()
+    {
+        var line = ParseSingleLine("plain more\n");
+        await Assert.That(line.Text).IsEqualTo("plain more");
+    }
+
+    [Test]
+    public async Task StrayOpenBoldWithoutClose_AppliesToRestOfLine()
+    {
+        var line = ParseSingleLine("rest of line\n");
+        await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue();
+    }
+
+    [Test]
+    public async Task Color_ForeNamed_SetsForeground()
+    {
+        WebColors.TryParse("red", out var red);
+        var line = ParseSingleLine("hot\n");
+        await Assert.That(line.Spans[0].Text).IsEqualTo("hot");
+        await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(red);
+    }
+
+    [Test]
+    public async Task ColorShortForm_C_WithForeAndBack()
+    {
+        WebColors.TryParse("white", out var white);
+        WebColors.TryParse("blue", out var blue);
+        var line = ParseSingleLine("x\n");
+        await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(white);
+        await Assert.That(line.Spans[0].Style.Background).IsEqualTo(blue);
+    }
+
+    [Test]
+    public async Task ColorPositional_FirstIsForeground()
+    {
+        WebColors.TryParse("green", out var green);
+        var line = ParseSingleLine("go\n");
+        await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(green);
+    }
+
+    [Test]
+    public async Task ColorClose_RevertsForeground()
+    {
+        var line = ParseSingleLine("redback\n");
+        await Assert.That(line.Spans).Count().IsEqualTo(2);
+        await Assert.That(line.Spans[1].Text).IsEqualTo("back");
+        await Assert.That(line.Spans[1].Style.Foreground).IsEqualTo(TerminalColor.Default);
+    }
+
+    [Test]
+    public async Task Font_ColorAttribute_SetsForegroundHex()
+    {
+        WebColors.TryParse("#00ff00", out var lime);
+        var line = ParseSingleLine("x\n");
+        await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(lime);
+    }
+
+    [Test]
+    public async Task Color_UnknownName_IsIgnored()
+    {
+        var line = ParseSingleLine("x\n");
+        await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.Default);
+    }
+
+    [Test]
+    public async Task Send_WithHref_ProducesSendCommand()
+    {
+        var line = ParseSingleLine("here\n");
+        await Assert.That(line.Spans).HasSingleItem();
+        var span = line.Spans[0];
+        await Assert.That(span.Text).IsEqualTo("here");
+        await Assert.That(span.IsInteractive).IsTrue();
+        await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.SendCommand);
+        await Assert.That(span.Interaction!.Target).IsEqualTo("look");
+    }
+
+    [Test]
+    public async Task Send_WithoutHref_UsesEnclosedTextAsCommand()
+    {
+        var line = ParseSingleLine("north\n");
+        var span = line.Spans[0];
+        await Assert.That(span.Text).IsEqualTo("north");
+        await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.SendCommand);
+        await Assert.That(span.Interaction!.Target).IsEqualTo("north");
+    }
+
+    [Test]
+    public async Task Send_CapturesHint()
+    {
+        var line = ParseSingleLine("x\n");
+        await Assert.That(line.Spans[0].Interaction!.Hint).IsEqualTo("examine");
+    }
+
+    [Test]
+    public async Task Send_MultiCommandHref_UsesFirstAsPrimary()
+    {
+        var line = ParseSingleLine("x\n");
+        await Assert.That(line.Spans[0].Interaction!.Target).IsEqualTo("a");
+    }
+
+    [Test]
+    public async Task Send_PromptFlag_SetsPromptOnly()
+    {
+        var line = ParseSingleLine("x\n");
+        await Assert.That(line.Spans[0].Interaction!.PromptOnly).IsTrue();
+    }
+
+    [Test]
+    public async Task Send_BareWithoutClose_ClosesAtEndOfLine()
+    {
+        var parser = new MxpParser();
+        var lines = parser.Feed("walk\nnext line\n");
+        await Assert.That(lines).Count().IsEqualTo(2);
+        await Assert.That(lines[0].Spans[0].Interaction!.Target).IsEqualTo("go");
+        await Assert.That(lines[1].Spans[0].IsInteractive).IsFalse();
+    }
+
+    [Test]
+    public async Task Anchor_WithHref_ProducesHyperlink()
+    {
+        var line = ParseSingleLine("site\n");
+        var span = line.Spans[0];
+        await Assert.That(span.Text).IsEqualTo("site");
+        await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.Hyperlink);
+        await Assert.That(span.Interaction!.Target).IsEqualTo("https://example.com");
+    }
+
+    [Test]
+    public async Task Entities_AreDecoded()
+    {
+        var line = ParseSingleLine("<>& A\n");
+        await Assert.That(line.Text).IsEqualTo("<>& A");
+    }
+
+    [Test]
+    public async Task Entities_QuoteAndApos()
+    {
+        var line = ParseSingleLine(""'\n");
+        await Assert.That(line.Text).IsEqualTo("\"'");
+    }
+
+    [Test]
+    public async Task Entity_HexNumeric_IsDecoded()
+    {
+        var line = ParseSingleLine("AB\n");
+        await Assert.That(line.Text).IsEqualTo("AB");
+    }
+
+    [Test]
+    public async Task Entity_Unknown_IsEmittedLiterally()
+    {
+        var line = ParseSingleLine("&unknown;\n");
+        await Assert.That(line.Text).IsEqualTo("&unknown;");
+    }
+
+    [Test]
+    public async Task StrayAmpersand_IsEmittedLiterally()
+    {
+        var line = ParseSingleLine("Tom & Jerry\n");
+        await Assert.That(line.Text).IsEqualTo("Tom & Jerry");
+    }
+
+    [Test]
+    public async Task UnknownTag_IsConsumedNotLeaked()
+    {
+        var line = ParseSingleLine("ab\n");
+        await Assert.That(line.Text).IsEqualTo("ab");
+    }
+
+    [Test]
+    public async Task UnsupportedTags_AreStripped()
+    {
+        var line = ParseSingleLine("

Title

textend\n"); + await Assert.That(line.Text).IsEqualTo("Titletextend"); + } + + [Test] + public async Task Br_ProducesLineBreak() + { + var parser = new MxpParser(); + var lines = parser.Feed("first
second\n"); + await Assert.That(lines).Count().IsEqualTo(2); + await Assert.That(lines[0].Text).IsEqualTo("first"); + await Assert.That(lines[1].Text).IsEqualTo("second"); + } + + [Test] + public async Task StrayLessThan_IsEmittedLiterally() + { + var line = ParseSingleLine("5 < 3 is false\n"); + await Assert.That(line.Text).IsEqualTo("5 < 3 is false"); + } + + [Test] + public async Task TagSplitAcrossFeeds_IsReassembled() + { + var parser = new MxpParser(); + var first = parser.Feed("hot\n"); + WebColors.TryParse("red", out var red); + await Assert.That(second).HasSingleItem(); + await Assert.That(second[0].Spans[0].Text).IsEqualTo("hot"); + await Assert.That(second[0].Spans[0].Style.Foreground).IsEqualTo(red); + } + + [Test] + public async Task EntitySplitAcrossFeeds_IsReassembled() + { + var parser = new MxpParser(); + parser.Feed("x&a"); + var lines = parser.Feed("mp;y\n"); + await Assert.That(lines[0].Text).IsEqualTo("x&y"); + } + + [Test] + public async Task StyleState_PersistsAcrossFeeds() + { + var parser = new MxpParser(); + parser.Feed("bold "); + await Assert.That(parser.CurrentStyle.HasAttribute(TextAttributes.Bold)).IsTrue(); + var lines = parser.Feed("still bold\n"); + await Assert.That(lines[0].Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task SelfClosingBr_IsHandled() + { + var parser = new MxpParser(); + var lines = parser.Feed("a
b\n"); + await Assert.That(lines).Count().IsEqualTo(2); + await Assert.That(lines[0].Text).IsEqualTo("a"); + } + + [Test] + public async Task EscapeByte_IsPassedThrough() + { + var line = ParseSingleLine("a\x1bb\n"); + await Assert.That(line.Text).IsEqualTo("a\x1bb"); + } + + [Test] + public async Task Send_WithFormattingInside_KeepsInteractionOnAllSpans() + { + var line = ParseSingleLine("walk now\n"); + await Assert.That(line.Spans).Count().IsEqualTo(2); + await Assert.That(line.Spans[0].Interaction!.Target).IsEqualTo("go"); + await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + await Assert.That(line.Spans[1].Interaction!.Target).IsEqualTo("go"); + } + + [Test] + public async Task Reset_ClearsStyleAndStack() + { + var parser = new MxpParser(); + parser.Feed("bold"); + parser.Reset(); + await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); + await Assert.That(parser.HasPendingContent).IsFalse(); + var lines = parser.Feed("plain\n"); + await Assert.That(lines[0].Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsFalse(); + } + + [Test] + public async Task HasPendingContent_TrueWithBufferedText() + { + var parser = new MxpParser(); + parser.Feed("partial"); + await Assert.That(parser.HasPendingContent).IsTrue(); + } +} diff --git a/tests/MuClient.Core.Tests/Protocols/PuebloParserTests.cs b/tests/MuClient.Core.Tests/Protocols/PuebloParserTests.cs new file mode 100644 index 0000000..f4a716e --- /dev/null +++ b/tests/MuClient.Core.Tests/Protocols/PuebloParserTests.cs @@ -0,0 +1,323 @@ +using MuClient.Core.Protocols; +using MuClient.Core.Text; + +namespace MuClient.Core.Tests.Protocols; + +public class PuebloParserTests +{ + private static StyledLine ParseSingleLine(string input) + { + var parser = new PuebloParser(); + var lines = parser.Feed(input); + if (lines.Count != 1) + { + throw new InvalidOperationException($"Expected 1 line, got {lines.Count}."); + } + + return lines[0]; + } + + [Test] + public async Task PlainText_ProducesSingleDefaultSpan() + { + var line = ParseSingleLine("hello world\n"); + await Assert.That(line.Spans).HasSingleItem(); + await Assert.That(line.Spans[0].Text).IsEqualTo("hello world"); + await Assert.That(line.Spans[0].Style).IsEqualTo(TextStyle.Default); + await Assert.That(line.Spans[0].IsInteractive).IsFalse(); + } + + [Test] + public async Task Feed_SplitsOnNewlines() + { + var parser = new PuebloParser(); + var lines = parser.Feed("one\ntwo\nthree\n"); + await Assert.That(lines).Count().IsEqualTo(3); + await Assert.That(lines[0].Text).IsEqualTo("one"); + await Assert.That(lines[1].Text).IsEqualTo("two"); + await Assert.That(lines[2].Text).IsEqualTo("three"); + } + + [Test] + public async Task Flush_ReturnsPartialLine() + { + var parser = new PuebloParser(); + var lines = parser.Feed("prompt> "); + await Assert.That(lines).Count().IsEqualTo(0); + await Assert.That(parser.HasPendingContent).IsTrue(); + + var flushed = parser.Flush(); + await Assert.That(flushed).IsNotNull(); + await Assert.That(flushed!.Text).IsEqualTo("prompt> "); + await Assert.That(parser.Flush()).IsNull(); + } + + [Test] + public async Task Bold_SetsBoldAttribute() + { + var line = ParseSingleLine("hi\n"); + await Assert.That(line.Spans).HasSingleItem(); + await Assert.That(line.Spans[0].Text).IsEqualTo("hi"); + await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task Strong_IsBold_AndClosesAsB() + { + // Alias closer: opened, closes it. + var line = ParseSingleLine("a
b\n"); + await Assert.That(line.Spans).Count().IsEqualTo(2); + await Assert.That(line.Spans[0].Text).IsEqualTo("a"); + await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + await Assert.That(line.Spans[1].Text).IsEqualTo("b"); + await Assert.That(line.Spans[1].Style.HasAttribute(TextAttributes.Bold)).IsFalse(); + } + + [Test] + public async Task ItalicAndUnderlineAndStrike_SetAttributes() + { + var i = ParseSingleLine("x\n"); + await Assert.That(i.Spans[0].Style.HasAttribute(TextAttributes.Italic)).IsTrue(); + + var u = ParseSingleLine("x\n"); + await Assert.That(u.Spans[0].Style.HasAttribute(TextAttributes.Underline)).IsTrue(); + + var s = ParseSingleLine("x\n"); + await Assert.That(s.Spans[0].Style.HasAttribute(TextAttributes.Strikethrough)).IsTrue(); + } + + [Test] + public async Task NestedFormatting_TogglesCorrectly() + { + var line = ParseSingleLine("x\n"); + await Assert.That(line.Spans).HasSingleItem(); + var style = line.Spans[0].Style; + await Assert.That(style.HasAttribute(TextAttributes.Bold)).IsTrue(); + await Assert.That(style.HasAttribute(TextAttributes.Italic)).IsTrue(); + } + + [Test] + public async Task Font_Color_SetsForegroundThenReverts() + { + var line = ParseSingleLine("abc\n"); + await Assert.That(line.Spans).Count().IsEqualTo(3); + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.Default); + await Assert.That(line.Spans[1].Text).IsEqualTo("b"); + await Assert.That(line.Spans[1].Style.Foreground).IsEqualTo(TerminalColor.FromRgb(0xff, 0x00, 0x00)); + await Assert.That(line.Spans[2].Style.Foreground).IsEqualTo(TerminalColor.Default); + } + + [Test] + public async Task Font_BgColor_HexSetsBackground() + { + var line = ParseSingleLine("x\n"); + await Assert.That(line.Spans).HasSingleItem(); + await Assert.That(line.Spans[0].Style.Background).IsEqualTo(TerminalColor.FromRgb(0x00, 0x00, 0xff)); + } + + [Test] + public async Task Font_UnquotedColor_IsAccepted() + { + var line = ParseSingleLine("x\n"); + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromRgb(0x00, 0xff, 0x00)); + } + + [Test] + public async Task Anchor_XchCmd_ProducesSendCommand() + { + var line = ParseSingleLine("here\n"); + await Assert.That(line.Spans).HasSingleItem(); + var span = line.Spans[0]; + await Assert.That(span.Text).IsEqualTo("here"); + await Assert.That(span.IsInteractive).IsTrue(); + await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.SendCommand); + await Assert.That(span.Interaction!.Target).IsEqualTo("look"); + await Assert.That(span.Interaction!.Hint).IsEqualTo("look around"); + await Assert.That(span.Interaction!.PromptOnly).IsFalse(); + } + + [Test] + public async Task Anchor_XchCmd_RevertsAfterClose() + { + var line = ParseSingleLine("here plain\n"); + await Assert.That(line.Spans).Count().IsEqualTo(2); + await Assert.That(line.Spans[0].IsInteractive).IsTrue(); + await Assert.That(line.Spans[1].Text).IsEqualTo(" plain"); + await Assert.That(line.Spans[1].IsInteractive).IsFalse(); + } + + [Test] + public async Task Anchor_XchMode_Prompt_SetsPromptOnly() + { + var line = ParseSingleLine("x\n"); + await Assert.That(line.Spans[0].Interaction!.PromptOnly).IsTrue(); + } + + [Test] + public async Task Anchor_Href_ProducesHyperlink() + { + var line = ParseSingleLine("site\n"); + await Assert.That(line.Spans).HasSingleItem(); + var span = line.Spans[0]; + await Assert.That(span.Text).IsEqualTo("site"); + await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.Hyperlink); + await Assert.That(span.Interaction!.Target).IsEqualTo("https://example.com"); + } + + [Test] + public async Task Send_Href_ProducesSendCommand() + { + var line = ParseSingleLine("go\n"); + await Assert.That(line.Spans[0].Interaction!.Kind).IsEqualTo(InteractionKind.SendCommand); + await Assert.That(line.Spans[0].Interaction!.Target).IsEqualTo("north"); + } + + [Test] + public async Task Entities_ResolveToCharacters() + { + var line = ParseSingleLine("<>& A\n"); + await Assert.That(line.Text).IsEqualTo("<>& A"); + } + + [Test] + public async Task Entities_QuotAndApos() + { + var line = ParseSingleLine(""'\n"); + await Assert.That(line.Text).IsEqualTo("\"'"); + } + + [Test] + public async Task Entity_HexNumeric_Resolves() + { + var line = ParseSingleLine("AB\n"); + await Assert.That(line.Text).IsEqualTo("AB"); + } + + [Test] + public async Task UnknownEntity_IsEmittedLiterally() + { + var line = ParseSingleLine("&bogus;\n"); + await Assert.That(line.Text).IsEqualTo("&bogus;"); + } + + [Test] + public async Task BareAmpersand_IsEmittedLiterally() + { + var line = ParseSingleLine("Tom & Jerry\n"); + await Assert.That(line.Text).IsEqualTo("Tom & Jerry"); + } + + [Test] + public async Task Br_BreaksLineMidText() + { + var parser = new PuebloParser(); + var lines = parser.Feed("a
b\n"); + await Assert.That(lines).Count().IsEqualTo(2); + await Assert.That(lines[0].Text).IsEqualTo("a"); + await Assert.That(lines[1].Text).IsEqualTo("b"); + } + + [Test] + public async Task Paragraph_BreaksLine() + { + var parser = new PuebloParser(); + var lines = parser.Feed("a

b

\n"); + await Assert.That(lines).Count().IsEqualTo(2); + await Assert.That(lines[0].Text).IsEqualTo("a"); + await Assert.That(lines[1].Text).IsEqualTo("b"); + } + + [Test] + public async Task UnknownTag_IsConsumedNotLeaked() + { + var line = ParseSingleLine("xyz\n"); + await Assert.That(line.Text).IsEqualTo("xyz"); + } + + [Test] + public async Task Img_IsIgnored() + { + var line = ParseSingleLine("beforeafter\n"); + await Assert.That(line.Text).IsEqualTo("beforeafter"); + } + + [Test] + public async Task Pre_TagsAreStrippedContentKept() + { + var line = ParseSingleLine("
  spaced  
\n"); + await Assert.That(line.Text).IsEqualTo(" spaced "); + } + + [Test] + public async Task Comment_IsIgnored() + { + var line = ParseSingleLine("ab\n"); + await Assert.That(line.Text).IsEqualTo("ab"); + } + + [Test] + public async Task TagSplitAcrossFeeds_IsReassembled() + { + var parser = new PuebloParser(); + var first = parser.Feed("x\n"); + await Assert.That(second).Count().IsEqualTo(1); + await Assert.That(second[0].Spans[0].Text).IsEqualTo("x"); + await Assert.That(second[0].Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromRgb(0xff, 0x00, 0x00)); + } + + [Test] + public async Task EntitySplitAcrossFeeds_IsReassembled() + { + var parser = new PuebloParser(); + parser.Feed("&a"); + parser.Feed("mp;"); + var line = parser.Flush(); + await Assert.That(line!.Text).IsEqualTo("&"); + } + + [Test] + public async Task UnbalancedCloser_DoesNotThrow() + { + var line = ParseSingleLine("plaintext\n"); + await Assert.That(line.Text).IsEqualTo("plaintext"); + await Assert.That(line.Spans[0].Style).IsEqualTo(TextStyle.Default); + } + + [Test] + public async Task StrayLessThan_WithoutClose_IsEmittedLiterally() + { + // No '>' before the newline: the buffered "<3" is treated as literal text. + var line = ParseSingleLine("love <3\n"); + await Assert.That(line.Text).IsEqualTo("love <3"); + } + + [Test] + public async Task Esc_IsPassedThroughUntouched() + { + var line = ParseSingleLine("a\x1bb\n"); + await Assert.That(line.Text).IsEqualTo("a\x1bb"); + } + + [Test] + public async Task Reset_ClearsStyleAndStack() + { + var parser = new PuebloParser(); + parser.Feed("bold"); + await Assert.That(parser.CurrentStyle).IsNotEqualTo(TextStyle.Default); + + parser.Reset(); + await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); + await Assert.That(parser.HasPendingContent).IsFalse(); + + var line = ParseSingleLineWith(parser, "plain\n"); + await Assert.That(line.Spans[0].Style).IsEqualTo(TextStyle.Default); + } + + private static StyledLine ParseSingleLineWith(PuebloParser parser, string input) + { + var lines = parser.Feed(input); + return lines[^1]; + } +} diff --git a/tests/MuClient.Core.Tests/Session/WorldSessionContentTests.cs b/tests/MuClient.Core.Tests/Session/WorldSessionContentTests.cs new file mode 100644 index 0000000..e3c28cd --- /dev/null +++ b/tests/MuClient.Core.Tests/Session/WorldSessionContentTests.cs @@ -0,0 +1,99 @@ +using MuClient.Core.Configuration; +using MuClient.Core.Session; +using MuClient.Core.Text; + +namespace MuClient.Core.Tests.Session; + +public class WorldSessionContentTests +{ + private static (WorldSession session, FakeTelnetSession telnet) Create(WorldDefinition world) + { + var telnet = new FakeTelnetSession(); + return (new WorldSession(world, _ => telnet), telnet); + } + + private static StyledLine FindLine(WorldSession session, string text) => + session.Scrollback.Snapshot().First(l => l.Text == text); + + [Test] + public async Task MxpContentFormat_ParsesTagsIntoStyledSpans() + { + var world = new WorldDefinition { Name = "M", Host = "h", Port = 1, ContentFormat = ContentFormat.Mxp }; + var (session, telnet) = Create(world); + await session.ConnectAsync(); + + telnet.EmitLine("bold"); + + var line = FindLine(session, "bold"); + await Assert.That(line.Spans.Any(s => s.Style.HasAttribute(TextAttributes.Bold))).IsTrue(); + } + + [Test] + public async Task MxpContentFormat_SendLinkBecomesInteractiveSpan() + { + var world = new WorldDefinition { Name = "M", Host = "h", Port = 1, ContentFormat = ContentFormat.Mxp }; + var (session, telnet) = Create(world); + await session.ConnectAsync(); + + telnet.EmitLine("here"); + + var line = FindLine(session, "here"); + var span = line.Spans.First(s => s.Text.Contains("here")); + await Assert.That(span.IsInteractive).IsTrue(); + await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.SendCommand); + await Assert.That(span.Interaction!.Target).IsEqualTo("look"); + } + + [Test] + public async Task PuebloContentFormat_ParsesXchCmdLink() + { + var world = new WorldDefinition { Name = "P", Host = "h", Port = 1, ContentFormat = ContentFormat.Pueblo }; + var (session, telnet) = Create(world); + await session.ConnectAsync(); + + telnet.EmitLine("go north"); + + var line = FindLine(session, "go north"); + var span = line.Spans.First(s => s.IsInteractive); + await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.SendCommand); + await Assert.That(span.Interaction!.Target).IsEqualTo("north"); + } + + [Test] + public async Task AnsiContentFormat_IsDefault() + { + var world = new WorldDefinition { Name = "A", Host = "h", Port = 1 }; + var (session, telnet) = Create(world); + await session.ConnectAsync(); + + telnet.EmitLine("\x1b[31mred\x1b[0m"); + + var line = FindLine(session, "red"); + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(1)); + } + + [Test] + public async Task Emoji_SubstitutesInOutput_WhenEnabled() + { + var world = new WorldDefinition { Name = "E", Host = "h", Port = 1 }; + world.Emoji.Enabled = true; + var (session, telnet) = Create(world); + await session.ConnectAsync(); + + telnet.EmitLine("greetings :fire:"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text.Contains("🔥"))).IsTrue(); + } + + [Test] + public async Task Emoji_NotApplied_WhenDisabled() + { + var world = new WorldDefinition { Name = "E", Host = "h", Port = 1 }; + var (session, telnet) = Create(world); + await session.ConnectAsync(); + + telnet.EmitLine("greetings :fire:"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text.Contains(":fire:"))).IsTrue(); + } +} diff --git a/tests/MuClient.Core.Tests/Text/EmojiSubstitutorTests.cs b/tests/MuClient.Core.Tests/Text/EmojiSubstitutorTests.cs new file mode 100644 index 0000000..5b2821b --- /dev/null +++ b/tests/MuClient.Core.Tests/Text/EmojiSubstitutorTests.cs @@ -0,0 +1,73 @@ +using MuClient.Core.Text; + +namespace MuClient.Core.Tests.Text; + +public class EmojiSubstitutorTests +{ + [Test] + public async Task Emoticon_IsReplaced_WhenTokenBounded() + { + var sub = new EmojiSubstitutor(); + await Assert.That(sub.Apply("hello :) there")).IsEqualTo("hello 🙂 there"); + } + + [Test] + public async Task Emoticon_AtStartAndEnd() + { + var sub = new EmojiSubstitutor(); + await Assert.That(sub.Apply(":) hi")).IsEqualTo("🙂 hi"); + await Assert.That(sub.Apply("bye :(")).IsEqualTo("bye 🙁"); + } + + [Test] + public async Task Emoticon_InsideWord_IsNotReplaced() + { + var sub = new EmojiSubstitutor(); + // Should not fire inside a URL or word. + await Assert.That(sub.Apply("http://x:)y")).IsEqualTo("http://x:)y"); + } + + [Test] + public async Task Shortcode_IsReplaced() + { + var sub = new EmojiSubstitutor(); + await Assert.That(sub.Apply("that was :fire: hot")).IsEqualTo("that was 🔥 hot"); + } + + [Test] + public async Task Shortcode_IsCaseInsensitive() + { + var sub = new EmojiSubstitutor(); + await Assert.That(sub.Apply(":HEART:")).IsEqualTo("❤️"); + } + + [Test] + public async Task UnknownShortcode_IsLeftAlone() + { + var sub = new EmojiSubstitutor(); + await Assert.That(sub.Apply("ratio 3:2 done")).IsEqualTo("ratio 3:2 done"); + await Assert.That(sub.Apply(":notareal:")).IsEqualTo(":notareal:"); + } + + [Test] + public async Task ExtraShortcodes_AreMerged() + { + var sub = new EmojiSubstitutor(extraShortcodes: new Dictionary { ["mudlet"] = "🐲" }); + await Assert.That(sub.Apply(":mudlet:")).IsEqualTo("🐲"); + await Assert.That(sub.Apply(":fire:")).IsEqualTo("🔥"); // defaults still present + } + + [Test] + public async Task Disabled_LeavesTextUnchanged() + { + var sub = new EmojiSubstitutor(emoticons: false, shortcodes: false); + await Assert.That(sub.Apply(":) :fire:")).IsEqualTo(":) :fire:"); + } + + [Test] + public async Task Heart_Emoticon() + { + var sub = new EmojiSubstitutor(); + await Assert.That(sub.Apply("<3 you")).IsEqualTo("❤️ you"); + } +} diff --git a/tests/MuClient.Core.Tests/Text/WebColorsAndInteractionTests.cs b/tests/MuClient.Core.Tests/Text/WebColorsAndInteractionTests.cs new file mode 100644 index 0000000..436f053 --- /dev/null +++ b/tests/MuClient.Core.Tests/Text/WebColorsAndInteractionTests.cs @@ -0,0 +1,73 @@ +using MuClient.Core.Text; + +namespace MuClient.Core.Tests.Text; + +public class WebColorsTests +{ + [Test] + public async Task NamedColor_Resolves() + { + await Assert.That(WebColors.TryParse("red", out var c)).IsTrue(); + await Assert.That(c).IsEqualTo(TerminalColor.FromRgb(0xff, 0x00, 0x00)); + } + + [Test] + public async Task NamedColor_IsCaseInsensitive() + { + await Assert.That(WebColors.TryParse("Blue", out var c)).IsTrue(); + await Assert.That(c).IsEqualTo(TerminalColor.FromRgb(0x00, 0x00, 0xff)); + } + + [Test] + public async Task HexColor_SixDigits() + { + await Assert.That(WebColors.TryParse("#12ab34", out var c)).IsTrue(); + await Assert.That(c).IsEqualTo(TerminalColor.FromRgb(0x12, 0xab, 0x34)); + } + + [Test] + public async Task HexColor_ShortForm() + { + await Assert.That(WebColors.TryParse("#0f0", out var c)).IsTrue(); + await Assert.That(c).IsEqualTo(TerminalColor.FromRgb(0x00, 0xff, 0x00)); + } + + [Test] + public async Task Unknown_ReturnsFalse() + { + await Assert.That(WebColors.TryParse("notacolor", out _)).IsFalse(); + await Assert.That(WebColors.TryParse("", out _)).IsFalse(); + await Assert.That(WebColors.TryParse(null, out _)).IsFalse(); + } +} + +public class SpanInteractionTests +{ + [Test] + public async Task Command_Factory_SetsFields() + { + var i = SpanInteraction.Command("look", "examine", promptOnly: true); + await Assert.That(i.Kind).IsEqualTo(InteractionKind.SendCommand); + await Assert.That(i.Target).IsEqualTo("look"); + await Assert.That(i.Hint).IsEqualTo("examine"); + await Assert.That(i.PromptOnly).IsTrue(); + } + + [Test] + public async Task Link_Factory_SetsFields() + { + var i = SpanInteraction.Link("https://example.org", "site"); + await Assert.That(i.Kind).IsEqualTo(InteractionKind.Hyperlink); + await Assert.That(i.Target).IsEqualTo("https://example.org"); + } + + [Test] + public async Task StyledSpan_CarriesInteraction_AndAffectsEquality() + { + var plain = new StyledSpan("x", TextStyle.Default); + var linked = new StyledSpan("x", TextStyle.Default, SpanInteraction.Command("go")); + await Assert.That(plain.IsInteractive).IsFalse(); + await Assert.That(linked.IsInteractive).IsTrue(); + await Assert.That(plain).IsNotEqualTo(linked); + } +} diff --git a/tests/MuClient.Graphics.Tests/KittyImageIdValidationTests.cs b/tests/MuClient.Graphics.Tests/KittyImageIdValidationTests.cs new file mode 100644 index 0000000..885bfd7 --- /dev/null +++ b/tests/MuClient.Graphics.Tests/KittyImageIdValidationTests.cs @@ -0,0 +1,30 @@ +using MuClient.Graphics; + +namespace MuClient.Graphics.Tests; + +public class KittyImageIdValidationTests +{ + private readonly KittyGraphicsProtocol _kitty = new(); + + [Test] + public async Task BuildPlaceholder_RejectsImageIdAbove24Bits() + { + // 0x1000000 cannot round-trip through the 24-bit foreground-colour carrier. + await Assert.That(() => _kitty.BuildPlaceholder(0x1000000, 2, 2)) + .Throws(); + } + + [Test] + public async Task BuildPlaceholder_RejectsNegativeImageId() + { + await Assert.That(() => _kitty.BuildPlaceholder(-1, 2, 2)) + .Throws(); + } + + [Test] + public async Task BuildPlaceholder_AcceptsMaxValidImageId() + { + var lines = _kitty.BuildPlaceholder(0xFFFFFF, 2, 2); + await Assert.That(lines).Count().IsEqualTo(2); + } +} From 28ead031e8f1d8a6333eb827608f9a55fd1e77cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:23:24 +0000 Subject: [PATCH 02/68] Add in-TUI web view (MuClient.Web + WebView pane) A text-mode web view: read HTML pages (and follow MXP/Pueblo/HTML links) inside the client, reusing the styled-line + interaction model. MuClient.Web (new, UI-agnostic, tested) - HtmlStyledRenderer: AngleSharp-backed HTML -> StyledLine[]. Block elements become breaks; inline elements map to TextStyle; and become clickable SpanInteraction spans; headings, lists,
, colours, entities,
  and word-wrapping to a target width. script/style/head stripped.
- WebPageFetcher: http(s)-only fetch with a byte cap and timeout, rendering
  HTML (or plain text for non-HTML) into a WebPage.
- 14 TUnit tests.

TUI
- WebView pane renders a WebPage's lines with scroll and clickable in-pane
  navigation (Navigate/Closed events). MuGlyphApp opens it via a `/web `
  command or by following an output-pane link; Esc closes it.

CI runs the new Web test project. 313 tests pass across the solution.

Co-Authored-By: Claude Opus 4.8 
Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB
---
 .github/workflows/ci.yml                      |   4 +
 CLAUDE.md                                     |   4 +-
 Directory.Packages.props                      |   3 +
 MuGlyph.slnx                                  |   2 +
 README.md                                     |   7 +-
 src/MuClient.Tui/MuClient.Tui.csproj          |   1 +
 src/MuClient.Tui/MuGlyphApp.cs                |  76 +++++--
 src/MuClient.Tui/Views/WebView.cs             | 144 +++++++++++++
 src/MuClient.Web/HtmlStyledRenderer.cs        | 191 ++++++++++++++++++
 src/MuClient.Web/LineWriter.cs                | 165 +++++++++++++++
 src/MuClient.Web/MuClient.Web.csproj          |  16 ++
 src/MuClient.Web/WebPage.cs                   |  23 +++
 src/MuClient.Web/WebPageFetcher.cs            |  97 +++++++++
 .../HtmlStyledRendererTests.cs                | 129 ++++++++++++
 .../MuClient.Web.Tests.csproj                 |  19 ++
 15 files changed, 856 insertions(+), 25 deletions(-)
 create mode 100644 src/MuClient.Tui/Views/WebView.cs
 create mode 100644 src/MuClient.Web/HtmlStyledRenderer.cs
 create mode 100644 src/MuClient.Web/LineWriter.cs
 create mode 100644 src/MuClient.Web/MuClient.Web.csproj
 create mode 100644 src/MuClient.Web/WebPage.cs
 create mode 100644 src/MuClient.Web/WebPageFetcher.cs
 create mode 100644 tests/MuClient.Web.Tests/HtmlStyledRendererTests.cs
 create mode 100644 tests/MuClient.Web.Tests/MuClient.Web.Tests.csproj

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7cf19ab..522b7f0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,3 +46,7 @@ jobs:
       - name: Test — Scripting
         shell: bash
         run: dotnet run -c Release --no-build --project tests/MuClient.Scripting.Tests/MuClient.Scripting.Tests.csproj
+
+      - name: Test — Web
+        shell: bash
+        run: dotnet run -c Release --no-build --project tests/MuClient.Web.Tests/MuClient.Web.Tests.csproj
diff --git a/CLAUDE.md b/CLAUDE.md
index 8bfda9d..4778d1d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,8 +27,8 @@ fallbacks) for inline images/maps.
 
 ## Repository state
 
-**M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all seven projects on
-`net10.0`; the solution has **299 passing tests**. In place:
+**M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all eight projects on
+`net10.0`; the solution has **313 passing tests**. In place:
 
 - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model,
   `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**),
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7efe5d0..15125b3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,6 +15,9 @@
     
     
 
+    
+    
+
     
     
 
diff --git a/MuGlyph.slnx b/MuGlyph.slnx
index 5df29b3..a8fe2b3 100644
--- a/MuGlyph.slnx
+++ b/MuGlyph.slnx
@@ -4,10 +4,12 @@
     
     
     
+    
   
   
     
     
     
+    
   
 
diff --git a/README.md b/README.md
index a8acd29..9c84c37 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ client: rich truecolor text, inline graphics, powerful automation, and full MU\*
 
 > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work
 > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming.
-> `MuClient.Core` is fully unit-tested (299 tests across the solution). See
+> `MuClient.Core` is fully unit-tested (313 tests across the solution). See
 > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap.
 
 ## Why a TUI
@@ -69,6 +69,10 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji.
 - **TUI** — a Terminal.Gui v2 app: truecolor output pane with wrapping/scrollback, clickable
   MXP/Pueblo links, command input with history and tab-completion, a GMCP-driven stat line, and
   key routing.
+- **Web view** — an in-TUI text-mode browser (`MuClient.Web`, AngleSharp): fetch a URL or
+  follow an MXP/Pueblo/HTML link and read the page as styled, word-wrapped text with clickable
+  links you can navigate in-pane. `` shows as a labelled link (graphics-terminal image
+  rendering reuses the Kitty/Sixel/half-block pipeline).
 - **Packaging** — self-contained single-file publishing for Linux/Windows/macOS (see
   [`docs/PACKAGING.md`](docs/PACKAGING.md)); a tagged release workflow builds the binaries.
 
@@ -98,6 +102,7 @@ The test projects use [TUnit], which runs on the Microsoft.Testing.Platform. Run
 dotnet run --project tests/MuClient.Core.Tests
 dotnet run --project tests/MuClient.Graphics.Tests
 dotnet run --project tests/MuClient.Scripting.Tests
+dotnet run --project tests/MuClient.Web.Tests
 ```
 
 ## License
diff --git a/src/MuClient.Tui/MuClient.Tui.csproj b/src/MuClient.Tui/MuClient.Tui.csproj
index a160bb3..d8ef0f0 100644
--- a/src/MuClient.Tui/MuClient.Tui.csproj
+++ b/src/MuClient.Tui/MuClient.Tui.csproj
@@ -29,6 +29,7 @@
     
     
     
+    
   
 
 
diff --git a/src/MuClient.Tui/MuGlyphApp.cs b/src/MuClient.Tui/MuGlyphApp.cs
index 8e96332..49ad7ef 100644
--- a/src/MuClient.Tui/MuGlyphApp.cs
+++ b/src/MuClient.Tui/MuGlyphApp.cs
@@ -27,8 +27,10 @@ internal sealed class MuGlyphApp : IAsyncDisposable
     private readonly Label _status;
     private readonly OutputView _output;
     private readonly CommandInput _input;
+    private readonly WebView _webView;
     private readonly GmcpStats _stats = new();
     private readonly HashSet _spawnTargets = new(StringComparer.OrdinalIgnoreCase);
+    private readonly MuClient.Web.WebPageFetcher _fetcher = new();
 
     private WorldSession? _active;
 
@@ -65,11 +67,23 @@ public MuGlyphApp(AppConfiguration config, TerminalCapabilities capabilities)
             Width = Dim.Fill(),
             Height = 1,
         };
+        _webView = new WebView
+        {
+            X = 0,
+            Y = 1,
+            Width = Dim.Fill(),
+            Height = Dim.Fill(1),
+            Mapper = new ColorMapper(_theme),
+            Visible = false,
+        };
+        _webView.Navigate += OpenWeb;
+        _webView.Closed += HideWeb;
+
         _input.CommandEntered += OnCommandEntered;
         _output.CommandActivated += OnCommandActivated;
-        _output.LinkActivated += OpenLink;
+        _output.LinkActivated += OpenWeb; // follow links in the in-TUI web view
 
-        _window.Add(_status, _output, _input);
+        _window.Add(_status, _output, _webView, _input);
         _window.KeyDown += OnGlobalKey;
     }
 
@@ -142,6 +156,13 @@ private void OnSpawnLine(string target)
 
     private void OnCommandEntered(string command)
     {
+        // `/web ` opens the in-TUI web view; everything else goes to the world.
+        if (command.StartsWith("/web ", StringComparison.OrdinalIgnoreCase))
+        {
+            OpenWeb(command[5..].Trim());
+            return;
+        }
+
         var session = _active;
         if (session is null)
         {
@@ -151,40 +172,50 @@ private void OnCommandEntered(string command)
         _ = session.SendUserInputAsync(command);
     }
 
-    private void OnCommandActivated(string command, bool promptOnly)
+    private void OpenWeb(string url)
     {
-        if (promptOnly)
+        if (string.IsNullOrWhiteSpace(url))
         {
-            _input.Text = command;
-            _input.SetFocus();
             return;
         }
 
-        _ = _active?.SendRawAsync(command);
+        _webView.Visible = true;
+        _webView.SetFocus();
+        _active?.PrintSystem($"*** Opening {url} in the web view (Esc to close)...");
+        _ = LoadWebAsync(url);
     }
 
-    private static void OpenLink(string url)
+    private async Task LoadWebAsync(string url)
     {
-        // Only open well-formed http(s) links, via the OS default handler.
-        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
-            uri.Scheme is not ("http" or "https"))
+        var width = Math.Max(20, _webView.Viewport.Width);
+        var page = await _fetcher.FetchAsync(url, width).ConfigureAwait(false);
+        Application.Invoke(() =>
         {
-            return;
-        }
+            _webView.Show(page);
+            _webView.SetNeedsDraw();
+        });
+    }
 
-        try
-        {
-            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(uri.ToString())
-            {
-                UseShellExecute = true,
-            });
-        }
-        catch
+    private void HideWeb()
+    {
+        _webView.Visible = false;
+        _input.SetFocus();
+        _output.SetNeedsDraw();
+    }
+
+    private void OnCommandActivated(string command, bool promptOnly)
+    {
+        if (promptOnly)
         {
-            // No browser available (e.g. headless) — ignore.
+            _input.Text = command;
+            _input.SetFocus();
+            return;
         }
+
+        _ = _active?.SendRawAsync(command);
     }
 
+
     private void OnGlobalKey(object? sender, Key key)
     {
         switch (key.KeyCode)
@@ -246,6 +277,7 @@ private static Theme ResolveTheme(AppConfiguration config)
 
     public async ValueTask DisposeAsync()
     {
+        _fetcher.Dispose();
         await _sessions.DisposeAsync().ConfigureAwait(false);
     }
 }
diff --git a/src/MuClient.Tui/Views/WebView.cs b/src/MuClient.Tui/Views/WebView.cs
new file mode 100644
index 0000000..9bd9511
--- /dev/null
+++ b/src/MuClient.Tui/Views/WebView.cs
@@ -0,0 +1,144 @@
+using System.Text;
+using MuClient.Core.Text;
+using MuClient.Core.Theming;
+using MuClient.Web;
+using Terminal.Gui.Drivers;
+using Terminal.Gui.Input;
+using Terminal.Gui.ViewBase;
+
+namespace MuClient.Tui.Views;
+
+/// 
+/// An in-TUI text-mode web view. Displays a 's pre-wrapped styled lines,
+/// scrolls, and lets links be followed in-pane (raising ) or the pane be
+/// closed (). Images render as labelled link spans; a graphics-capable
+/// terminal can later realise them through the graphics layer. Final placement/chrome will follow
+/// the layout design.
+/// 
+internal sealed class WebView : View
+{
+    private readonly Dictionary<(int Row, int Col), SpanInteraction> _cellInteractions = new();
+    private IReadOnlyList _lines = Array.Empty();
+    private int _scroll;
+
+    public WebView()
+    {
+        CanFocus = true;
+        MouseEvent += OnMouseEvent;
+        KeyDown += OnKeyDown;
+    }
+
+    public ColorMapper Mapper { get; set; } = new(ThemeLibrary.Dark());
+
+    public string Title { get; private set; } = string.Empty;
+
+    /// Raised when a hyperlink is followed (the target URL).
+    public event Action? Navigate;
+
+    /// Raised when the view is closed (Esc/q).
+    public event Action? Closed;
+
+    public void Show(WebPage page)
+    {
+        Title = string.IsNullOrEmpty(page.Title) ? page.Url : $"{page.Title} — {page.Url}";
+        _lines = page.Lines;
+        _scroll = 0;
+        SetNeedsDraw();
+    }
+
+    private void OnKeyDown(object? sender, Key key)
+    {
+        switch (key.KeyCode)
+        {
+            case KeyCode.Esc:
+                Closed?.Invoke();
+                key.Handled = true;
+                break;
+            case KeyCode.CursorUp:
+                Scroll(-1);
+                key.Handled = true;
+                break;
+            case KeyCode.CursorDown:
+                Scroll(1);
+                key.Handled = true;
+                break;
+            case KeyCode.PageUp:
+                Scroll(-Math.Max(1, Viewport.Height - 1));
+                key.Handled = true;
+                break;
+            case KeyCode.PageDown:
+                Scroll(Math.Max(1, Viewport.Height - 1));
+                key.Handled = true;
+                break;
+        }
+    }
+
+    private void Scroll(int delta)
+    {
+        var max = Math.Max(0, _lines.Count - 1);
+        _scroll = Math.Clamp(_scroll + delta, 0, max);
+        SetNeedsDraw();
+    }
+
+    private void OnMouseEvent(object? sender, Mouse mouse)
+    {
+        if (!mouse.IsSingleClicked || mouse.Position is not { } position)
+        {
+            return;
+        }
+
+        if (_cellInteractions.TryGetValue((position.Y, position.X), out var interaction) &&
+            interaction.Kind == InteractionKind.Hyperlink)
+        {
+            Navigate?.Invoke(interaction.Target);
+            mouse.Handled = true;
+        }
+    }
+
+    protected override bool OnDrawingContent(DrawContext? context)
+    {
+        _cellInteractions.Clear();
+        var viewport = Viewport;
+        var width = Math.Max(1, viewport.Width);
+        var height = Math.Max(1, viewport.Height);
+
+        for (var screenRow = 0; screenRow < height; screenRow++)
+        {
+            var index = _scroll + screenRow;
+            if (index >= _lines.Count)
+            {
+                break;
+            }
+
+            DrawLine(_lines[index], screenRow, width);
+        }
+
+        return true;
+    }
+
+    private void DrawLine(StyledLine line, int screenRow, int width)
+    {
+        Move(0, screenRow);
+        var col = 0;
+        foreach (var span in line.Spans)
+        {
+            var attr = Mapper.ToAttribute(span.Style);
+            foreach (var rune in span.Text.EnumerateRunes())
+            {
+                if (col >= width)
+                {
+                    return;
+                }
+
+                SetAttribute(attr);
+                AddRune(col, screenRow, rune);
+                if (span.Interaction is not null)
+                {
+                    _cellInteractions[(screenRow, col)] = span.Interaction;
+                }
+
+                col++;
+            }
+        }
+    }
+}
diff --git a/src/MuClient.Web/HtmlStyledRenderer.cs b/src/MuClient.Web/HtmlStyledRenderer.cs
new file mode 100644
index 0000000..651254b
--- /dev/null
+++ b/src/MuClient.Web/HtmlStyledRenderer.cs
@@ -0,0 +1,191 @@
+using AngleSharp.Dom;
+using AngleSharp.Html.Parser;
+using MuClient.Core.Text;
+
+namespace MuClient.Web;
+
+/// 
+/// Renders an HTML document into MuGlyph's  model (a text-mode browser,
+/// w3m/lynx-style): block elements become line breaks, inline elements map to ,
+/// and <a href>/<img> become clickable  spans.
+/// Pure and UI-agnostic — the TUI places the result in a pane, and images can be realised later via
+/// the graphics layer. Text is word-wrapped to a target width.
+/// 
+public sealed class HtmlStyledRenderer
+{
+    private static readonly HashSet BlockTags = new(StringComparer.OrdinalIgnoreCase)
+    {
+        "p", "div", "section", "article", "header", "footer", "main", "aside", "nav",
+        "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "pre",
+        "table", "tr", "figure", "figcaption", "form", "fieldset", "dl", "dt", "dd",
+    };
+
+    private static readonly HashSet SkipTags = new(StringComparer.OrdinalIgnoreCase)
+    {
+        "script", "style", "head", "noscript", "template", "svg", "iframe",
+    };
+
+    private static readonly TextStyle LinkStyle =
+        TextStyle.Default.WithForeground(TerminalColor.FromIndex(12)).AddAttribute(TextAttributes.Underline);
+
+    private static readonly TextStyle HeadingStyle =
+        TextStyle.Default.WithForeground(TerminalColor.FromIndex(11)).AddAttribute(TextAttributes.Bold);
+
+    private readonly string? _baseUrl;
+
+    public HtmlStyledRenderer(string? baseUrl = null) => _baseUrl = baseUrl;
+
+    public IReadOnlyList Render(string html, int width = 80)
+    {
+        ArgumentNullException.ThrowIfNull(html);
+        var document = new HtmlParser().ParseDocument(html);
+        var writer = new LineWriter(Math.Max(20, width));
+        INode? root = document.Body ?? document.DocumentElement;
+        if (root is not null)
+        {
+            foreach (var child in root.ChildNodes)
+            {
+                Walk(child, writer, TextStyle.Default, null, preformatted: false);
+            }
+        }
+
+        return writer.Finish();
+    }
+
+    /// Extracts the document title, if any.
+    public static string? GetTitle(string html)
+    {
+        var document = new HtmlParser().ParseDocument(html ?? string.Empty);
+        var title = document.Title;
+        return string.IsNullOrWhiteSpace(title) ? null : title.Trim();
+    }
+
+    private void Walk(INode node, LineWriter writer, TextStyle style, SpanInteraction? link, bool preformatted)
+    {
+        switch (node)
+        {
+            case IText text:
+                writer.AddText(text.Data, style, link, preformatted);
+                break;
+            case IElement element:
+                WalkElement(element, writer, style, link, preformatted);
+                break;
+        }
+    }
+
+    private void WalkElement(IElement element, LineWriter writer, TextStyle style, SpanInteraction? link, bool preformatted)
+    {
+        var tag = element.LocalName;
+        if (SkipTags.Contains(tag))
+        {
+            return;
+        }
+
+        switch (tag)
+        {
+            case "br":
+                writer.LineBreak();
+                return;
+            case "hr":
+                writer.BlankLine();
+                writer.AddText(new string('─', 40), style, null, preformatted: true);
+                writer.LineBreak();
+                writer.BlankLine();
+                return;
+            case "img":
+                var alt = element.GetAttribute("alt");
+                var label = string.IsNullOrWhiteSpace(alt) ? "[image]" : $"[image: {alt}]";
+                var src = element.GetAttribute("src");
+                var imgLink = string.IsNullOrWhiteSpace(src) ? null : SpanInteraction.Link(Resolve(src));
+                writer.AddText(label, style.AddAttribute(TextAttributes.Italic), imgLink, preformatted);
+                return;
+        }
+
+        var isBlock = BlockTags.Contains(tag);
+        if (isBlock)
+        {
+            writer.EndLine();
+        }
+
+        var childStyle = style;
+        var childLink = link;
+        var childPre = preformatted;
+
+        switch (tag)
+        {
+            case "b" or "strong":
+                childStyle = style.AddAttribute(TextAttributes.Bold);
+                break;
+            case "i" or "em" or "cite":
+                childStyle = style.AddAttribute(TextAttributes.Italic);
+                break;
+            case "u" or "ins":
+                childStyle = style.AddAttribute(TextAttributes.Underline);
+                break;
+            case "s" or "strike" or "del":
+                childStyle = style.AddAttribute(TextAttributes.Strikethrough);
+                break;
+            case "h1" or "h2" or "h3" or "h4" or "h5" or "h6":
+                writer.BlankLine();
+                childStyle = HeadingStyle;
+                break;
+            case "pre":
+                childPre = true;
+                break;
+            case "li":
+                writer.AddText("• ", style, null, preformatted: false);
+                break;
+            case "a":
+                var href = element.GetAttribute("href");
+                if (!string.IsNullOrWhiteSpace(href))
+                {
+                    childLink = SpanInteraction.Link(Resolve(href), href);
+                    childStyle = MergeLinkStyle(style);
+                }
+
+                break;
+        }
+
+        if (TryGetColor(element, out var color))
+        {
+            childStyle = childStyle.WithForeground(color);
+        }
+
+        foreach (var child in element.ChildNodes)
+        {
+            Walk(child, writer, childStyle, childLink, childPre);
+        }
+
+        if (isBlock)
+        {
+            writer.EndLine();
+        }
+
+        if (tag is "p" or "h1" or "h2" or "h3" or "h4" or "h5" or "h6" or "blockquote" or "ul" or "ol" or "pre")
+        {
+            writer.BlankLine();
+        }
+    }
+
+    private static TextStyle MergeLinkStyle(TextStyle style) =>
+        style.WithForeground(LinkStyle.Foreground).AddAttribute(TextAttributes.Underline);
+
+    private static bool TryGetColor(IElement element, out TerminalColor color)
+    {
+        color = TerminalColor.Default;
+        var value = element.GetAttribute("color");
+        return value is not null && WebColors.TryParse(value, out color);
+    }
+
+    private string Resolve(string url)
+    {
+        if (string.IsNullOrEmpty(_baseUrl) ||
+            Uri.TryCreate(url, UriKind.Absolute, out _) ||
+            !Uri.TryCreate(_baseUrl, UriKind.Absolute, out var baseUri))
+        {
+            return url;
+        }
+
+        return Uri.TryCreate(baseUri, url, out var resolved) ? resolved.ToString() : url;
+    }
+}
diff --git a/src/MuClient.Web/LineWriter.cs b/src/MuClient.Web/LineWriter.cs
new file mode 100644
index 0000000..b2e571f
--- /dev/null
+++ b/src/MuClient.Web/LineWriter.cs
@@ -0,0 +1,165 @@
+using MuClient.Core.Text;
+
+namespace MuClient.Web;
+
+/// 
+/// Accumulates styled text into word-wrapped s. Handles inter-element
+/// whitespace collapsing, preformatted runs, hard breaks, and blank-line collapsing, coalescing
+/// adjacent same-style segments into spans.
+/// 
+internal sealed class LineWriter(int width)
+{
+    private readonly int _width = width;
+    private readonly List _lines = new();
+    private readonly List _current = new();
+    private int _col;
+    private bool _lineHasContent;
+    private bool _pendingSpace;
+
+    public void AddText(string text, TextStyle style, SpanInteraction? link, bool preformatted)
+    {
+        if (string.IsNullOrEmpty(text))
+        {
+            return;
+        }
+
+        if (preformatted)
+        {
+            AddPreformatted(text, style, link);
+            return;
+        }
+
+        var leadingWs = char.IsWhiteSpace(text[0]);
+        var trailingWs = char.IsWhiteSpace(text[^1]);
+        var words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+
+        if (words.Length == 0)
+        {
+            // All whitespace: remember an inter-word space if we already have content.
+            if (_lineHasContent)
+            {
+                _pendingSpace = true;
+            }
+
+            return;
+        }
+
+        for (var i = 0; i < words.Length; i++)
+        {
+            var word = words[i];
+            var spaceBefore = i == 0 ? (_pendingSpace || leadingWs) && _lineHasContent : _lineHasContent;
+            var needed = (spaceBefore ? 1 : 0) + word.Length;
+
+            if (_lineHasContent && _col + needed > _width)
+            {
+                LineBreak();
+                spaceBefore = false;
+            }
+
+            var piece = spaceBefore ? " " + word : word;
+            _current.Add(new Segment(piece, style, link));
+            _col += piece.Length;
+            _lineHasContent = true;
+            _pendingSpace = false;
+        }
+
+        if (trailingWs)
+        {
+            _pendingSpace = true;
+        }
+    }
+
+    private void AddPreformatted(string text, TextStyle style, SpanInteraction? link)
+    {
+        var parts = text.Split('\n');
+        for (var i = 0; i < parts.Length; i++)
+        {
+            if (i > 0)
+            {
+                LineBreak();
+            }
+
+            var part = parts[i].TrimEnd('\r');
+            if (part.Length > 0)
+            {
+                _current.Add(new Segment(part, style, link));
+                _col += part.Length;
+                _lineHasContent = true;
+            }
+        }
+    }
+
+    /// Ends the current line unconditionally (an explicit break, e.g. <br>).
+    public void LineBreak()
+    {
+        _lines.Add(Coalesce());
+        _current.Clear();
+        _col = 0;
+        _lineHasContent = false;
+        _pendingSpace = false;
+    }
+
+    /// Ends the current line only if it has content (used around block elements).
+    public void EndLine()
+    {
+        if (_lineHasContent || _current.Count > 0)
+        {
+            LineBreak();
+        }
+    }
+
+    /// Ensures a single blank separator line (collapses consecutive blanks; skips a leading blank).
+    public void BlankLine()
+    {
+        EndLine();
+        if (_lines.Count > 0 && !_lines[^1].IsEmpty)
+        {
+            _lines.Add(StyledLine.Empty);
+        }
+    }
+
+    public IReadOnlyList Finish()
+    {
+        EndLine();
+        while (_lines.Count > 0 && _lines[^1].IsEmpty)
+        {
+            _lines.RemoveAt(_lines.Count - 1);
+        }
+
+        return _lines;
+    }
+
+    private StyledLine Coalesce()
+    {
+        if (_current.Count == 0)
+        {
+            return StyledLine.Empty;
+        }
+
+        var spans = new List();
+        var runText = _current[0].Text;
+        var runStyle = _current[0].Style;
+        var runLink = _current[0].Link;
+
+        for (var i = 1; i < _current.Count; i++)
+        {
+            var seg = _current[i];
+            if (seg.Style.Equals(runStyle) && Equals(seg.Link, runLink))
+            {
+                runText += seg.Text;
+            }
+            else
+            {
+                spans.Add(new StyledSpan(runText, runStyle, runLink));
+                runText = seg.Text;
+                runStyle = seg.Style;
+                runLink = seg.Link;
+            }
+        }
+
+        spans.Add(new StyledSpan(runText, runStyle, runLink));
+        return new StyledLine(spans);
+    }
+
+    private readonly record struct Segment(string Text, TextStyle Style, SpanInteraction? Link);
+}
diff --git a/src/MuClient.Web/MuClient.Web.csproj b/src/MuClient.Web/MuClient.Web.csproj
new file mode 100644
index 0000000..78add9b
--- /dev/null
+++ b/src/MuClient.Web/MuClient.Web.csproj
@@ -0,0 +1,16 @@
+
+
+  
+    MuClient.Web
+    MuClient.Web
+  
+
+  
+    
+  
+
+  
+    
+  
+
+
diff --git a/src/MuClient.Web/WebPage.cs b/src/MuClient.Web/WebPage.cs
new file mode 100644
index 0000000..ea14740
--- /dev/null
+++ b/src/MuClient.Web/WebPage.cs
@@ -0,0 +1,23 @@
+using MuClient.Core.Text;
+
+namespace MuClient.Web;
+
+/// A fetched-and-rendered web page: its title, source URL, and styled lines.
+public sealed class WebPage
+{
+    public WebPage(string url, string? title, IReadOnlyList lines)
+    {
+        Url = url;
+        Title = title;
+        Lines = lines;
+    }
+
+    public string Url { get; }
+
+    public string? Title { get; }
+
+    public IReadOnlyList Lines { get; }
+
+    public static WebPage Error(string url, string message) =>
+        new(url, "Error", new[] { StyledLine.FromText(message, TextStyle.Default.WithForeground(TerminalColor.FromIndex(9))) });
+}
diff --git a/src/MuClient.Web/WebPageFetcher.cs b/src/MuClient.Web/WebPageFetcher.cs
new file mode 100644
index 0000000..0013801
--- /dev/null
+++ b/src/MuClient.Web/WebPageFetcher.cs
@@ -0,0 +1,97 @@
+using System.Net;
+
+namespace MuClient.Web;
+
+/// 
+/// Fetches an http(s) URL and renders it to a  via
+/// . Non-HTML responses render as plain text. Deliberately
+/// conservative: only http/https, a byte cap, and a timeout, so a hostile page can't hang or
+/// exhaust the client.
+/// 
+public sealed class WebPageFetcher : IDisposable
+{
+    private readonly HttpClient _http;
+    private readonly long _maxBytes;
+
+    public WebPageFetcher(HttpClient? httpClient = null, long maxBytes = 4 * 1024 * 1024)
+    {
+        _http = httpClient ?? new HttpClient(new HttpClientHandler
+        {
+            AutomaticDecompression = DecompressionMethods.All,
+            AllowAutoRedirect = true,
+        })
+        {
+            Timeout = TimeSpan.FromSeconds(20),
+        };
+
+        if (!_http.DefaultRequestHeaders.UserAgent.TryParseAdd("MuGlyph/0.1 (+https://github.com/HarryCordewener/MuGlyph)"))
+        {
+            // A restrictive HttpClient may reject the header; not fatal.
+        }
+
+        _maxBytes = maxBytes;
+    }
+
+    public async Task FetchAsync(string url, int width = 80, CancellationToken cancellationToken = default)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(url);
+        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
+        {
+            return WebPage.Error(url, "Only http(s) URLs can be opened in the web view.");
+        }
+
+        try
+        {
+            using var response = await _http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+                .ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
+
+            var body = await ReadCappedAsync(response, cancellationToken).ConfigureAwait(false);
+            var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
+
+            if (contentType.Contains("html", StringComparison.OrdinalIgnoreCase) || contentType.Length == 0)
+            {
+                var renderer = new HtmlStyledRenderer(uri.ToString());
+                var lines = renderer.Render(body, width);
+                return new WebPage(uri.ToString(), HtmlStyledRenderer.GetTitle(body), lines);
+            }
+
+            // Non-HTML: render as plain text lines.
+            var parser = new Core.Text.AnsiParser();
+            var textLines = parser.Feed(body.Replace("\r\n", "\n")).ToList();
+            var tail = parser.Flush();
+            if (tail is not null)
+            {
+                textLines.Add(tail);
+            }
+
+            return new WebPage(uri.ToString(), uri.Host, textLines);
+        }
+        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or InvalidOperationException)
+        {
+            return WebPage.Error(url, $"Could not load {url}: {ex.Message}");
+        }
+    }
+
+    private async Task ReadCappedAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+    {
+        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+        using var limited = new MemoryStream();
+        var buffer = new byte[81920];
+        int read;
+        while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
+        {
+            if (limited.Length + read > _maxBytes)
+            {
+                limited.Write(buffer, 0, (int)(_maxBytes - limited.Length));
+                break;
+            }
+
+            limited.Write(buffer, 0, read);
+        }
+
+        return System.Text.Encoding.UTF8.GetString(limited.ToArray());
+    }
+
+    public void Dispose() => _http.Dispose();
+}
diff --git a/tests/MuClient.Web.Tests/HtmlStyledRendererTests.cs b/tests/MuClient.Web.Tests/HtmlStyledRendererTests.cs
new file mode 100644
index 0000000..5f1cc33
--- /dev/null
+++ b/tests/MuClient.Web.Tests/HtmlStyledRendererTests.cs
@@ -0,0 +1,129 @@
+using MuClient.Core.Text;
+using MuClient.Web;
+
+namespace MuClient.Web.Tests;
+
+public class HtmlStyledRendererTests
+{
+    private static IReadOnlyList Render(string html, int width = 80) =>
+        new HtmlStyledRenderer().Render(html, width);
+
+    private static string AllText(IReadOnlyList lines) =>
+        string.Join("\n", lines.Select(l => l.Text));
+
+    [Test]
+    public async Task PlainParagraph_RendersText()
+    {
+        var lines = Render("

Hello world

"); + await Assert.That(AllText(lines)).Contains("Hello world"); + } + + [Test] + public async Task Bold_SetsBoldAttribute() + { + var lines = Render("

a strong word

"); + var strong = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("strong")); + await Assert.That(strong.Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task Anchor_BecomesHyperlinkInteraction() + { + var lines = Render("
site"); + var span = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("site")); + await Assert.That(span.IsInteractive).IsTrue(); + await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.Hyperlink); + await Assert.That(span.Interaction!.Target).IsEqualTo("https://example.org"); + } + + [Test] + public async Task RelativeAnchor_IsResolvedAgainstBaseUrl() + { + var renderer = new HtmlStyledRenderer("https://example.org/dir/page.html"); + var lines = renderer.Render("next"); + var span = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("next")); + await Assert.That(span.Interaction!.Target).IsEqualTo("https://example.org/dir/other.html"); + } + + [Test] + public async Task ScriptAndStyle_AreStripped() + { + var lines = Render("

visible

"); + var text = AllText(lines); + await Assert.That(text).Contains("visible"); + await Assert.That(text).DoesNotContain("var x"); + await Assert.That(text).DoesNotContain(".a{}"); + } + + [Test] + public async Task Heading_IsBoldAndSeparated() + { + var lines = Render("

Title

body

"); + var title = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("Title")); + await Assert.That(title.Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task ListItems_GetBullets() + { + var lines = Render("
  • one
  • two
"); + var text = AllText(lines); + await Assert.That(text).Contains("• one"); + await Assert.That(text).Contains("• two"); + } + + [Test] + public async Task Break_ProducesNewLine() + { + var lines = Render("first
second"); + await Assert.That(lines.Count).IsGreaterThanOrEqualTo(2); + await Assert.That(lines.Any(l => l.Text == "first")).IsTrue(); + await Assert.That(lines.Any(l => l.Text == "second")).IsTrue(); + } + + [Test] + public async Task WhitespaceIsCollapsed_AcrossInlineElements() + { + var lines = Render("

a b c

"); + await Assert.That(AllText(lines)).Contains("a b c"); + } + + [Test] + public async Task Wrapping_RespectsWidth() + { + var lines = Render("

" + string.Join(' ', Enumerable.Repeat("word", 40)) + "

", width: 20); + await Assert.That(lines.All(l => l.Text.Length <= 20)).IsTrue(); + await Assert.That(lines.Count).IsGreaterThan(1); + } + + [Test] + public async Task Image_BecomesLabelledLink() + { + var lines = Render("\"a"); + var span = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("image")); + await Assert.That(span.Text).Contains("a cat"); + } + + [Test] + public async Task Preformatted_PreservesLineBreaks() + { + var lines = Render("
line1\nline2
"); + await Assert.That(lines.Any(l => l.Text == "line1")).IsTrue(); + await Assert.That(lines.Any(l => l.Text == "line2")).IsTrue(); + } + + [Test] + public async Task GetTitle_ReturnsDocumentTitle() + { + await Assert.That(HtmlStyledRenderer.GetTitle("My Page")) + .IsEqualTo("My Page"); + } + + [Test] + public async Task FontColor_IsApplied() + { + var lines = Render("danger"); + var span = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("danger")); + await Assert.That(span.Style.Foreground).IsEqualTo(TerminalColor.FromRgb(0xff, 0, 0)); + } +} diff --git a/tests/MuClient.Web.Tests/MuClient.Web.Tests.csproj b/tests/MuClient.Web.Tests/MuClient.Web.Tests.csproj new file mode 100644 index 0000000..2983633 --- /dev/null +++ b/tests/MuClient.Web.Tests/MuClient.Web.Tests.csproj @@ -0,0 +1,19 @@ + + + + Exe + MuClient.Web.Tests + MuClient.Web.Tests + false + true + + + + + + + + + + + From 5dd929b5fbfa7bab5fb7f50446c95ad7f5221569 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:44:52 +0000 Subject: [PATCH 03/68] Address PR #2 review: parser/leak/web-view fixes Resolve the actionable CodeRabbit findings on PR #2. Correctness - TelnetSession: dispose the interpreter inside DisconnectAsync before clearing it, so it no longer leaks once per disconnect/reconnect. - MxpParser/PuebloParser: close interaction frames at line/prompt boundaries so an unclosed or nested / can't make later lines clickable; Pueblo now mirrors MXP (CompleteLine + Flush). Regression tests added. - WorldSession/EmojiSubstitutor: substitute emoji across the whole line (EmojiSubstitutor.ApplyToLine) so word boundaries are judged correctly across span seams, preserving per-span style/interaction. Tests added. - ScriptException: anchor line extraction to the ":(" / "]:" header markers so a bracket/paren in the message text can't mislead it. - GmcpStats: render JSON booleans as lowercase true/false. - WebPageFetcher: resolve page identity and relative links against the final (post-redirect) URI. - LineWriter: StringBuilder coalescing to avoid O(n^2) on large same-style runs. - HtmlStyledRenderer: size
to the requested width. - MuGlyphApp: observe and surface exceptions from the fire-and-forget web fetch. - WebView: clamp scroll to the last full screen. Delivery / docs - release.yml: serialize release creation in a downstream job so matrix jobs don't race softprops/action-gh-release on the same tag. - Doc fixes: nine projects (CLAUDE.md), web-view marked delivered (PLAN/README), osx-arm64 single-file native-libs note (PACKAGING.md), drop redundant namespace qualification on the parsers. 323 tests pass (Core 209, Graphics 57, Scripting 42, Web 15). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB --- .github/workflows/release.yml | 18 +- CLAUDE.md | 2 +- docs/PACKAGING.md | 3 +- docs/PLAN.md | 13 +- src/MuClient.Core/Protocols/MxpParser.cs | 25 ++- src/MuClient.Core/Protocols/PuebloParser.cs | 27 ++- src/MuClient.Core/Session/WorldSession.cs | 28 +-- src/MuClient.Core/Telnet/TelnetSession.cs | 19 ++- src/MuClient.Core/Text/EmojiSubstitutor.cs | 159 ++++++++++++++++++ src/MuClient.Scripting/ScriptException.cs | 27 +-- src/MuClient.Tui/GmcpStats.cs | 9 +- src/MuClient.Tui/MuGlyphApp.cs | 18 +- src/MuClient.Tui/Views/WebView.cs | 3 +- src/MuClient.Web/HtmlStyledRenderer.cs | 6 +- src/MuClient.Web/LineWriter.cs | 13 +- src/MuClient.Web/WebPageFetcher.cs | 8 +- .../Protocols/ParserBoundaryTests.cs | 49 ++++++ .../Text/EmojiSubstitutorTests.cs | 41 +++++ .../Text/WebColorsAndInteractionTests.cs | 17 ++ .../HtmlStyledRendererTests.cs | 13 ++ 20 files changed, 425 insertions(+), 73 deletions(-) create mode 100644 tests/MuClient.Core.Tests/Protocols/ParserBoundaryTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7525509..ce77482 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,8 +57,20 @@ jobs: name: muglyph-${{ matrix.rid }} path: muglyph-${{ matrix.rid }}.* - - name: Attach to release - if: startsWith(github.ref, 'refs/tags/') + # Serialize release creation in a single downstream job so the two matrix jobs don't race + # softprops/action-gh-release on the same tag. + release: + needs: publish + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create release and upload assets uses: softprops/action-gh-release@v2 with: - files: muglyph-${{ matrix.rid }}.* + files: artifacts/* diff --git a/CLAUDE.md b/CLAUDE.md index 4778d1d..afe3729 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ fallbacks) for inline images/maps. ## Repository state -**M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all eight projects on +**M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all nine projects on `net10.0`; the solution has **313 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index abd40f4..e82ce90 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -24,7 +24,8 @@ For a RID without a profile, pass the flags directly: ```bash dotnet publish src/MuClient.Tui -c Release -r osx-arm64 \ - --self-contained -p:PublishSingleFile=true -o out/osx-arm64 + --self-contained -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true -o out/osx-arm64 ``` The profiles enable single-file extraction, compression, and ReadyToRun for faster startup. diff --git a/docs/PLAN.md b/docs/PLAN.md index 8beef5f..10edb6a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -113,16 +113,17 @@ MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscri in the TUI. **Emoji** substitution (`EmojiSubstitutor`), opt-in per world. GMCP-driven **stat line**, **spawn** capture, ReDoS-guarded regex engines, and self-contained **single-file packaging** (`docs/PACKAGING.md`) + a tagged release workflow. +- **In-TUI web view** (`MuClient.Web` + `WebView`): fetch a URL or follow an MXP/Pueblo/HTML link + and read the page as styled, word-wrapped text with clickable in-pane navigation (AngleSharp → + `StyledLine`s, reusing `SpanInteraction`). `` renders as a labelled link today. ### Still open (M5+) - Dedicated **spawn windows** and **multiple input windows** (capture + routing hooks exist), **puppets**, MSDP-driven stat panes, and the **map** view. -- **In-TUI web view.** MXP/Pueblo already produce clickable links. Plan: a `WebViewPane` that - fetches a URL and renders HTML → `StyledLine`s via a managed parser (AngleSharp), reusing - `SpanInteraction` for in-pane navigation, with `` shown through the existing - `InlineImageRenderer` (Kitty → Sixel → half-block). Optional high-fidelity mode: snapshot the - page with headless Chromium (Playwright) and display the image via the graphics layer. Text - mode works in any terminal; images need a graphics-capable one. +- **Web view enhancements:** render `` inline through the existing `InlineImageRenderer` + (Kitty → Sixel → half-block) in graphics-capable terminals, and an optional high-fidelity mode + that snapshots the page with headless Chromium (Playwright) and displays the image via the + graphics layer. --- diff --git a/src/MuClient.Core/Protocols/MxpParser.cs b/src/MuClient.Core/Protocols/MxpParser.cs index 1107c10..fd62aeb 100644 --- a/src/MuClient.Core/Protocols/MxpParser.cs +++ b/src/MuClient.Core/Protocols/MxpParser.cs @@ -31,7 +31,7 @@ namespace MuClient.Core.Protocols; /// capped to avoid runaway on malformed input. /// /// -public sealed class MxpParser : MuClient.Core.Text.ILineParser +public sealed class MxpParser : ILineParser { private const int MaxTagLength = 4096; private const int MaxEntityLength = 32; @@ -553,6 +553,29 @@ private void CloseInteractionsAtBoundary() _stack.RemoveAt(i); } + // A formatting frame opened *inside* an interaction still holds it in SavedInteraction; + // if it closes on a later line, CloseTag would resurrect the ended interaction. Clear it + // (and rebase SpanStart, which pointed into the now-cleared line) on surviving frames. + for (var i = 0; i < _stack.Count; i++) + { + var frame = _stack[i]; + if (frame.SavedInteraction is not null || frame.SpanStart != 0) + { + _stack[i] = new Frame + { + Name = frame.Name, + SavedStyle = frame.SavedStyle, + SavedInteraction = null, + IsInteraction = frame.IsInteraction, + IsLink = frame.IsLink, + DeferCommand = frame.DeferCommand, + Hint = frame.Hint, + PromptOnly = frame.PromptOnly, + SpanStart = 0, + }; + } + } + _interaction = null; } diff --git a/src/MuClient.Core/Protocols/PuebloParser.cs b/src/MuClient.Core/Protocols/PuebloParser.cs index d5fd013..6f08542 100644 --- a/src/MuClient.Core/Protocols/PuebloParser.cs +++ b/src/MuClient.Core/Protocols/PuebloParser.cs @@ -23,7 +23,7 @@ namespace MuClient.Core.Protocols; /// escape sequences are handled upstream, so an ESC (0x1b) byte is passed through untouched into /// the span text rather than being interpreted here. /// -public sealed class PuebloParser : MuClient.Core.Text.ILineParser +public sealed class PuebloParser : ILineParser { private const int MaxTagLength = 4096; private const int MaxEntityLength = 32; @@ -94,6 +94,7 @@ public IReadOnlyList Feed(ReadOnlySpan text) public StyledLine? Flush() { FlushRun(); + CloseInteractionsAtBoundary(); if (_lineSpans.Count == 0) { return null; @@ -398,11 +399,35 @@ private void CloseTag(string name) private void CompleteLine(ref List? lines) { FlushRun(); + CloseInteractionsAtBoundary(); var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans); _lineSpans.Clear(); (lines ??= new List()).Add(line); } + /// + /// Drops open anchor/send frames at a line/prompt boundary so an unclosed <A> + /// never leaves subsequent output clickable (matching MxpParser). Formatting/colour + /// frames persist, but any interaction they saved is cleared so a later close can't restore it. + /// + private void CloseInteractionsAtBoundary() + { + for (var i = _stack.Count - 1; i >= 0; i--) + { + var frame = _stack[i]; + if (frame.Name is "a" or "send") + { + _stack.RemoveAt(i); + } + else if (frame.Interaction is not null) + { + _stack[i] = new Frame(frame.Name, frame.Style, null); + } + } + + _interaction = null; + } + private void FlushRun() { if (_run.Length == 0) diff --git a/src/MuClient.Core/Session/WorldSession.cs b/src/MuClient.Core/Session/WorldSession.cs index 10f38d9..f80a033 100644 --- a/src/MuClient.Core/Session/WorldSession.cs +++ b/src/MuClient.Core/Session/WorldSession.cs @@ -178,28 +178,12 @@ private void ProcessOutputLine(StyledLine line) } } - /// Substitutes emoji in each span's text when enabled for this world; a no-op otherwise. - private StyledLine ApplyEmoji(StyledLine line) - { - if (_emoji is null || line.IsEmpty) - { - return line; - } - - StyledSpan[]? rebuilt = null; - for (var i = 0; i < line.Spans.Count; i++) - { - var span = line.Spans[i]; - var replaced = _emoji.Apply(span.Text); - if (!ReferenceEquals(replaced, span.Text) && replaced != span.Text) - { - rebuilt ??= line.Spans.ToArray(); - rebuilt[i] = new StyledSpan(replaced, span.Style, span.Interaction); - } - } - - return rebuilt is null ? line : new StyledLine(rebuilt); - } + /// + /// Substitutes emoji across the whole line when enabled for this world (preserving word + /// boundaries across span seams and each span's style/interaction); a no-op otherwise. + /// + private StyledLine ApplyEmoji(StyledLine line) => + _emoji is null ? line : _emoji.ApplyToLine(line); /// Handles a line of user input: alias expansion, local echo, and send. public async Task SendUserInputAsync(string input, CancellationToken cancellationToken = default) diff --git a/src/MuClient.Core/Telnet/TelnetSession.cs b/src/MuClient.Core/Telnet/TelnetSession.cs index ab9d748..958d43e 100644 --- a/src/MuClient.Core/Telnet/TelnetSession.cs +++ b/src/MuClient.Core/Telnet/TelnetSession.cs @@ -262,9 +262,16 @@ public async Task DisconnectAsync() } } - // Clear the interpreter so a subsequent ConnectAsync can reconnect and the "not - // connected" guards on the send methods observe the disconnected state. + // Dispose and clear the interpreter here (not in DisposeAsync, which calls this first + // and would then see it already null) so no interpreter leaks per disconnect/reconnect, + // and so the "not connected" send guards observe the disconnected state. + var interpreter = _interpreter; _interpreter = null; + if (interpreter is not null) + { + await interpreter.DisposeAsync().ConfigureAwait(false); + } + RaiseDisconnected(null); } @@ -280,15 +287,9 @@ private void RaiseDisconnected(Exception? error) public async ValueTask DisposeAsync() { + // DisconnectAsync disposes and clears the interpreter. await DisconnectAsync().ConfigureAwait(false); _loopCts?.Dispose(); - - if (_interpreter is not null) - { - await _interpreter.DisposeAsync().ConfigureAwait(false); - _interpreter = null; - } - await _transport.DisposeAsync().ConfigureAwait(false); } } diff --git a/src/MuClient.Core/Text/EmojiSubstitutor.cs b/src/MuClient.Core/Text/EmojiSubstitutor.cs index 51b9358..badf449 100644 --- a/src/MuClient.Core/Text/EmojiSubstitutor.cs +++ b/src/MuClient.Core/Text/EmojiSubstitutor.cs @@ -59,6 +59,165 @@ public EmojiSubstitutor( public bool ShortcodesEnabled { get; } + /// + /// Substitutes emoji across a whole , using the full line for word-boundary + /// detection (so a token split across span seams is judged correctly) while preserving each + /// character's style and interaction. Returns the same line when nothing changes. + /// + public StyledLine ApplyToLine(StyledLine line) + { + ArgumentNullException.ThrowIfNull(line); + if (line.IsEmpty || (!EmoticonsEnabled && !ShortcodesEnabled)) + { + return line; + } + + var cells = new List(line.Length); + foreach (var span in line.Spans) + { + foreach (var ch in span.Text) + { + cells.Add(new Cell(ch.ToString(), span.Style, span.Interaction)); + } + } + + var changed = false; + if (ShortcodesEnabled) + { + changed |= ReplaceShortcodeCells(cells); + } + + if (EmoticonsEnabled) + { + changed |= ReplaceEmoticonCells(cells); + } + + return changed ? Coalesce(cells) : line; + } + + private bool ReplaceShortcodeCells(List cells) + { + var changed = false; + for (var i = 0; i < cells.Count; i++) + { + if (cells[i].Text != ":") + { + continue; + } + + var end = -1; + for (var j = i + 1; j < cells.Count && j <= i + 33; j++) + { + if (cells[j].Text == ":") + { + end = j; + break; + } + } + + if (end <= i + 1) + { + continue; + } + + var name = string.Concat(cells.GetRange(i + 1, end - i - 1).Select(c => c.Text)); + if (IsShortcodeName(name) && _shortcodes.TryGetValue(name, out var emoji)) + { + var style = cells[i].Style; + cells.RemoveRange(i, end - i + 1); + cells.Insert(i, new Cell(emoji, style, null)); + changed = true; + } + } + + return changed; + } + + private bool ReplaceEmoticonCells(List cells) + { + var changed = false; + for (var i = 0; i < cells.Count; i++) + { + var atBoundary = i == 0 || IsWhitespaceCell(cells[i - 1]); + if (!atBoundary) + { + continue; + } + + foreach (var (token, emoji) in _emoticons) + { + if (i + token.Length > cells.Count) + { + continue; + } + + var matches = true; + for (var k = 0; k < token.Length; k++) + { + if (cells[i + k].Text.Length != 1 || cells[i + k].Text[0] != token[k]) + { + matches = false; + break; + } + } + + if (!matches) + { + continue; + } + + var after = i + token.Length; + if (after != cells.Count && !IsWhitespaceCell(cells[after])) + { + continue; + } + + var style = cells[i].Style; + cells.RemoveRange(i, token.Length); + cells.Insert(i, new Cell(emoji, style, null)); + changed = true; + break; + } + } + + return changed; + } + + private static bool IsWhitespaceCell(Cell cell) => cell.Text.Length == 1 && char.IsWhiteSpace(cell.Text[0]); + + private static StyledLine Coalesce(List cells) + { + if (cells.Count == 0) + { + return StyledLine.Empty; + } + + var spans = new List(); + var sb = new System.Text.StringBuilder(cells[0].Text); + var style = cells[0].Style; + var link = cells[0].Link; + for (var i = 1; i < cells.Count; i++) + { + if (cells[i].Style.Equals(style) && Equals(cells[i].Link, link)) + { + sb.Append(cells[i].Text); + } + else + { + spans.Add(new StyledSpan(sb.ToString(), style, link)); + sb.Clear(); + sb.Append(cells[i].Text); + style = cells[i].Style; + link = cells[i].Link; + } + } + + spans.Add(new StyledSpan(sb.ToString(), style, link)); + return new StyledLine(spans); + } + + private readonly record struct Cell(string Text, TextStyle Style, SpanInteraction? Link); + /// Returns with emoticons and shortcodes replaced by emoji. public string Apply(string text) { diff --git a/src/MuClient.Scripting/ScriptException.cs b/src/MuClient.Scripting/ScriptException.cs index a94c560..df12cf9 100644 --- a/src/MuClient.Scripting/ScriptException.cs +++ b/src/MuClient.Scripting/ScriptException.cs @@ -32,24 +32,27 @@ internal static ScriptException FromInterpreter(InterpreterException ex) return null; } - // Syntax errors: "chunk:(fromLine,fromCol-toLine,toCol) message". - var open = decorated.IndexOf('('); - if (open >= 0 && open + 1 < decorated.Length) + // Syntax errors: "chunk:(fromLine,fromCol-toLine,toCol) message". Anchor on the ":(" + // header marker so a stray '(' in the message text can't be mistaken for the range. + var syntax = decorated.IndexOf(":(", StringComparison.Ordinal); + if (syntax >= 0) { - var comma = decorated.IndexOf(',', open); - if (comma > open && int.TryParse(decorated.AsSpan(open + 1, comma - open - 1), out var parenLine)) + var start = syntax + 2; + var comma = decorated.IndexOf(',', start); + if (comma > start && int.TryParse(decorated.AsSpan(start, comma - start), out var syntaxLine)) { - return parenLine; + return syntaxLine; } } - // Runtime errors: "[string \"chunk\"]:LINE: message" (no parenthesised range). - var bracket = decorated.LastIndexOf(']'); - var start = bracket >= 0 ? bracket + 1 : 0; - if (start < decorated.Length && decorated[start] == ':') + // Runtime errors: "[string \"chunk\"]:LINE: message". Anchor on the "]:" header marker + // (not the last ']', which may appear inside the message, e.g. table["x"]). + var runtime = decorated.IndexOf("]:", StringComparison.Ordinal); + if (runtime >= 0) { - var colon = decorated.IndexOf(':', start + 1); - if (colon > start && int.TryParse(decorated.AsSpan(start + 1, colon - start - 1), out var runtimeLine)) + var start = runtime + 2; + var colon = decorated.IndexOf(':', start); + if (colon > start && int.TryParse(decorated.AsSpan(start, colon - start), out var runtimeLine)) { return runtimeLine; } diff --git a/src/MuClient.Tui/GmcpStats.cs b/src/MuClient.Tui/GmcpStats.cs index 95f5106..fbfd1c6 100644 --- a/src/MuClient.Tui/GmcpStats.cs +++ b/src/MuClient.Tui/GmcpStats.cs @@ -47,7 +47,14 @@ public bool Update(string package, string json) continue; } - var value = property.Value.ToString(); + // JsonElement.ToString() yields "True"/"False" for booleans; normalise to JSON casing + // so booleans read consistently with numeric/string fields. + var value = property.Value.ValueKind switch + { + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => property.Value.ToString(), + }; if (!_values.TryGetValue(property.Name, out var existing) || existing != value) { _values[property.Name] = value; diff --git a/src/MuClient.Tui/MuGlyphApp.cs b/src/MuClient.Tui/MuGlyphApp.cs index 49ad7ef..8011072 100644 --- a/src/MuClient.Tui/MuGlyphApp.cs +++ b/src/MuClient.Tui/MuGlyphApp.cs @@ -188,12 +188,20 @@ private void OpenWeb(string url) private async Task LoadWebAsync(string url) { var width = Math.Max(20, _webView.Viewport.Width); - var page = await _fetcher.FetchAsync(url, width).ConfigureAwait(false); - Application.Invoke(() => + try { - _webView.Show(page); - _webView.SetNeedsDraw(); - }); + var page = await _fetcher.FetchAsync(url, width).ConfigureAwait(false); + Application.Invoke(() => + { + _webView.Show(page); + _webView.SetNeedsDraw(); + }); + } + catch (Exception ex) + { + // Fire-and-forget: surface the failure instead of dropping it silently. + Application.Invoke(() => _active?.PrintSystem($"*** Failed to load {url}: {ex.Message}")); + } } private void HideWeb() diff --git a/src/MuClient.Tui/Views/WebView.cs b/src/MuClient.Tui/Views/WebView.cs index 9bd9511..7b6dbc1 100644 --- a/src/MuClient.Tui/Views/WebView.cs +++ b/src/MuClient.Tui/Views/WebView.cs @@ -75,7 +75,8 @@ private void OnKeyDown(object? sender, Key key) private void Scroll(int delta) { - var max = Math.Max(0, _lines.Count - 1); + // Clamp so the last full screen of content stays visible rather than scrolling into blank space. + var max = Math.Max(0, _lines.Count - Math.Max(1, Viewport.Height)); _scroll = Math.Clamp(_scroll + delta, 0, max); SetNeedsDraw(); } diff --git a/src/MuClient.Web/HtmlStyledRenderer.cs b/src/MuClient.Web/HtmlStyledRenderer.cs index 651254b..5ac39de 100644 --- a/src/MuClient.Web/HtmlStyledRenderer.cs +++ b/src/MuClient.Web/HtmlStyledRenderer.cs @@ -32,14 +32,16 @@ public sealed class HtmlStyledRenderer TextStyle.Default.WithForeground(TerminalColor.FromIndex(11)).AddAttribute(TextAttributes.Bold); private readonly string? _baseUrl; + private int _width = 80; public HtmlStyledRenderer(string? baseUrl = null) => _baseUrl = baseUrl; public IReadOnlyList Render(string html, int width = 80) { ArgumentNullException.ThrowIfNull(html); + _width = Math.Max(20, width); var document = new HtmlParser().ParseDocument(html); - var writer = new LineWriter(Math.Max(20, width)); + var writer = new LineWriter(_width); INode? root = document.Body ?? document.DocumentElement; if (root is not null) { @@ -88,7 +90,7 @@ private void WalkElement(IElement element, LineWriter writer, TextStyle style, S return; case "hr": writer.BlankLine(); - writer.AddText(new string('─', 40), style, null, preformatted: true); + writer.AddText(new string('─', _width), style, null, preformatted: true); writer.LineBreak(); writer.BlankLine(); return; diff --git a/src/MuClient.Web/LineWriter.cs b/src/MuClient.Web/LineWriter.cs index b2e571f..1e74aa1 100644 --- a/src/MuClient.Web/LineWriter.cs +++ b/src/MuClient.Web/LineWriter.cs @@ -1,3 +1,4 @@ +using System.Text; using MuClient.Core.Text; namespace MuClient.Web; @@ -137,7 +138,7 @@ private StyledLine Coalesce() } var spans = new List(); - var runText = _current[0].Text; + var runText = new StringBuilder(_current[0].Text); var runStyle = _current[0].Style; var runLink = _current[0].Link; @@ -146,18 +147,20 @@ private StyledLine Coalesce() var seg = _current[i]; if (seg.Style.Equals(runStyle) && Equals(seg.Link, runLink)) { - runText += seg.Text; + // StringBuilder avoids O(n^2) copying when a page has many same-style segments. + runText.Append(seg.Text); } else { - spans.Add(new StyledSpan(runText, runStyle, runLink)); - runText = seg.Text; + spans.Add(new StyledSpan(runText.ToString(), runStyle, runLink)); + runText.Clear(); + runText.Append(seg.Text); runStyle = seg.Style; runLink = seg.Link; } } - spans.Add(new StyledSpan(runText, runStyle, runLink)); + spans.Add(new StyledSpan(runText.ToString(), runStyle, runLink)); return new StyledLine(spans); } diff --git a/src/MuClient.Web/WebPageFetcher.cs b/src/MuClient.Web/WebPageFetcher.cs index 0013801..b2bc9d2 100644 --- a/src/MuClient.Web/WebPageFetcher.cs +++ b/src/MuClient.Web/WebPageFetcher.cs @@ -46,14 +46,16 @@ public async Task FetchAsync(string url, int width = 80, CancellationTo .ConfigureAwait(false); response.EnsureSuccessStatusCode(); + // After redirects, resolve page identity and relative links against the final location. + var finalUri = response.RequestMessage?.RequestUri ?? uri; var body = await ReadCappedAsync(response, cancellationToken).ConfigureAwait(false); var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; if (contentType.Contains("html", StringComparison.OrdinalIgnoreCase) || contentType.Length == 0) { - var renderer = new HtmlStyledRenderer(uri.ToString()); + var renderer = new HtmlStyledRenderer(finalUri.ToString()); var lines = renderer.Render(body, width); - return new WebPage(uri.ToString(), HtmlStyledRenderer.GetTitle(body), lines); + return new WebPage(finalUri.ToString(), HtmlStyledRenderer.GetTitle(body), lines); } // Non-HTML: render as plain text lines. @@ -65,7 +67,7 @@ public async Task FetchAsync(string url, int width = 80, CancellationTo textLines.Add(tail); } - return new WebPage(uri.ToString(), uri.Host, textLines); + return new WebPage(finalUri.ToString(), finalUri.Host, textLines); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or InvalidOperationException) { diff --git a/tests/MuClient.Core.Tests/Protocols/ParserBoundaryTests.cs b/tests/MuClient.Core.Tests/Protocols/ParserBoundaryTests.cs new file mode 100644 index 0000000..adcd8c1 --- /dev/null +++ b/tests/MuClient.Core.Tests/Protocols/ParserBoundaryTests.cs @@ -0,0 +1,49 @@ +using MuClient.Core.Protocols; + +namespace MuClient.Core.Tests.Protocols; + +/// +/// Regression coverage for interaction (link/command) leakage across line boundaries in the +/// MXP and Pueblo parsers — an unclosed or nested interaction must not make later lines clickable. +/// +public class ParserBoundaryTests +{ + [Test] + public async Task Mxp_BareSend_DoesNotLeakToNextLine() + { + var parser = new MxpParser(); + var lines = parser.Feed("walk\nmore\n"); + var second = lines[1]; + await Assert.That(second.Spans.All(s => !s.IsInteractive)).IsTrue(); + } + + [Test] + public async Task Mxp_NestedFormattingInsideSend_DoesNotLeakInteractionAcrossNewline() + { + var parser = new MxpParser(); + // Bold opened inside the SEND and closed on the next line must not resurrect the command. + var lines = parser.Feed("walk\nmore tail\n"); + var second = lines[1]; + await Assert.That(second.Spans.All(s => !s.IsInteractive)).IsTrue(); + } + + [Test] + public async Task Pueblo_UnclosedAnchor_DoesNotLeakToNextLine() + { + var parser = new PuebloParser(); + var lines = parser.Feed("
here\nnext\n"); + var second = lines[1]; + await Assert.That(second.Spans.All(s => !s.IsInteractive)).IsTrue(); + } + + [Test] + public async Task Pueblo_UnclosedAnchor_DoesNotLeakIntoFlushedPrompt() + { + var parser = new PuebloParser(); + parser.Feed("here\n"); + parser.Feed("prompt> "); + var prompt = parser.Flush(); + await Assert.That(prompt).IsNotNull(); + await Assert.That(prompt!.Spans.All(s => !s.IsInteractive)).IsTrue(); + } +} diff --git a/tests/MuClient.Core.Tests/Text/EmojiSubstitutorTests.cs b/tests/MuClient.Core.Tests/Text/EmojiSubstitutorTests.cs index 5b2821b..6b199db 100644 --- a/tests/MuClient.Core.Tests/Text/EmojiSubstitutorTests.cs +++ b/tests/MuClient.Core.Tests/Text/EmojiSubstitutorTests.cs @@ -70,4 +70,45 @@ public async Task Heart_Emoticon() var sub = new EmojiSubstitutor(); await Assert.That(sub.Apply("<3 you")).IsEqualTo("❤️ you"); } + + [Test] + public async Task ApplyToLine_StandaloneEmoticon_AcrossSpanSeam_IsReplaced() + { + var sub = new EmojiSubstitutor(); + // Two spans, whitespace before the ":)" — a genuine standalone emoticon. + var line = new StyledLine(new[] + { + new StyledSpan("hi ", TextStyle.Default), + new StyledSpan(":)", new TextStyle(TerminalColor.FromIndex(2), TerminalColor.Default, TextAttributes.None)), + }); + var result = sub.ApplyToLine(line); + await Assert.That(result.Text).IsEqualTo("hi 🙂"); + } + + [Test] + public async Task ApplyToLine_MidWordAcrossSeam_IsNotReplaced() + { + var sub = new EmojiSubstitutor(); + // "foo" then ":)" with no whitespace — the full line is "foo:)", not a standalone emoticon. + var line = new StyledLine(new[] + { + new StyledSpan("foo", TextStyle.Default), + new StyledSpan(":)", new TextStyle(TerminalColor.FromIndex(2), TerminalColor.Default, TextAttributes.None)), + }); + var result = sub.ApplyToLine(line); + await Assert.That(result.Text).IsEqualTo("foo:)"); + } + + [Test] + public async Task ApplyToLine_Shortcode_PreservesOtherSpanStyles() + { + var sub = new EmojiSubstitutor(); + var line = new StyledLine(new[] + { + new StyledSpan("hot ", TextStyle.Default), + new StyledSpan(":fire:", new TextStyle(TerminalColor.FromIndex(1), TerminalColor.Default, TextAttributes.None)), + }); + var result = sub.ApplyToLine(line); + await Assert.That(result.Text).IsEqualTo("hot 🔥"); + } } diff --git a/tests/MuClient.Core.Tests/Text/WebColorsAndInteractionTests.cs b/tests/MuClient.Core.Tests/Text/WebColorsAndInteractionTests.cs index 436f053..9671cd0 100644 --- a/tests/MuClient.Core.Tests/Text/WebColorsAndInteractionTests.cs +++ b/tests/MuClient.Core.Tests/Text/WebColorsAndInteractionTests.cs @@ -70,4 +70,21 @@ public async Task StyledSpan_CarriesInteraction_AndAffectsEquality() await Assert.That(linked.IsInteractive).IsTrue(); await Assert.That(plain).IsNotEqualTo(linked); } + + [Test] + public async Task StyledSpan_WithIdenticalInteraction_AreEqualAndShareHash() + { + // Separately-constructed interactions with equal values (record equality). + var a = new StyledSpan("x", TextStyle.Default, SpanInteraction.Command("go", "hint")); + var b = new StyledSpan("x", TextStyle.Default, SpanInteraction.Command("go", "hint")); + await Assert.That(a).IsEqualTo(b); + await Assert.That(a.GetHashCode()).IsEqualTo(b.GetHashCode()); + } + + [Test] + public async Task SpanInteraction_ValueEquality() + { + await Assert.That(SpanInteraction.Command("go")).IsEqualTo(SpanInteraction.Command("go")); + await Assert.That(SpanInteraction.Link("u")).IsNotEqualTo(SpanInteraction.Command("u")); + } } diff --git a/tests/MuClient.Web.Tests/HtmlStyledRendererTests.cs b/tests/MuClient.Web.Tests/HtmlStyledRendererTests.cs index 5f1cc33..5259a47 100644 --- a/tests/MuClient.Web.Tests/HtmlStyledRendererTests.cs +++ b/tests/MuClient.Web.Tests/HtmlStyledRendererTests.cs @@ -102,6 +102,19 @@ public async Task Image_BecomesLabelledLink() var lines = Render("\"a"); var span = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("image")); await Assert.That(span.Text).Contains("a cat"); + await Assert.That(span.IsInteractive).IsTrue(); + await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.Hyperlink); + await Assert.That(span.Interaction!.Target).IsEqualTo("pic.png"); + } + + [Test] + public async Task ManySameStyleSegments_CoalesceCorrectly() + { + // Exercises the StringBuilder coalescing path with many adjacent same-style inline nodes. + var html = "

" + string.Concat(Enumerable.Repeat("a", 500)) + "

"; + var lines = Render(html, width: 10_000); + var text = string.Concat(lines.Select(l => l.Text)); + await Assert.That(text).IsEqualTo(new string('a', 500)); } [Test] From d6200d3296e83c741f05193dbe3a8f57ec1ef54f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:22:26 +0000 Subject: [PATCH 04/68] =?UTF-8?q?Design=20Step=201:=20worlds=E2=86=92chara?= =?UTF-8?q?cters=20+=20shared=20trigger=20sets=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the config schema around the design's connection model: - WorldDefinition is now a *server* holding CharacterDefinitions; a character is the connection unit and carries login (connect string, auto-login, on-connect/disconnect) and per-character logging. - Automation moves off the world into world-independent TriggerSets on AppConfiguration; characters opt in by name. A session composes its trigger/alias/macro engines from the union of the character's sets. - WorldSession gains Character, SessionKey (world.character), composed ScriptFiles, and character auto-login on connect. SessionManager gets a character+sets Open overload and SessionKey lookup. - Add ConfigurationMigrator (v1→v2): lifts each world's legacy triggers/aliases/macros/logging into a generated trigger set + default character, run automatically on deserialize. - Drop the BeipMU importer (not needed). - WorldDefinition gains an Accent colour for per-world window traceability. Tests updated for the new shape; 325 passing across the solution. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB --- CLAUDE.md | 10 +- README.md | 5 +- docs/PLAN.md | 2 +- .../Configuration/AppConfiguration.cs | 45 +++++- .../Configuration/BeipMuImporter.cs | 129 ---------------- .../Configuration/CharacterDefinition.cs | 38 +++++ .../Configuration/ConfigurationMigrator.cs | 142 ++++++++++++++++++ .../Configuration/ConfigurationStore.cs | 11 +- src/MuClient.Core/Configuration/TriggerSet.cs | 25 +++ .../Configuration/WorldDefinition.cs | 30 ++-- src/MuClient.Core/Session/SessionManager.cs | 33 +++- src/MuClient.Core/Session/WorldSession.cs | 71 ++++++++- .../Configuration/ConfigurationTests.cs | 128 ++++++++++------ .../Session/WorldSessionContentTests.cs | 2 +- .../Session/WorldSessionTests.cs | 68 ++++++--- 15 files changed, 515 insertions(+), 224 deletions(-) delete mode 100644 src/MuClient.Core/Configuration/BeipMuImporter.cs create mode 100644 src/MuClient.Core/Configuration/CharacterDefinition.cs create mode 100644 src/MuClient.Core/Configuration/ConfigurationMigrator.cs create mode 100644 src/MuClient.Core/Configuration/TriggerSet.cs diff --git a/CLAUDE.md b/CLAUDE.md index afe3729..d91f676 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,18 +22,20 @@ fallbacks) for inline images/maps. - **Inline graphics:** in scope from day one (Kitty Unicode placeholders → Sixel → half-block). - **Protocols:** aim for all common MU\* protocols. GMCP/MSSP/CHARSET/NAWS/MTTS/EOR via TelnetNegotiationCore; **MCCP, MSDP, MXP, and Pueblo are our own app layer.** -- **Config:** fresh JSON schema of our own + a **BeipMU import/migration** path. +- **Config:** fresh JSON schema of our own (worlds hold characters; automation lives in shared + named trigger sets), versioned with automatic migration between schema revisions. - **License:** MIT. ## Repository state **M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all nine projects on -`net10.0`; the solution has **313 passing tests**. In place: +`net10.0`; the solution has **325 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), - trigger/alias/macro engines + `IntervalScheduler`, plain-text + HTML logging, JSON config + - BeipMU importer, `Theme`/`ThemeLibrary`, and `WorldSession`/`SessionManager` orchestration. + trigger/alias/macro engines + `IntervalScheduler`, plain-text + HTML logging, versioned JSON + config (worlds → characters + shared trigger sets, with migration), + `Theme`/`ThemeLibrary`, and `WorldSession`/`SessionManager` orchestration. - **Graphics** — Kitty encoder + Unicode placeholders, Sixel + half-block fallbacks, capability probe (no UI dependency). - **Scripting** — sandboxed MoonSharp `ScriptHost` (world/output/trigger/alias/timer/gmcp/log). diff --git a/README.md b/README.md index 9c84c37..613a0f5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ client: rich truecolor text, inline graphics, powerful automation, and full MU\* > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (313 tests across the solution). See +> `MuClient.Core` is fully unit-tested (325 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI @@ -59,7 +59,8 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. line breaks. Selectable per world. - **Emoji** — optional emoticon (`:)` → 🙂) and `:shortcode:` (`:fire:` → 🔥) substitution. - **Logging** — plain-text and styled **HTML** session logs. -- **Config** — a fresh JSON schema plus a best-effort **BeipMU importer**. +- **Config** — a fresh JSON schema: worlds (servers) hold **characters**, and automation lives in + shared, named **trigger sets** that characters opt into; versioned with automatic migration. - **Inline graphics** — Kitty graphics-protocol encoder (incl. Unicode placeholders), Sixel and half-block fallbacks, and a capability probe that degrades cleanly when no protocol is present. - **Scripting** — sandboxed **Lua** (MoonSharp) exposing `world`/`output`/`trigger`/`alias`/ diff --git a/docs/PLAN.md b/docs/PLAN.md index 10edb6a..20d4885 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -13,7 +13,7 @@ Key reframing from research: **"GPU-enabled" is a property of the terminal emula - **Scope:** broad BeipMU parity (phased into milestones below). - **Target framework:** **.NET 10**. - **Protocol coverage:** aim for compatibility with *all* common MU\* protocols; **MXP** is first-class, and **Pueblo** (and its enhancements) are explicitly in scope alongside GMCP/MSDP/MSSP/MCCP. -- **Config:** **fresh JSON** schema of our own, plus a **BeipMU import/migration** path for initial onboarding (parse BeipMU's settings to seed worlds/triggers/aliases). +- **Config:** **fresh JSON** schema of our own — worlds (servers) hold **characters**; automation lives in shared, named **trigger sets** that characters opt into by name — versioned with automatic migration between schema revisions. --- diff --git a/src/MuClient.Core/Configuration/AppConfiguration.cs b/src/MuClient.Core/Configuration/AppConfiguration.cs index 2c77609..375eded 100644 --- a/src/MuClient.Core/Configuration/AppConfiguration.cs +++ b/src/MuClient.Core/Configuration/AppConfiguration.cs @@ -2,11 +2,14 @@ namespace MuClient.Core.Configuration; -/// Top-level MuGlyph configuration: global preferences plus the saved worlds. +/// Top-level MuGlyph configuration: global preferences, saved worlds, and shared trigger sets. public sealed class AppConfiguration { + /// The current on-disk schema version. Older configs are upgraded by . + public const int CurrentVersion = 2; + /// Schema version, for future migrations. - public int Version { get; set; } = 1; + public int Version { get; set; } = CurrentVersion; /// The name of the active built-in theme (see ). public string ThemeName { get; set; } = "Dark"; @@ -17,7 +20,7 @@ public sealed class AppConfiguration /// public Theme Theme { get; set; } = ThemeLibrary.Dark(); - /// Maximum scrollback lines retained per world. + /// Maximum scrollback lines retained per session. public int ScrollbackLines { get; set; } = 20_000; /// @@ -29,5 +32,41 @@ public sealed class AppConfiguration /// Default charset preference order (IANA names), most-preferred first. public List CharsetOrder { get; set; } = new() { "utf-8", "iso-8859-1" }; + /// The saved worlds (servers), each holding its own characters. public List Worlds { get; set; } = new(); + + /// + /// World-independent automation bundles. Characters opt in by name via + /// , so a set can be shared across worlds and + /// characters. See . + /// + public List TriggerSets { get; set; } = new(); + + /// + /// Resolves the s a character has opted into, in the character's own + /// order, matching by name case-insensitively. Names with no matching set are skipped, and + /// each set is returned at most once even if referenced twice. + /// + public IReadOnlyList ResolveTriggerSets(CharacterDefinition character) + { + ArgumentNullException.ThrowIfNull(character); + + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var set in TriggerSets) + { + byName.TryAdd(set.Name, set); + } + + var resolved = new List(); + var seen = new HashSet(); + foreach (var name in character.TriggerSets) + { + if (byName.TryGetValue(name, out var set) && seen.Add(set)) + { + resolved.Add(set); + } + } + + return resolved; + } } diff --git a/src/MuClient.Core/Configuration/BeipMuImporter.cs b/src/MuClient.Core/Configuration/BeipMuImporter.cs deleted file mode 100644 index cc57fd7..0000000 --- a/src/MuClient.Core/Configuration/BeipMuImporter.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Xml.Linq; -using MuClient.Core.Automation; - -namespace MuClient.Core.Configuration; - -/// -/// Best-effort importer for BeipMU's XML settings. BeipMU's schema is not formally documented -/// here, so this parser is deliberately tolerant: it scans for world-like elements and reads -/// name/host/port plus any nested triggers/aliases by common element and attribute names, -/// case-insensitively. Unrecognised data is ignored rather than failing the import. -/// -public static class BeipMuImporter -{ - public static IReadOnlyList Import(string xml) - { - ArgumentNullException.ThrowIfNull(xml); - XDocument doc; - try - { - doc = XDocument.Parse(xml); - } - catch (System.Xml.XmlException) - { - return Array.Empty(); - } - - var worlds = new List(); - foreach (var element in doc.Descendants().Where(e => LocalNameContains(e, "world"))) - { - var world = TryReadWorld(element); - if (world is not null) - { - worlds.Add(world); - } - } - - return worlds; - } - - public static IReadOnlyList ImportFile(string path) - { - ArgumentException.ThrowIfNullOrEmpty(path); - return Import(File.ReadAllText(path)); - } - - private static WorldDefinition? TryReadWorld(XElement element) - { - var host = Value(element, "host") ?? Value(element, "address") ?? Value(element, "server"); - var name = Value(element, "name") ?? Value(element, "title") ?? host; - var portText = Value(element, "port"); - - if (string.IsNullOrWhiteSpace(host)) - { - return null; - } - - var world = new WorldDefinition - { - Name = name ?? host, - Host = host, - Port = int.TryParse(portText, out var port) ? port : 4000, - UseTls = ParseBool(Value(element, "ssl") ?? Value(element, "tls") ?? Value(element, "secure")), - }; - - foreach (var t in element.Descendants().Where(e => LocalNameContains(e, "trigger"))) - { - var pattern = Value(t, "pattern") ?? Value(t, "match") ?? Value(t, "regex"); - if (string.IsNullOrWhiteSpace(pattern)) - { - continue; - } - - var send = Value(t, "send") ?? Value(t, "command") ?? Value(t, "response"); - world.Triggers.Add(new Trigger - { - Name = Value(t, "name") ?? string.Empty, - Pattern = pattern, - Actions = new TriggerActions - { - Gag = ParseBool(Value(t, "gag") ?? Value(t, "omit")), - SendResponse = string.IsNullOrWhiteSpace(send) ? null : send, - }, - }); - } - - foreach (var a in element.Descendants().Where(e => LocalNameContains(e, "alias"))) - { - var pattern = Value(a, "pattern") ?? Value(a, "match") ?? Value(a, "name"); - var substitution = Value(a, "send") ?? Value(a, "command") ?? Value(a, "expand"); - if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(substitution)) - { - continue; - } - - world.Aliases.Add(new Alias - { - Name = Value(a, "name") ?? string.Empty, - Pattern = pattern, - Substitution = substitution, - }); - } - - return world; - } - - private static bool LocalNameContains(XElement element, string fragment) => - element.Name.LocalName.Contains(fragment, StringComparison.OrdinalIgnoreCase); - - /// Reads a value from either a same-named attribute or a same-named child element. - private static string? Value(XElement element, string name) - { - var attribute = element.Attributes() - .FirstOrDefault(a => a.Name.LocalName.Equals(name, StringComparison.OrdinalIgnoreCase)); - if (attribute is not null && !string.IsNullOrWhiteSpace(attribute.Value)) - { - return attribute.Value; - } - - var child = element.Elements() - .FirstOrDefault(e => e.Name.LocalName.Equals(name, StringComparison.OrdinalIgnoreCase)); - return child is not null && !string.IsNullOrWhiteSpace(child.Value) ? child.Value : null; - } - - private static bool ParseBool(string? value) => - value is not null && - (value.Equals("true", StringComparison.OrdinalIgnoreCase) || - value == "1" || - value.Equals("yes", StringComparison.OrdinalIgnoreCase)); -} diff --git a/src/MuClient.Core/Configuration/CharacterDefinition.cs b/src/MuClient.Core/Configuration/CharacterDefinition.cs new file mode 100644 index 0000000..9f3bd25 --- /dev/null +++ b/src/MuClient.Core/Configuration/CharacterDefinition.cs @@ -0,0 +1,38 @@ +namespace MuClient.Core.Configuration; + +/// +/// A character on a — the unit you actually connect *as*. A world +/// (server) may hold zero or more characters, and several can be connected at once; sessions are +/// keyed world.character. Automation is composed from the named . +/// +public sealed class CharacterDefinition +{ + public string Name { get; set; } = "New Character"; + + /// Login password. Should be keychain-backed by the host; avoid persisting in plain JSON. + public string? Password { get; set; } + + /// The login line to send. Defaults to connect {Name} {Password} when null. + public string? ConnectString { get; set; } + + /// Send the connect string automatically on connect. + public bool AutoLogin { get; set; } + + /// Semicolon-separated commands sent after connecting. + public string? OnConnect { get; set; } + + /// Semicolon-separated commands sent (or run locally) on disconnect. + public string? OnDisconnect { get; set; } + + /// Names of the s that apply to this character. + public List TriggerSets { get; set; } = new(); + + /// Logging is configured per character. + public LoggingSettings Logging { get; set; } = new(); + + /// Builds the default login line when is unset. + public string ResolveConnectString() => + !string.IsNullOrWhiteSpace(ConnectString) + ? ConnectString! + : $"connect {Name}{(string.IsNullOrEmpty(Password) ? string.Empty : " " + Password)}"; +} diff --git a/src/MuClient.Core/Configuration/ConfigurationMigrator.cs b/src/MuClient.Core/Configuration/ConfigurationMigrator.cs new file mode 100644 index 0000000..1d4d462 --- /dev/null +++ b/src/MuClient.Core/Configuration/ConfigurationMigrator.cs @@ -0,0 +1,142 @@ +using System.Text.Json.Nodes; + +namespace MuClient.Core.Configuration; + +/// +/// Upgrades an on-disk configuration DOM to . Runs +/// before deserialization so old files keep working after the schema moved automation and login +/// details off the world and onto shared trigger sets and per-world characters. +/// +public static class ConfigurationMigrator +{ + /// + /// Mutates in place, applying each version step needed to bring it up + /// to the current schema. Unknown or already-current documents are left untouched. + /// + public static void Migrate(JsonObject root) + { + ArgumentNullException.ThrowIfNull(root); + + var version = root["version"]?.GetValue() ?? 1; + if (version < 2) + { + MigrateV1ToV2(root); + } + + root["version"] = AppConfiguration.CurrentVersion; + } + + /// + /// v1 stored triggers/aliases/macros/scriptFiles and logging on each world. v2 lifts each + /// world's automation into a shared trigger set and gives the world a default character that + /// opts into that set and carries the old logging. + /// + private static void MigrateV1ToV2(JsonObject root) + { + if (root["worlds"] is not JsonArray worlds) + { + return; + } + + var triggerSets = root["triggerSets"] as JsonArray; + if (triggerSets is null) + { + triggerSets = new JsonArray(); + root["triggerSets"] = triggerSets; + } + + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var set in triggerSets.OfType()) + { + if (set["name"]?.GetValue() is { } existing) + { + usedNames.Add(existing); + } + } + + foreach (var node in worlds) + { + if (node is JsonObject world) + { + MigrateWorld(world, triggerSets, usedNames); + } + } + } + + private static void MigrateWorld(JsonObject world, JsonArray triggerSets, HashSet usedNames) + { + var worldName = world["name"]?.GetValue() ?? "Imported World"; + + var triggers = Detach(world, "triggers"); + var aliases = Detach(world, "aliases"); + var macros = Detach(world, "macros"); + var scriptFiles = Detach(world, "scriptFiles"); + var logging = Detach(world, "logging"); + + var hasAutomation = HasItems(triggers) || HasItems(aliases) || HasItems(macros) || HasItems(scriptFiles); + + // Preserve an existing v2 characters array if a partially-migrated file supplies one. + if (world["characters"] is JsonArray existingCharacters && existingCharacters.Count > 0 && !hasAutomation) + { + return; + } + + string? setName = null; + if (hasAutomation) + { + setName = UniqueName(worldName, usedNames); + triggerSets.Add(new JsonObject + { + ["name"] = setName, + ["description"] = $"Migrated from world '{worldName}'.", + ["triggers"] = triggers ?? new JsonArray(), + ["aliases"] = aliases ?? new JsonArray(), + ["macros"] = macros ?? new JsonArray(), + ["scriptFiles"] = scriptFiles ?? new JsonArray(), + }); + } + + if (world["characters"] is not JsonArray) + { + var character = new JsonObject { ["name"] = worldName }; + if (setName is not null) + { + character["triggerSets"] = new JsonArray(setName); + } + + if (logging is not null) + { + character["logging"] = logging; + } + + world["characters"] = new JsonArray(character); + } + } + + /// Removes from and returns the detached node. + private static JsonNode? Detach(JsonObject obj, string name) + { + if (!obj.TryGetPropertyValue(name, out var node) || node is null) + { + obj.Remove(name); + return null; + } + + obj.Remove(name); + return node; + } + + private static bool HasItems(JsonNode? node) => node is JsonArray array && array.Count > 0; + + private static string UniqueName(string worldName, HashSet used) + { + var candidate = worldName; + var suffix = 2; + while (!used.Add(candidate)) + { + candidate = $"{worldName} ({suffix++})"; + } + + return candidate; + } +} diff --git a/src/MuClient.Core/Configuration/ConfigurationStore.cs b/src/MuClient.Core/Configuration/ConfigurationStore.cs index c5a04eb..4182215 100644 --- a/src/MuClient.Core/Configuration/ConfigurationStore.cs +++ b/src/MuClient.Core/Configuration/ConfigurationStore.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace MuClient.Core.Configuration; @@ -53,7 +54,15 @@ public static AppConfiguration Load(string path) public static AppConfiguration Deserialize(string json) { ArgumentNullException.ThrowIfNull(json); - return JsonSerializer.Deserialize(json, SerializerOptions) ?? new AppConfiguration(); + + // Parse to a DOM first so older files can be upgraded to the current schema in place. + if (JsonNode.Parse(json) is not JsonObject root) + { + return new AppConfiguration(); + } + + ConfigurationMigrator.Migrate(root); + return root.Deserialize(SerializerOptions) ?? new AppConfiguration(); } public static string Serialize(AppConfiguration configuration) diff --git a/src/MuClient.Core/Configuration/TriggerSet.cs b/src/MuClient.Core/Configuration/TriggerSet.cs new file mode 100644 index 0000000..ec6c207 --- /dev/null +++ b/src/MuClient.Core/Configuration/TriggerSet.cs @@ -0,0 +1,25 @@ +using MuClient.Core.Automation; + +namespace MuClient.Core.Configuration; + +/// +/// A named, world-independent bundle of automation. Characters select which sets apply by name, +/// so a "Comms" set can be shared across every character while a "Trade" set is live for one +/// character and dark for another on the same world. A session's engines are composed from the +/// union of its character's assigned sets. +/// +public sealed class TriggerSet +{ + public string Name { get; set; } = "New Set"; + + public string? Description { get; set; } + + public List Triggers { get; set; } = new(); + + public List Aliases { get; set; } = new(); + + public List Macros { get; set; } = new(); + + /// Lua script files loaded when this set is active for a session. + public List ScriptFiles { get; set; } = new(); +} diff --git a/src/MuClient.Core/Configuration/WorldDefinition.cs b/src/MuClient.Core/Configuration/WorldDefinition.cs index ddb7444..8a1430a 100644 --- a/src/MuClient.Core/Configuration/WorldDefinition.cs +++ b/src/MuClient.Core/Configuration/WorldDefinition.cs @@ -1,9 +1,9 @@ -using MuClient.Core.Automation; +using MuClient.Core.Text; using MuClient.Core.Transport; namespace MuClient.Core.Configuration; -/// Which log formats a world writes. +/// Which log formats a session writes. public enum LogFormat { None, @@ -35,18 +35,20 @@ public sealed class EmojiSettings public bool Shortcodes { get; set; } = true; } -/// Per-world logging configuration. +/// Per-character logging configuration. public sealed class LoggingSettings { public LogFormat Format { get; set; } = LogFormat.None; - /// Directory for log files. Defaults to a per-world folder under the config dir. + /// Directory for log files. Defaults to a per-session folder under the config dir. public string? Directory { get; set; } } /// -/// A saved MU* world: connection parameters plus its automation (triggers/aliases/macros), -/// scripting, and logging. This is the primary unit persisted to JSON. +/// A saved MU* world — i.e. a server (host/port/TLS/encoding + rendering options) that +/// holds zero or more s. A character is the connection unit; +/// automation lives in world-independent s on . +/// A world with no characters is valid — it simply cannot connect. /// public sealed class WorldDefinition { @@ -69,16 +71,14 @@ public sealed class WorldDefinition /// Emoji/emoticon substitution for inbound text. public EmojiSettings Emoji { get; set; } = new(); - public List Triggers { get; set; } = new(); + /// + /// The world's accent colour, used to keep its windows traceable to their owner once they + /// scatter across panes. Default resolves to a theme-derived accent. + /// + public TerminalColor Accent { get; set; } = TerminalColor.Default; - public List Aliases { get; set; } = new(); - - public List Macros { get; set; } = new(); - - /// Lua script files (relative or absolute) loaded for this world. - public List ScriptFiles { get; set; } = new(); - - public LoggingSettings Logging { get; set; } = new(); + /// The characters that can connect to this world. + public List Characters { get; set; } = new(); /// Builds the transport-level options from this world. public ConnectionOptions ToConnectionOptions() => new() diff --git a/src/MuClient.Core/Session/SessionManager.cs b/src/MuClient.Core/Session/SessionManager.cs index d843185..41d6f11 100644 --- a/src/MuClient.Core/Session/SessionManager.cs +++ b/src/MuClient.Core/Session/SessionManager.cs @@ -23,7 +23,10 @@ public IReadOnlyList Sessions } } - /// Creates and registers a session for a world (does not connect it). + /// + /// Creates and registers an anonymous session for a world (no character, no automation). + /// Used for ad-hoc command-line connections. Does not connect it. + /// public WorldSession Open(WorldDefinition world, int scrollbackCapacity = 20_000) { ArgumentNullException.ThrowIfNull(world); @@ -32,6 +35,34 @@ public WorldSession Open(WorldDefinition world, int scrollbackCapacity = 20_000) return session; } + /// + /// Creates and registers a session for a specific character on a world, composing its + /// automation from . Does not connect it. + /// + public WorldSession Open( + WorldDefinition world, + CharacterDefinition character, + IReadOnlyList triggerSets, + int scrollbackCapacity = 20_000) + { + ArgumentNullException.ThrowIfNull(world); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(triggerSets); + var session = new WorldSession(world, character, triggerSets, scrollbackCapacity: scrollbackCapacity); + Add(session); + return session; + } + + /// Finds an open session by its , or null. + public WorldSession? Find(string sessionKey) + { + ArgumentNullException.ThrowIfNull(sessionKey); + lock (_gate) + { + return _sessions.FirstOrDefault(s => s.SessionKey == sessionKey); + } + } + /// Registers an already-constructed session (used by tests with a fake transport). public void Add(WorldSession session) { diff --git a/src/MuClient.Core/Session/WorldSession.cs b/src/MuClient.Core/Session/WorldSession.cs index f80a033..838468a 100644 --- a/src/MuClient.Core/Session/WorldSession.cs +++ b/src/MuClient.Core/Session/WorldSession.cs @@ -28,13 +28,22 @@ public sealed class WorldSession : IAsyncDisposable private readonly ILogSink? _log; private ITelnetSession? _telnet; + /// + /// Creates a session for a world and (optionally) the character being connected as. Automation + /// is composed from the union of — resolve them for a character + /// via . A null character yields an anonymous + /// session (e.g. an ad-hoc command-line connection) with no auto-login. + /// public WorldSession( WorldDefinition world, + CharacterDefinition? character = null, + IReadOnlyList? triggerSets = null, Func? sessionFactory = null, ILogSink? log = null, int scrollbackCapacity = 20_000) { World = world ?? throw new ArgumentNullException(nameof(world)); + Character = character; _sessionFactory = sessionFactory ?? DefaultSessionFactory; _log = log; _parser = CreateParser(world.ContentFormat); @@ -42,9 +51,14 @@ public WorldSession( ? new EmojiSubstitutor(world.Emoji.Emoticons, world.Emoji.Shortcodes) : null; Scrollback = new ScrollbackBuffer(scrollbackCapacity); - Triggers = new TriggerEngine(world.Triggers); - Aliases = new AliasEngine(world.Aliases); - Macros = new MacroEngine(world.Macros); + + var sets = triggerSets ?? Array.Empty(); + Triggers = new TriggerEngine(sets.SelectMany(s => s.Triggers)); + Aliases = new AliasEngine(sets.SelectMany(s => s.Aliases)); + Macros = new MacroEngine(sets.SelectMany(s => s.Macros)); + ScriptFiles = sets.SelectMany(s => s.ScriptFiles) + .Distinct(StringComparer.Ordinal) + .ToArray(); } private static ILineParser CreateParser(ContentFormat format) => format switch @@ -56,6 +70,18 @@ public WorldSession( public WorldDefinition World { get; } + /// The character this session connects as, or null for an anonymous connection. + public CharacterDefinition? Character { get; } + + /// + /// Stable identity for this session: world.character, or just the world name when + /// connecting anonymously. Used to key open sessions in the . + /// + public string SessionKey => Character is null ? World.Name : $"{World.Name}.{Character.Name}"; + + /// Lua script files contributed by the active trigger sets, de-duplicated. + public IReadOnlyList ScriptFiles { get; } + public ScrollbackBuffer Scrollback { get; } public TriggerEngine Triggers { get; } @@ -121,6 +147,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) await telnet.ConnectAsync(cancellationToken).ConfigureAwait(false); SetState(ConnectionState.Connected, null); PrintSystem("*** Connected."); + await SendLoginAsync(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -209,6 +236,44 @@ public async Task SendUserInputAsync(string input, CancellationToken cancellatio await SendRawAsync(input, cancellationToken).ConfigureAwait(false); } + /// + /// Sends the character's auto-login line (when is + /// set) followed by its semicolon-separated commands. + /// A no-op for anonymous sessions. + /// + private async Task SendLoginAsync(CancellationToken cancellationToken) + { + var character = Character; + if (character is null) + { + return; + } + + if (character.AutoLogin) + { + await SendRawAsync(character.ResolveConnectString(), cancellationToken).ConfigureAwait(false); + } + + foreach (var command in SplitCommands(character.OnConnect)) + { + await SendRawAsync(command, cancellationToken).ConfigureAwait(false); + } + } + + /// Splits a semicolon-separated command string, trimming and dropping blank segments. + private static IEnumerable SplitCommands(string? commands) + { + if (string.IsNullOrWhiteSpace(commands)) + { + yield break; + } + + foreach (var part in commands.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + yield return part; + } + } + /// Sends a command verbatim (no alias expansion, no echo). public async Task SendRawAsync(string command, CancellationToken cancellationToken = default) { diff --git a/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs b/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs index 8d4bdb6..b1c4b91 100644 --- a/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs +++ b/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs @@ -7,7 +7,7 @@ namespace MuClient.Core.Tests.Configuration; public class ConfigurationTests { [Test] - public async Task RoundTrip_PreservesWorldsTriggersAndColors() + public async Task RoundTrip_PreservesWorldsCharactersTriggerSetsAndColors() { var config = new AppConfiguration { @@ -20,6 +20,17 @@ public async Task RoundTrip_PreservesWorldsTriggersAndColors() Host = "mush.example.org", Port = 4201, UseTls = true, + Characters = + { + new CharacterDefinition { Name = "Wizard", TriggerSets = { "Combat" } }, + }, + }, + }, + TriggerSets = + { + new TriggerSet + { + Name = "Combat", Triggers = { new Trigger @@ -38,77 +49,100 @@ public async Task RoundTrip_PreservesWorldsTriggersAndColors() var json = ConfigurationStore.Serialize(config); var restored = ConfigurationStore.Deserialize(json); + await Assert.That(restored.Version).IsEqualTo(AppConfiguration.CurrentVersion); await Assert.That(restored.ScrollbackLines).IsEqualTo(5000); await Assert.That(restored.Worlds).HasSingleItem(); var world = restored.Worlds[0]; await Assert.That(world.Name).IsEqualTo("Test MUSH"); await Assert.That(world.Port).IsEqualTo(4201); await Assert.That(world.UseTls).IsTrue(); - await Assert.That(world.Triggers).HasSingleItem(); - await Assert.That(world.Triggers[0].Actions.HighlightForeground).IsEqualTo(TerminalColor.FromRgb(255, 215, 0)); - await Assert.That(world.Aliases[0].Substitution).IsEqualTo("\"$1"); - await Assert.That(world.Macros[0].Command).IsEqualTo("look"); + await Assert.That(world.Characters[0].Name).IsEqualTo("Wizard"); + await Assert.That(world.Characters[0].TriggerSets).Contains("Combat"); + + await Assert.That(restored.TriggerSets).HasSingleItem(); + var set = restored.TriggerSets[0]; + await Assert.That(set.Triggers[0].Actions.HighlightForeground).IsEqualTo(TerminalColor.FromRgb(255, 215, 0)); + await Assert.That(set.Aliases[0].Substitution).IsEqualTo("\"$1"); + await Assert.That(set.Macros[0].Command).IsEqualTo("look"); } [Test] - public async Task Deserialize_EmptyDefaultsGracefully() + public async Task ResolveTriggerSets_ReturnsCharactersSetsInOrder_SkippingMissingAndDuplicates() { - var config = ConfigurationStore.Deserialize("{}"); - await Assert.That(config.Worlds).IsEmpty(); - await Assert.That(config.Version).IsEqualTo(1); + var config = new AppConfiguration + { + TriggerSets = + { + new TriggerSet { Name = "Comms" }, + new TriggerSet { Name = "Trade" }, + }, + }; + var character = new CharacterDefinition + { + TriggerSets = { "trade", "Comms", "Missing", "Trade" }, + }; + + var resolved = config.ResolveTriggerSets(character); + + await Assert.That(resolved.Select(s => s.Name)).IsEquivalentTo(new[] { "Trade", "Comms" }); } [Test] - public async Task ColorConverter_RoundTripsAllKinds() + public async Task Deserialize_EmptyDefaultsGracefully() { - await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.Default)).IsEqualTo("default"); - await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.FromIndex(196))).IsEqualTo("idx:196"); - await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.FromRgb(1, 2, 3))).IsEqualTo("rgb:1,2,3"); - await Assert.That(TerminalColorJsonConverter.Parse("idx:196")).IsEqualTo(TerminalColor.FromIndex(196)); - await Assert.That(TerminalColorJsonConverter.Parse("rgb:1,2,3")).IsEqualTo(TerminalColor.FromRgb(1, 2, 3)); - await Assert.That(TerminalColorJsonConverter.Parse("garbage")).IsEqualTo(TerminalColor.Default); + var config = ConfigurationStore.Deserialize("{}"); + await Assert.That(config.Worlds).IsEmpty(); + await Assert.That(config.Version).IsEqualTo(AppConfiguration.CurrentVersion); } -} -public class BeipMuImporterTests -{ [Test] - public async Task Import_ReadsWorldsAndAutomation() + public async Task Deserialize_V1Config_MigratesAutomationIntoTriggerSetsAndCharacter() { - const string xml = """ - - - - - - - - - - - - - + const string v1 = """ + { + "version": 1, + "worlds": [ + { + "name": "Old World", + "host": "old.example.net", + "port": 6250, + "triggers": [ { "name": "hail", "pattern": "waves" } ], + "aliases": [ { "pattern": "^gt (.+)", "substitution": "grouptell $1" } ], + "macros": [ { "key": "F1", "command": "look" } ], + "logging": { "format": "Html" } + } + ] + } """; - var worlds = BeipMuImporter.Import(xml); - await Assert.That(worlds).Count().IsEqualTo(2); + var config = ConfigurationStore.Deserialize(v1); + + await Assert.That(config.Version).IsEqualTo(AppConfiguration.CurrentVersion); + await Assert.That(config.Worlds).HasSingleItem(); + var world = config.Worlds[0]; + await Assert.That(world.Host).IsEqualTo("old.example.net"); + await Assert.That(world.Characters).HasSingleItem(); - var muck = worlds[0]; - await Assert.That(muck.Name).IsEqualTo("Furry MUCK"); - await Assert.That(muck.Host).IsEqualTo("muck.example.net"); - await Assert.That(muck.Port).IsEqualTo(8888); - await Assert.That(muck.Triggers).HasSingleItem(); - await Assert.That(muck.Triggers[0].Actions.SendResponse).IsEqualTo("reply hi"); - await Assert.That(muck.Aliases[0].Substitution).IsEqualTo("grouptell $1"); + var character = world.Characters[0]; + await Assert.That(character.TriggerSets).Contains("Old World"); + await Assert.That(character.Logging.Format).IsEqualTo(LogFormat.Html); - await Assert.That(worlds[1].UseTls).IsTrue(); + await Assert.That(config.TriggerSets).HasSingleItem(); + var set = config.TriggerSets[0]; + await Assert.That(set.Name).IsEqualTo("Old World"); + await Assert.That(set.Triggers[0].Pattern).IsEqualTo("waves"); + await Assert.That(set.Aliases[0].Substitution).IsEqualTo("grouptell $1"); + await Assert.That(set.Macros[0].Command).IsEqualTo("look"); } [Test] - public async Task Import_InvalidXml_ReturnsEmpty() + public async Task ColorConverter_RoundTripsAllKinds() { - var worlds = BeipMuImporter.Import("not xml <<<"); - await Assert.That(worlds).IsEmpty(); + await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.Default)).IsEqualTo("default"); + await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.FromIndex(196))).IsEqualTo("idx:196"); + await Assert.That(TerminalColorJsonConverter.ToString(TerminalColor.FromRgb(1, 2, 3))).IsEqualTo("rgb:1,2,3"); + await Assert.That(TerminalColorJsonConverter.Parse("idx:196")).IsEqualTo(TerminalColor.FromIndex(196)); + await Assert.That(TerminalColorJsonConverter.Parse("rgb:1,2,3")).IsEqualTo(TerminalColor.FromRgb(1, 2, 3)); + await Assert.That(TerminalColorJsonConverter.Parse("garbage")).IsEqualTo(TerminalColor.Default); } } diff --git a/tests/MuClient.Core.Tests/Session/WorldSessionContentTests.cs b/tests/MuClient.Core.Tests/Session/WorldSessionContentTests.cs index e3c28cd..5dd0eb3 100644 --- a/tests/MuClient.Core.Tests/Session/WorldSessionContentTests.cs +++ b/tests/MuClient.Core.Tests/Session/WorldSessionContentTests.cs @@ -9,7 +9,7 @@ public class WorldSessionContentTests private static (WorldSession session, FakeTelnetSession telnet) Create(WorldDefinition world) { var telnet = new FakeTelnetSession(); - return (new WorldSession(world, _ => telnet), telnet); + return (new WorldSession(world, sessionFactory: _ => telnet), telnet); } private static StyledLine FindLine(WorldSession session, string text) => diff --git a/tests/MuClient.Core.Tests/Session/WorldSessionTests.cs b/tests/MuClient.Core.Tests/Session/WorldSessionTests.cs index 0979ebc..364fac0 100644 --- a/tests/MuClient.Core.Tests/Session/WorldSessionTests.cs +++ b/tests/MuClient.Core.Tests/Session/WorldSessionTests.cs @@ -8,10 +8,13 @@ namespace MuClient.Core.Tests.Session; public class WorldSessionTests { - private static (WorldSession session, FakeTelnetSession telnet) Create(WorldDefinition world) + private static (WorldSession session, FakeTelnetSession telnet) Create( + WorldDefinition world, + TriggerSet? set = null) { var telnet = new FakeTelnetSession(); - var session = new WorldSession(world, _ => telnet); + var sets = set is null ? null : new[] { set }; + var session = new WorldSession(world, triggerSets: sets, sessionFactory: _ => telnet); return (session, telnet); } @@ -46,9 +49,9 @@ public async Task AnsiColor_InOutput_IsParsedIntoStyledSpans() [Test] public async Task Trigger_Gag_SuppressesLineFromScrollback() { - var world = World(); - world.Triggers.Add(new Trigger { Pattern = "secret", Actions = new TriggerActions { Gag = true } }); - var (session, telnet) = Create(world); + var set = new TriggerSet(); + set.Triggers.Add(new Trigger { Pattern = "secret", Actions = new TriggerActions { Gag = true } }); + var (session, telnet) = Create(World(), set); await session.ConnectAsync(); telnet.EmitLine("a secret message"); @@ -59,13 +62,13 @@ public async Task Trigger_Gag_SuppressesLineFromScrollback() [Test] public async Task Trigger_Response_IsSentToServer() { - var world = World(); - world.Triggers.Add(new Trigger + var set = new TriggerSet(); + set.Triggers.Add(new Trigger { Pattern = @"^(\w+) waves", Actions = new TriggerActions { SendResponse = "wave $1" }, }); - var (session, telnet) = Create(world); + var (session, telnet) = Create(World(), set); await session.ConnectAsync(); telnet.EmitLine("Gandalf waves"); @@ -76,9 +79,9 @@ public async Task Trigger_Response_IsSentToServer() [Test] public async Task Trigger_Spawn_RoutesLineToSpawnEvent() { - var world = World(); - world.Triggers.Add(new Trigger { Pattern = @"\[chat\]", Actions = new TriggerActions { SpawnTarget = "Chat" } }); - var (session, telnet) = Create(world); + var set = new TriggerSet(); + set.Triggers.Add(new Trigger { Pattern = @"\[chat\]", Actions = new TriggerActions { SpawnTarget = "Chat" } }); + var (session, telnet) = Create(World(), set); SpawnLineEventArgs? spawned = null; session.SpawnLine += (_, e) => spawned = e; await session.ConnectAsync(); @@ -120,9 +123,9 @@ public async Task UserInput_IsEchoedAndSent() [Test] public async Task UserInput_AliasIsExpandedBeforeSend() { - var world = World(); - world.Aliases.Add(new Alias { Pattern = "^gt (.+)", Substitution = "grouptell $1" }); - var (session, telnet) = Create(world); + var set = new TriggerSet(); + set.Aliases.Add(new Alias { Pattern = "^gt (.+)", Substitution = "grouptell $1" }); + var (session, telnet) = Create(World(), set); await session.ConnectAsync(); await session.SendUserInputAsync("gt hello"); @@ -134,9 +137,9 @@ public async Task UserInput_AliasIsExpandedBeforeSend() [Test] public async Task Macro_KeyResolvesAndSends() { - var world = World(); - world.Macros.Add(new Macro { Key = "Ctrl+F1", Command = "north" }); - var (session, telnet) = Create(world); + var set = new TriggerSet(); + set.Macros.Add(new Macro { Key = "Ctrl+F1", Command = "north" }); + var (session, telnet) = Create(World(), set); await session.ConnectAsync(); var command = await session.HandleKeyAsync("Ctrl+F1"); @@ -159,6 +162,37 @@ public async Task Gmcp_IsReRaised() await Assert.That(gmcp!.Package).IsEqualTo("Char.Vitals"); } + [Test] + public async Task Character_AutoLogin_SendsConnectStringAndOnConnect() + { + var character = new CharacterDefinition + { + Name = "Wizard", + Password = "swordfish", + AutoLogin = true, + OnConnect = "look; who", + }; + var telnet = new FakeTelnetSession(); + var session = new WorldSession(World(), character, sessionFactory: _ => telnet); + + await session.ConnectAsync(); + + await Assert.That(telnet.SentLines).Contains("connect Wizard swordfish"); + await Assert.That(telnet.SentLines).Contains("look"); + await Assert.That(telnet.SentLines).Contains("who"); + await Assert.That(session.SessionKey).IsEqualTo("T.Wizard"); + } + + [Test] + public async Task AnonymousSession_KeyIsWorldName_AndDoesNotAutoLogin() + { + var (session, telnet) = Create(World()); + await session.ConnectAsync(); + + await Assert.That(session.SessionKey).IsEqualTo("T"); + await Assert.That(telnet.SentLines).IsEmpty(); + } + [Test] public async Task State_TransitionsToConnected() { From 5e2c81ef5d8ef9fe50358c3f475ef9419b6527fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:28:42 +0000 Subject: [PATCH 05/68] Design Step 2: pure pane-split workspace model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MuClient.Core.Workspace — a UI-agnostic model of the design's tmux-style pane tree, so the layout logic is fully unit-testable before any Terminal.Gui hosting: - LayoutNode tree: PaneNode leaves (a tab strip of window ids + active tab + frozen flag) and SplitNode interior nodes (row/col, fractional sizes summing to 1). - WorkspaceLayout owns the tree plus focus/zoom state and all mutation: split-focused (moving non-active tabs into a new sibling), close, cycle focus, zoom, freeze, add/remove/move windows, split-with-window at an edge, and reorder active tab. Every op maintains the invariants — no empty panes, no single-child splits, always ≥1 pane, valid focus/zoom — via a prune-and-fix pass that collapses the tree. 17 workspace tests; 342 passing across the solution. Docs updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB --- CLAUDE.md | 2 +- README.md | 2 +- docs/PLAN.md | 13 +- src/MuClient.Core/Workspace/LayoutNode.cs | 125 +++++++ .../Workspace/WorkspaceLayout.cs | 340 ++++++++++++++++++ .../Workspace/WorkspaceLayoutTests.cs | 241 +++++++++++++ 6 files changed, 720 insertions(+), 3 deletions(-) create mode 100644 src/MuClient.Core/Workspace/LayoutNode.cs create mode 100644 src/MuClient.Core/Workspace/WorkspaceLayout.cs create mode 100644 tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index d91f676..6cde76f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ fallbacks) for inline images/maps. ## Repository state **M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all nine projects on -`net10.0`; the solution has **325 passing tests**. In place: +`net10.0`; the solution has **342 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), diff --git a/README.md b/README.md index 613a0f5..1533c55 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ client: rich truecolor text, inline graphics, powerful automation, and full MU\* > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (325 tests across the solution). See +> `MuClient.Core` is fully unit-tested (342 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI diff --git a/docs/PLAN.md b/docs/PLAN.md index 20d4885..35dc67e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -105,7 +105,7 @@ Transport (TCP + SslStream TLS + IPv6); TelnetSession over TelnetNegotiationCore MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscriptions from Lua, hot-reload. **M5 — Full parity & polish** -**Spawns** (route matched output to named windows), **puppets**, **multiple input windows**, **MXP + Pueblo** parsers (clickable links/commands/``, inline images via graphics layer), MSDP, **BeipMU config importer**, Unicode emoji + `:)`→🙂, smooth-scroll/appearance options, theming, packaging (dotnet single-file for Windows + Linux; optional distro packages). +**Spawns** (route matched output to named windows), **puppets**, **multiple input windows**, **MXP + Pueblo** parsers (clickable links/commands/``, inline images via graphics layer), MSDP, Unicode emoji + `:)`→🙂, smooth-scroll/appearance options, theming, packaging (dotnet single-file for Windows + Linux; optional distro packages). ### M5 progress (delivered) - **MXP** and **Pueblo** parsers in `Core` (`ILineParser`), selectable per world via @@ -117,6 +117,17 @@ MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscri and read the page as styled, word-wrapped text with clickable in-pane navigation (AngleSharp → `StyledLine`s, reusing `SpanInteraction`). `` renders as a labelled link today. +### M5 UI design (in progress) +Implementing the multi-pane workspace design (tmux-style pane tree hosting BeipMU-style windows), +in reviewable steps landing on `Core` first (pure + tested), then the Terminal.Gui shell: +- **Config schema** (`Core.Configuration`): worlds (servers) hold **characters**; automation lives + in shared, named **trigger sets** that characters opt into. Sessions key on `world.character` and + compose engines from the union of a character's sets. Versioned with `ConfigurationMigrator`. +- **Workspace model** (`Core.Workspace`): a pure `WorkspaceLayout` split tree — `PaneNode` + (tab strip of window ids) / `SplitNode` (row/col) with focus, zoom, freeze, and the tmux-style + split / close / cycle / move / reorder operations, maintaining the no-empty-pane / no-lone-split + invariants. The Terminal.Gui view hosting renders from this model (next). + ### Still open (M5+) - Dedicated **spawn windows** and **multiple input windows** (capture + routing hooks exist), **puppets**, MSDP-driven stat panes, and the **map** view. diff --git a/src/MuClient.Core/Workspace/LayoutNode.cs b/src/MuClient.Core/Workspace/LayoutNode.cs new file mode 100644 index 0000000..f40c2d7 --- /dev/null +++ b/src/MuClient.Core/Workspace/LayoutNode.cs @@ -0,0 +1,125 @@ +namespace MuClient.Core.Workspace; + +/// How a arranges its children. +public enum SplitDirection +{ + /// Children sit side by side, left to right (a vertical divider between them). + Row, + + /// Children stack top to bottom (a horizontal divider between them). + Column, +} + +/// An edge of a pane, used when splitting a pane by dropping a window against a side. +public enum Edge +{ + Left, + Right, + Top, + Bottom, +} + +/// A node in the workspace split tree: either a or a . +public abstract class LayoutNode +{ + /// Enumerates every pane at or under this node, in left-to-right / top-to-bottom order. + public abstract IEnumerable Panes(); +} + +/// +/// A leaf pane: a bordered box hosting a tab strip of window ids and one active tab. Panes are the +/// only nodes that hold content; interior nodes only divide space. +/// +public sealed class PaneNode : LayoutNode +{ + public PaneNode(string id, IEnumerable? tabs = null, int activeIndex = 0, bool frozen = false) + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + Tabs = tabs is null ? new List() : new List(tabs); + ActiveIndex = Tabs.Count == 0 ? -1 : Math.Clamp(activeIndex, 0, Tabs.Count - 1); + Frozen = frozen; + } + + /// Stable pane identity, unique within a workspace. + public string Id { get; } + + /// The window ids hosted here, in tab order. + public List Tabs { get; } + + /// Index of the active tab, or -1 when the pane is empty. + public int ActiveIndex { get; set; } + + /// Whether this pane is frozen (scrollback split from live output). + public bool Frozen { get; set; } + + /// The active window id, or null when the pane is empty. + public string? ActiveTab => ActiveIndex >= 0 && ActiveIndex < Tabs.Count ? Tabs[ActiveIndex] : null; + + public override IEnumerable Panes() + { + yield return this; + } + + /// Re-clamps into range after the tab list changes. + internal void ClampActive() => ActiveIndex = Tabs.Count == 0 ? -1 : Math.Clamp(ActiveIndex, 0, Tabs.Count - 1); +} + +/// +/// An interior split dividing its area among two or more children along one axis. Sizes are +/// fractional weights that always sum to 1. +/// +public sealed class SplitNode : LayoutNode +{ + public SplitNode(SplitDirection direction, IReadOnlyList children, IReadOnlyList? sizes = null) + { + ArgumentNullException.ThrowIfNull(children); + if (children.Count < 2) + { + throw new ArgumentException("A split needs at least two children.", nameof(children)); + } + + Direction = direction; + Children = new List(children); + Sizes = sizes is { Count: > 0 } && sizes.Count == children.Count + ? Normalize(sizes) + : Even(children.Count); + } + + public SplitDirection Direction { get; } + + public List Children { get; } + + /// Fractional sizes parallel to , summing to 1. + public List Sizes { get; private set; } + + public override IEnumerable Panes() + { + foreach (var child in Children) + { + foreach (var pane in child.Panes()) + { + yield return pane; + } + } + } + + /// Resets sizes to an even split across the current children. + internal void ResetSizes() => Sizes = Even(Children.Count); + + private static List Even(int count) + { + var each = 1.0 / count; + return Enumerable.Repeat(each, count).ToList(); + } + + private static List Normalize(IReadOnlyList sizes) + { + var total = sizes.Sum(s => Math.Max(0, s)); + if (total <= 0) + { + return Even(sizes.Count); + } + + return sizes.Select(s => Math.Max(0, s) / total).ToList(); + } +} diff --git a/src/MuClient.Core/Workspace/WorkspaceLayout.cs b/src/MuClient.Core/Workspace/WorkspaceLayout.cs new file mode 100644 index 0000000..780dcd6 --- /dev/null +++ b/src/MuClient.Core/Workspace/WorkspaceLayout.cs @@ -0,0 +1,340 @@ +namespace MuClient.Core.Workspace; + +/// +/// The pure, UI-agnostic model of a tmux-style pane workspace: a recursive split tree of panes, +/// each hosting a tab strip of window ids, plus focus and zoom state. All mutation goes through +/// this type so the invariants — no empty panes, no single-child splits, always at least one pane, +/// a valid focus — hold after every operation. The Terminal.Gui layer renders from this model. +/// +public sealed class WorkspaceLayout +{ + private readonly HashSet _removed = new(); + private int _paneCounter; + + /// Creates a workspace with a single pane, optionally seeded with window ids. + public WorkspaceLayout(IEnumerable? initialTabs = null) + { + var pane = NewPane(initialTabs); + Root = pane; + FocusedPaneId = pane.Id; + } + + /// The root of the split tree (a or ). + public LayoutNode Root { get; private set; } + + /// The id of the focused pane. Always references a live pane. + public string FocusedPaneId { get; private set; } + + /// The id of the zoomed pane (rendered full-area), or null when nothing is zoomed. + public string? ZoomedPaneId { get; private set; } + + /// Every pane, in left-to-right / top-to-bottom order. + public IReadOnlyList Panes => Root.Panes().ToList(); + + /// The focused pane. + public PaneNode FocusedPane => FindPane(FocusedPaneId)!; + + /// Finds a pane by id, or null. + public PaneNode? FindPane(string id) => Root.Panes().FirstOrDefault(p => p.Id == id); + + /// Finds the pane currently hosting a window, or null. + public PaneNode? FindWindow(string windowId) => + Root.Panes().FirstOrDefault(p => p.Tabs.Contains(windowId)); + + /// Moves focus to a pane by id. Returns false if the pane does not exist. + public bool Focus(string paneId) + { + if (FindPane(paneId) is null) + { + return false; + } + + FocusedPaneId = paneId; + return true; + } + + /// Cycles focus to the next pane in tree order (tmux o). + public void CycleFocus() + { + var panes = Panes; + if (panes.Count <= 1) + { + return; + } + + var index = 0; + for (var i = 0; i < panes.Count; i++) + { + if (panes[i].Id == FocusedPaneId) + { + index = i; + break; + } + } + + FocusedPaneId = panes[(index + 1) % panes.Count].Id; + } + + /// + /// Splits the focused pane along , moving its non-active tabs into a + /// new sibling pane (tmux | / -). The active tab and focus stay put. A no-op when + /// the pane has fewer than two tabs (nothing to pull out). Returns true if a split happened. + /// + public bool SplitFocused(SplitDirection direction) + { + var pane = FocusedPane; + if (pane.Tabs.Count <= 1) + { + return false; + } + + var active = pane.ActiveTab!; + var others = pane.Tabs.Where((_, i) => i != pane.ActiveIndex).ToList(); + + pane.Tabs.Clear(); + pane.Tabs.Add(active); + pane.ActiveIndex = 0; + + var newPane = NewPane(others); + var split = new SplitNode(direction, new LayoutNode[] { pane, newPane }); + ReplaceNode(pane, split); + return true; + } + + /// Closes the focused pane and its tabs (tmux x), pruning and refocusing. + public void CloseFocused() + { + _removed.Add(FocusedPane); + PruneAndFix(); + } + + /// Toggles zoom on the focused pane (tmux z). + public void ToggleZoom() => + ZoomedPaneId = ZoomedPaneId == FocusedPaneId ? null : FocusedPaneId; + + /// Toggles the frozen (split-scrollback) state of the focused pane. + public void ToggleFreezeFocused() + { + var pane = FocusedPane; + pane.Frozen = !pane.Frozen; + } + + /// Adds a window as a tab in a pane (the focused pane by default) and activates it. + public void AddWindow(string windowId, string? paneId = null) + { + ArgumentNullException.ThrowIfNull(windowId); + DetachWindow(windowId); + var pane = paneId is null ? FocusedPane : FindPane(paneId) ?? FocusedPane; + pane.Tabs.Add(windowId); + pane.ActiveIndex = pane.Tabs.Count - 1; + PruneAndFix(); + } + + /// Removes a window wherever it lives, pruning any pane it emptied. Returns false if absent. + public bool RemoveWindow(string windowId) + { + if (!DetachWindow(windowId)) + { + return false; + } + + PruneAndFix(); + return true; + } + + /// Moves a window into an existing pane as a tab, activating it. Returns false if the pane is gone. + public bool MoveWindowToPane(string windowId, string targetPaneId) + { + var target = FindPane(targetPaneId); + if (target is null) + { + return false; + } + + DetachWindow(windowId); + target.Tabs.Add(windowId); + target.ActiveIndex = target.Tabs.Count - 1; + PruneAndFix(); + return true; + } + + /// + /// Moves a window out into a new pane created by splitting along + /// the given edge, and focuses the new pane. Returns false if the target pane is gone. + /// + public bool SplitWithWindow(string windowId, string targetPaneId, Edge edge) + { + var target = FindPane(targetPaneId); + if (target is null) + { + return false; + } + + DetachWindow(windowId); + var newPane = NewPane(new[] { windowId }); + var direction = edge is Edge.Left or Edge.Right ? SplitDirection.Row : SplitDirection.Column; + var before = edge is Edge.Left or Edge.Top; + var children = before + ? new LayoutNode[] { newPane, target } + : new LayoutNode[] { target, newPane }; + ReplaceNode(target, new SplitNode(direction, children)); + FocusedPaneId = newPane.Id; + PruneAndFix(); + return true; + } + + /// Reorders the focused pane's active tab by one slot (tmux < / >). + public bool ReorderActiveTab(int delta) + { + var pane = FocusedPane; + if (pane.Tabs.Count < 2 || pane.ActiveIndex < 0 || delta == 0) + { + return false; + } + + var target = pane.ActiveIndex + Math.Sign(delta); + if (target < 0 || target >= pane.Tabs.Count) + { + return false; + } + + (pane.Tabs[pane.ActiveIndex], pane.Tabs[target]) = (pane.Tabs[target], pane.Tabs[pane.ActiveIndex]); + pane.ActiveIndex = target; + return true; + } + + /// Sets the active tab of a pane to a hosted window. Returns false if not found. + public bool SetActiveTab(string paneId, string windowId) + { + var pane = FindPane(paneId); + var index = pane?.Tabs.IndexOf(windowId) ?? -1; + if (pane is null || index < 0) + { + return false; + } + + pane.ActiveIndex = index; + return true; + } + + private PaneNode NewPane(IEnumerable? tabs = null) => new($"p{++_paneCounter}", tabs); + + /// Removes a window from whichever pane hosts it, fixing that pane's active index. + private bool DetachWindow(string windowId) + { + foreach (var pane in Root.Panes()) + { + var index = pane.Tabs.IndexOf(windowId); + if (index < 0) + { + continue; + } + + pane.Tabs.RemoveAt(index); + if (pane.ActiveIndex >= index && pane.ActiveIndex > 0) + { + pane.ActiveIndex--; + } + + pane.ClampActive(); + return true; + } + + return false; + } + + /// Swaps for in the tree. + private void ReplaceNode(LayoutNode target, LayoutNode replacement) + { + if (ReferenceEquals(Root, target)) + { + Root = replacement; + return; + } + + if (FindParent(target) is (SplitNode parent, int index)) + { + parent.Children[index] = replacement; + } + } + + private (SplitNode parent, int index)? FindParent(LayoutNode target) + { + return Search(Root); + + (SplitNode, int)? Search(LayoutNode node) + { + if (node is not SplitNode split) + { + return null; + } + + for (var i = 0; i < split.Children.Count; i++) + { + if (ReferenceEquals(split.Children[i], target)) + { + return (split, i); + } + + if (Search(split.Children[i]) is { } found) + { + return found; + } + } + + return null; + } + } + + /// + /// Rebuilds the tree, dropping panes marked for removal or left empty and collapsing splits with + /// a single surviving child. Guarantees at least one pane and a valid focus/zoom afterward. + /// + private void PruneAndFix() + { + Root = Prune(Root) ?? NewPane(); + _removed.Clear(); + + if (FindPane(FocusedPaneId) is null) + { + FocusedPaneId = Root.Panes().First().Id; + } + + if (ZoomedPaneId is not null && FindPane(ZoomedPaneId) is null) + { + ZoomedPaneId = null; + } + } + + private LayoutNode? Prune(LayoutNode node) + { + if (_removed.Contains(node)) + { + return null; + } + + if (node is PaneNode pane) + { + return pane.Tabs.Count > 0 ? pane : null; + } + + var split = (SplitNode)node; + var keptNodes = new List(); + var keptSizes = new List(); + for (var i = 0; i < split.Children.Count; i++) + { + if (Prune(split.Children[i]) is { } survivor) + { + keptNodes.Add(survivor); + keptSizes.Add(split.Sizes[i]); + } + } + + return keptNodes.Count switch + { + 0 => null, + 1 => keptNodes[0], + _ => new SplitNode(split.Direction, keptNodes, keptSizes), + }; + } +} diff --git a/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs b/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs new file mode 100644 index 0000000..6c9ebf7 --- /dev/null +++ b/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs @@ -0,0 +1,241 @@ +using MuClient.Core.Workspace; + +namespace MuClient.Core.Tests.Workspace; + +public class WorkspaceLayoutTests +{ + [Test] + public async Task New_HasSingleFocusedPane_NoZoom() + { + var w = new WorkspaceLayout(new[] { "main" }); + + await Assert.That(w.Root).IsTypeOf(); + await Assert.That(w.Panes).HasSingleItem(); + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "main" }); + await Assert.That(w.FocusedPane.ActiveTab).IsEqualTo("main"); + await Assert.That(w.ZoomedPaneId).IsNull(); + } + + [Test] + public async Task AddWindow_AppendsAndActivates_MovingFromAnyExistingPane() + { + var w = new WorkspaceLayout(new[] { "a" }); + w.AddWindow("b"); + + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "a", "b" }); + await Assert.That(w.FocusedPane.ActiveTab).IsEqualTo("b"); + + // Re-adding an existing window relocates it rather than duplicating. + w.AddWindow("a"); + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "b", "a" }); + await Assert.That(w.FindWindow("a")).IsEqualTo(w.FocusedPane); + } + + [Test] + public async Task SplitFocused_WithOneTab_IsNoOp() + { + var w = new WorkspaceLayout(new[] { "only" }); + + var split = w.SplitFocused(SplitDirection.Row); + + await Assert.That(split).IsFalse(); + await Assert.That(w.Root).IsTypeOf(); + } + + [Test] + public async Task SplitFocused_KeepsActiveTab_MovesOthersToNewPane() + { + var w = new WorkspaceLayout(new[] { "a", "b", "c" }); + var originalId = w.FocusedPaneId; + + var ok = w.SplitFocused(SplitDirection.Row); + + await Assert.That(ok).IsTrue(); + await Assert.That(w.Root).IsTypeOf(); + var split = (SplitNode)w.Root; + await Assert.That(split.Direction).IsEqualTo(SplitDirection.Row); + await Assert.That(split.Children).Count().IsEqualTo(2); + await Assert.That(split.Sizes).IsEquivalentTo(new[] { 0.5, 0.5 }); + + // Focus and the active tab stay in the original pane; the rest move out. + await Assert.That(w.FocusedPaneId).IsEqualTo(originalId); + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "a" }); + var other = w.Panes.First(p => p.Id != originalId); + await Assert.That(other.Tabs).IsEquivalentTo(new[] { "b", "c" }); + } + + [Test] + public async Task SplitFocused_MovesTheNonActiveTab_WhenActiveIsNotFirst() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SetActiveTab(w.FocusedPaneId, "b"); + + w.SplitFocused(SplitDirection.Column); + + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "b" }); + await Assert.That(w.Panes.First(p => p.Id != w.FocusedPaneId).Tabs).IsEquivalentTo(new[] { "a" }); + } + + [Test] + public async Task CloseFocused_CollapsesSplitIntoRemainingChild() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Row); // focused pane [a], sibling [b] + var survivorId = w.Panes.First(p => p.Id != w.FocusedPaneId).Id; + + w.CloseFocused(); + + await Assert.That(w.Root).IsTypeOf(); + await Assert.That(w.Panes).HasSingleItem(); + await Assert.That(w.FocusedPaneId).IsEqualTo(survivorId); + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "b" }); + } + + [Test] + public async Task CloseFocused_OnLonePane_LeavesAFreshEmptyPane() + { + var w = new WorkspaceLayout(new[] { "a" }); + + w.CloseFocused(); + + await Assert.That(w.Panes).HasSingleItem(); + await Assert.That(w.FocusedPane.Tabs).IsEmpty(); + await Assert.That(w.FindPane(w.FocusedPaneId)).IsNotNull(); + } + + [Test] + public async Task CycleFocus_WalksPanesAndWraps() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Row); + var first = w.FocusedPaneId; + + w.CycleFocus(); + var second = w.FocusedPaneId; + await Assert.That(second).IsNotEqualTo(first); + + w.CycleFocus(); + await Assert.That(w.FocusedPaneId).IsEqualTo(first); + } + + [Test] + public async Task ToggleZoom_SetsAndClears_AndClosingZoomedPaneResetsIt() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Row); + + w.ToggleZoom(); + await Assert.That(w.ZoomedPaneId).IsEqualTo(w.FocusedPaneId); + + w.ToggleZoom(); + await Assert.That(w.ZoomedPaneId).IsNull(); + + w.ToggleZoom(); + w.CloseFocused(); + await Assert.That(w.ZoomedPaneId).IsNull(); + } + + [Test] + public async Task ReorderActiveTab_MovesWithinPane_AndClampsAtEdges() + { + var w = new WorkspaceLayout(new[] { "a", "b", "c" }); + + await Assert.That(w.ReorderActiveTab(-1)).IsFalse(); // active "a" already leftmost + + w.SetActiveTab(w.FocusedPaneId, "b"); + await Assert.That(w.ReorderActiveTab(-1)).IsTrue(); + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "b", "a", "c" }); + await Assert.That(w.FocusedPane.ActiveTab).IsEqualTo("b"); + } + + [Test] + public async Task MoveWindowToPane_MovesTab_AndPrunesEmptiedSource() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Row); // [a] focused, [b] sibling + var focusedId = w.FocusedPaneId; + + var moved = w.MoveWindowToPane("b", focusedId); + + await Assert.That(moved).IsTrue(); + await Assert.That(w.Root).IsTypeOf(); // sibling emptied → collapsed + await Assert.That(w.FindPane(focusedId)!.Tabs).IsEquivalentTo(new[] { "a", "b" }); + } + + [Test] + public async Task SplitWithWindow_CreatesEdgePane_AndFocusesIt() + { + var w = new WorkspaceLayout(new[] { "a", "b", "c" }); + var targetId = w.FocusedPaneId; + + var ok = w.SplitWithWindow("c", targetId, Edge.Right); + + await Assert.That(ok).IsTrue(); + var split = (SplitNode)w.Root; + await Assert.That(split.Direction).IsEqualTo(SplitDirection.Row); + await Assert.That(((PaneNode)split.Children[0]).Id).IsEqualTo(targetId); + var newPane = (PaneNode)split.Children[1]; + await Assert.That(newPane.Tabs).IsEquivalentTo(new[] { "c" }); + await Assert.That(w.FocusedPaneId).IsEqualTo(newPane.Id); + await Assert.That(w.FindPane(targetId)!.Tabs).IsEquivalentTo(new[] { "a", "b" }); + } + + [Test] + public async Task SplitWithWindow_LeftEdge_PlacesNewPaneFirst_AsColumnForTopBottom() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + var targetId = w.FocusedPaneId; + + w.SplitWithWindow("b", targetId, Edge.Top); + + var split = (SplitNode)w.Root; + await Assert.That(split.Direction).IsEqualTo(SplitDirection.Column); + await Assert.That(((PaneNode)split.Children[0]).Tabs).IsEquivalentTo(new[] { "b" }); // Top → first + await Assert.That(((PaneNode)split.Children[1]).Id).IsEqualTo(targetId); + } + + [Test] + public async Task RemoveWindow_RemovesTab_AndFixesActiveIndex() + { + var w = new WorkspaceLayout(new[] { "a", "b", "c" }); + w.SetActiveTab(w.FocusedPaneId, "c"); + + var removed = w.RemoveWindow("a"); + + await Assert.That(removed).IsTrue(); + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "b", "c" }); + await Assert.That(w.FocusedPane.ActiveTab).IsEqualTo("c"); + } + + [Test] + public async Task RemoveWindow_Absent_ReturnsFalse() + { + var w = new WorkspaceLayout(new[] { "a" }); + await Assert.That(w.RemoveWindow("nope")).IsFalse(); + } + + [Test] + public async Task ToggleFreezeFocused_TogglesPaneFlag() + { + var w = new WorkspaceLayout(new[] { "a" }); + + w.ToggleFreezeFocused(); + await Assert.That(w.FocusedPane.Frozen).IsTrue(); + + w.ToggleFreezeFocused(); + await Assert.That(w.FocusedPane.Frozen).IsFalse(); + } + + [Test] + public async Task NestedSplits_PaneOrderIsStable() + { + var w = new WorkspaceLayout(new[] { "a", "b", "c", "d" }); + w.SplitFocused(SplitDirection.Row); // [a] | [b,c,d] + w.CycleFocus(); // focus the [b,c,d] pane + w.SplitFocused(SplitDirection.Column); // [b] / [c,d] + + await Assert.That(w.Panes).Count().IsEqualTo(3); + var tabs = w.Panes.Select(p => string.Join(",", p.Tabs)).ToArray(); + await Assert.That(tabs).IsEquivalentTo(new[] { "a", "b", "c,d" }); + } +} From af53d476c542870d8f86159bb4e086d6a7f8a0c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:33:50 +0000 Subject: [PATCH 06/68] Design Step 3 (Core): spawn routing + window state model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Workspace aggregate tying the pane tree to per-window state, so the design's tab strips / spawn routing / rail have a pure, tested core ahead of Terminal.Gui hosting: - WorkspaceWindow: per-window state (id, title, WindowKind, owning world.character, unread count, unsent-input marker) independent of pane placement. - Workspace: keeps the layout tree and the window registry consistent — OpenWindow places a tab, CloseWindow removes it (pruning empties), RouteSpawn finds-or-creates a background spawn window per SpawnTarget and accrues unread while it is not the visible tab, ActivateWindow makes it visible and clears unread. - WorkspaceLayout.AddWindow gains an `activate` flag so spawns open in the background. Renamed the namespace MuClient.Core.Workspace → .Workspaces so the Workspace type isn't shadowed by its own namespace leaf. 351 passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB --- CLAUDE.md | 2 +- README.md | 2 +- docs/PLAN.md | 9 +- src/MuClient.Core/Workspace/LayoutNode.cs | 2 +- src/MuClient.Core/Workspace/Workspace.cs | 133 ++++++++++++++++++ .../Workspace/WorkspaceLayout.cs | 16 ++- .../Workspace/WorkspaceWindow.cs | 48 +++++++ .../Workspace/WorkspaceLayoutTests.cs | 4 +- .../Workspace/WorkspaceTests.cs | 125 ++++++++++++++++ 9 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 src/MuClient.Core/Workspace/Workspace.cs create mode 100644 src/MuClient.Core/Workspace/WorkspaceWindow.cs create mode 100644 tests/MuClient.Core.Tests/Workspace/WorkspaceTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6cde76f..ad5ce64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ fallbacks) for inline images/maps. ## Repository state **M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all nine projects on -`net10.0`; the solution has **342 passing tests**. In place: +`net10.0`; the solution has **351 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), diff --git a/README.md b/README.md index 1533c55..8b17a67 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ client: rich truecolor text, inline graphics, powerful automation, and full MU\* > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (342 tests across the solution). See +> `MuClient.Core` is fully unit-tested (351 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI diff --git a/docs/PLAN.md b/docs/PLAN.md index 35dc67e..65f23b6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -123,10 +123,15 @@ in reviewable steps landing on `Core` first (pure + tested), then the Terminal.G - **Config schema** (`Core.Configuration`): worlds (servers) hold **characters**; automation lives in shared, named **trigger sets** that characters opt into. Sessions key on `world.character` and compose engines from the union of a character's sets. Versioned with `ConfigurationMigrator`. -- **Workspace model** (`Core.Workspace`): a pure `WorkspaceLayout` split tree — `PaneNode` +- **Workspace model** (`Core.Workspaces`): a pure `WorkspaceLayout` split tree — `PaneNode` (tab strip of window ids) / `SplitNode` (row/col) with focus, zoom, freeze, and the tmux-style split / close / cycle / move / reorder operations, maintaining the no-empty-pane / no-lone-split - invariants. The Terminal.Gui view hosting renders from this model (next). + invariants. +- **Windows & spawn routing** (`Core.Workspaces`): a `Workspace` aggregate ties the layout to a + registry of `WorkspaceWindow`s (title, kind, owning `world.character`, unread count, unsent-input + marker). `RouteSpawn` finds-or-creates a background spawn window per `TriggerEngine` `SpawnTarget` + and accrues unread while it is not the visible tab; activating a window clears it. The Terminal.Gui + view hosting (panes, dividers, tab strips, rail) renders from this model (next). ### Still open (M5+) - Dedicated **spawn windows** and **multiple input windows** (capture + routing hooks exist), diff --git a/src/MuClient.Core/Workspace/LayoutNode.cs b/src/MuClient.Core/Workspace/LayoutNode.cs index f40c2d7..e2e747f 100644 --- a/src/MuClient.Core/Workspace/LayoutNode.cs +++ b/src/MuClient.Core/Workspace/LayoutNode.cs @@ -1,4 +1,4 @@ -namespace MuClient.Core.Workspace; +namespace MuClient.Core.Workspaces; /// How a arranges its children. public enum SplitDirection diff --git a/src/MuClient.Core/Workspace/Workspace.cs b/src/MuClient.Core/Workspace/Workspace.cs new file mode 100644 index 0000000..05bc815 --- /dev/null +++ b/src/MuClient.Core/Workspace/Workspace.cs @@ -0,0 +1,133 @@ +namespace MuClient.Core.Workspaces; + +/// +/// The full workspace state the TUI shell drives: the pane tree plus +/// the registry of s the panes host. It keeps the two consistent — +/// opening a window places it in a pane, closing one removes its tab, spawn routing finds-or-creates +/// the destination window — and tracks activity badges (unread, unsent-input) against visibility. +/// UI-agnostic and fully testable; the Terminal.Gui layer renders from it and calls its operations. +/// +public sealed class Workspace +{ + private readonly Dictionary _windows = new(StringComparer.Ordinal); + + /// Creates a workspace with a single main window in one pane. + public Workspace(string mainWindowId = "main", string mainTitle = "Main", string? sessionKey = null) + { + ArgumentException.ThrowIfNullOrEmpty(mainWindowId); + Layout = new WorkspaceLayout(new[] { mainWindowId }); + var main = new WorkspaceWindow(mainWindowId, mainTitle, WindowKind.Main, sessionKey); + _windows[main.Id] = main; + } + + /// The pane tree. + public WorkspaceLayout Layout { get; } + + /// Every known window, in insertion order. + public IReadOnlyCollection Windows => _windows.Values; + + /// Looks up a window by id, or null. + public WorkspaceWindow? FindWindow(string id) => _windows.GetValueOrDefault(id); + + /// + /// Opens a window: registers it (if new) and places it as a tab. An existing id updates nothing + /// but is returned so callers can treat open as idempotent. Placement defaults to the focused + /// pane. Returns the window. + /// + public WorkspaceWindow OpenWindow( + string id, + string title, + WindowKind kind = WindowKind.Auxiliary, + string? sessionKey = null, + string? paneId = null) + { + ArgumentException.ThrowIfNullOrEmpty(id); + if (_windows.TryGetValue(id, out var existing)) + { + return existing; + } + + var window = new WorkspaceWindow(id, title, kind, sessionKey); + _windows[id] = window; + Layout.AddWindow(id, paneId); + return window; + } + + /// + /// Routes trigger-spawned output to a spawn window named , creating and + /// placing the window on first use, and counts the line as unread unless the window is currently + /// visible. Returns the destination window. + /// + public WorkspaceWindow RouteSpawn(string target, string? sessionKey = null) + { + ArgumentException.ThrowIfNullOrEmpty(target); + var id = SpawnWindowId(target); + if (!_windows.TryGetValue(id, out var window)) + { + window = new WorkspaceWindow(id, target, WindowKind.Spawn, sessionKey); + _windows[id] = window; + Layout.AddWindow(id, activate: false); // spawns open in the background and accrue unread + } + + NoteActivity(id); + return window; + } + + /// The window id a spawn routes to. + public static string SpawnWindowId(string target) => $"spawn:{target}"; + + /// + /// Records a line arriving in a window: increments its unread badge unless the window is visible + /// (the active tab of its host pane). Unknown ids are ignored. + /// + public void NoteActivity(string windowId) + { + if (_windows.TryGetValue(windowId, out var window) && !IsVisible(windowId)) + { + window.Unread++; + } + } + + /// + /// Makes a window the active tab of its pane, focuses that pane, and clears its unread badge. + /// Returns false if the window is not placed in any pane. + /// + public bool ActivateWindow(string windowId) + { + var pane = Layout.FindWindow(windowId); + if (pane is null || !_windows.ContainsKey(windowId)) + { + return false; + } + + Layout.SetActiveTab(pane.Id, windowId); + Layout.Focus(pane.Id); + _windows[windowId].Unread = 0; + return true; + } + + /// Sets the unsent-input marker for a window. Unknown ids are ignored. + public void SetUnsentInput(string windowId, bool hasUnsent) + { + if (_windows.TryGetValue(windowId, out var window)) + { + window.HasUnsentInput = hasUnsent; + } + } + + /// Closes a window: removes its tab (pruning empty panes) and forgets its state. + public bool CloseWindow(string windowId) + { + if (!_windows.Remove(windowId)) + { + return false; + } + + Layout.RemoveWindow(windowId); + return true; + } + + /// True when the window is the active tab of the pane that hosts it. + public bool IsVisible(string windowId) => + Layout.FindWindow(windowId) is { } pane && pane.ActiveTab == windowId; +} diff --git a/src/MuClient.Core/Workspace/WorkspaceLayout.cs b/src/MuClient.Core/Workspace/WorkspaceLayout.cs index 780dcd6..3efc399 100644 --- a/src/MuClient.Core/Workspace/WorkspaceLayout.cs +++ b/src/MuClient.Core/Workspace/WorkspaceLayout.cs @@ -1,4 +1,4 @@ -namespace MuClient.Core.Workspace; +namespace MuClient.Core.Workspaces; /// /// The pure, UI-agnostic model of a tmux-style pane workspace: a recursive split tree of panes, @@ -119,14 +119,22 @@ public void ToggleFreezeFocused() pane.Frozen = !pane.Frozen; } - /// Adds a window as a tab in a pane (the focused pane by default) and activates it. - public void AddWindow(string windowId, string? paneId = null) + /// + /// Adds a window as a tab in a pane (the focused pane by default). When + /// is true it becomes the pane's active tab; otherwise it is added in + /// the background (but an empty pane always activates its first tab). + /// + public void AddWindow(string windowId, string? paneId = null, bool activate = true) { ArgumentNullException.ThrowIfNull(windowId); DetachWindow(windowId); var pane = paneId is null ? FocusedPane : FindPane(paneId) ?? FocusedPane; pane.Tabs.Add(windowId); - pane.ActiveIndex = pane.Tabs.Count - 1; + if (activate || pane.ActiveIndex < 0) + { + pane.ActiveIndex = pane.Tabs.Count - 1; + } + PruneAndFix(); } diff --git a/src/MuClient.Core/Workspace/WorkspaceWindow.cs b/src/MuClient.Core/Workspace/WorkspaceWindow.cs new file mode 100644 index 0000000..80244b6 --- /dev/null +++ b/src/MuClient.Core/Workspace/WorkspaceWindow.cs @@ -0,0 +1,48 @@ +namespace MuClient.Core.Workspaces; + +/// What a window shows, used for rail grouping and routing rules. +public enum WindowKind +{ + /// A character's main output window. + Main, + + /// A spawn window fed by a trigger's SpawnTarget. + Spawn, + + /// An arbitrary auxiliary window (web view, map, notes…). + Auxiliary, +} + +/// +/// Per-window state independent of where the window sits in the pane tree: its identity, title, +/// owning session, activity badges (unread count, unsent-input marker), and kind. Placement lives +/// in ; this is the metadata the tab strip and rail render. +/// +public sealed class WorkspaceWindow +{ + public WorkspaceWindow(string id, string title, WindowKind kind = WindowKind.Main, string? sessionKey = null) + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + Title = title ?? throw new ArgumentNullException(nameof(title)); + Kind = kind; + SessionKey = sessionKey; + } + + /// Stable window identity, unique within a workspace and referenced by pane tabs. + public string Id { get; } + + /// Display title (rail entry, tab label). + public string Title { get; set; } + + /// What the window hosts. + public WindowKind Kind { get; } + + /// The world.character session this window belongs to, or null if unowned. + public string? SessionKey { get; } + + /// Unread lines accumulated while the window was not visible. + public int Unread { get; internal set; } + + /// Whether the window holds a typed-but-unsent input draft (the marker). + public bool HasUnsentInput { get; internal set; } +} diff --git a/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs b/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs index 6c9ebf7..ab3bb43 100644 --- a/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs +++ b/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs @@ -1,6 +1,6 @@ -using MuClient.Core.Workspace; +using MuClient.Core.Workspaces; -namespace MuClient.Core.Tests.Workspace; +namespace MuClient.Core.Tests.Workspaces; public class WorkspaceLayoutTests { diff --git a/tests/MuClient.Core.Tests/Workspace/WorkspaceTests.cs b/tests/MuClient.Core.Tests/Workspace/WorkspaceTests.cs new file mode 100644 index 0000000..f09e91d --- /dev/null +++ b/tests/MuClient.Core.Tests/Workspace/WorkspaceTests.cs @@ -0,0 +1,125 @@ +using MuClient.Core.Workspaces; + +namespace MuClient.Core.Tests.Workspaces; + +public class WorkspaceTests +{ + [Test] + public async Task New_HasVisibleMainWindow() + { + var w = new Workspace("main", "Main", sessionKey: "Server.Wizard"); + + await Assert.That(w.Windows).HasSingleItem(); + var main = w.FindWindow("main")!; + await Assert.That(main.Kind).IsEqualTo(WindowKind.Main); + await Assert.That(main.SessionKey).IsEqualTo("Server.Wizard"); + await Assert.That(w.IsVisible("main")).IsTrue(); + await Assert.That(main.Unread).IsEqualTo(0); + } + + [Test] + public async Task RouteSpawn_CreatesBackgroundWindow_AndAccruesUnread() + { + var w = new Workspace(); + + var chat = w.RouteSpawn("Chat"); + + await Assert.That(chat.Kind).IsEqualTo(WindowKind.Spawn); + await Assert.That(chat.Id).IsEqualTo(Workspace.SpawnWindowId("Chat")); + await Assert.That(w.IsVisible(chat.Id)).IsFalse(); // main stays the active tab + await Assert.That(chat.Unread).IsEqualTo(1); + + // Second route reuses the same window and keeps counting. + var again = w.RouteSpawn("Chat"); + await Assert.That(again).IsEqualTo(chat); + await Assert.That(w.Windows).Count().IsEqualTo(2); + await Assert.That(chat.Unread).IsEqualTo(2); + } + + [Test] + public async Task ActivateWindow_MakesVisible_AndClearsUnread() + { + var w = new Workspace(); + var chat = w.RouteSpawn("Chat"); + await Assert.That(chat.Unread).IsEqualTo(1); + + var ok = w.ActivateWindow(chat.Id); + + await Assert.That(ok).IsTrue(); + await Assert.That(w.IsVisible(chat.Id)).IsTrue(); + await Assert.That(chat.Unread).IsEqualTo(0); + // Activating a background tab hides the previously-active one. + await Assert.That(w.IsVisible("main")).IsFalse(); + } + + [Test] + public async Task NoteActivity_OnVisibleWindow_DoesNotIncrement() + { + var w = new Workspace(); + + w.NoteActivity("main"); // main is visible + + await Assert.That(w.FindWindow("main")!.Unread).IsEqualTo(0); + } + + [Test] + public async Task OpenWindow_IsIdempotent_AndActivatesByDefault() + { + var w = new Workspace(); + + var web = w.OpenWindow("web:1", "example.com", WindowKind.Auxiliary); + await Assert.That(w.IsVisible("web:1")).IsTrue(); + + var second = w.OpenWindow("web:1", "ignored"); + await Assert.That(second).IsEqualTo(web); + await Assert.That(web.Title).IsEqualTo("example.com"); + await Assert.That(w.Windows).Count().IsEqualTo(2); + } + + [Test] + public async Task SetUnsentInput_TogglesMarker() + { + var w = new Workspace(); + + w.SetUnsentInput("main", true); + await Assert.That(w.FindWindow("main")!.HasUnsentInput).IsTrue(); + + w.SetUnsentInput("main", false); + await Assert.That(w.FindWindow("main")!.HasUnsentInput).IsFalse(); + } + + [Test] + public async Task CloseWindow_RemovesFromRegistryAndLayout() + { + var w = new Workspace(); + w.RouteSpawn("Chat"); + var chatId = Workspace.SpawnWindowId("Chat"); + + var closed = w.CloseWindow(chatId); + + await Assert.That(closed).IsTrue(); + await Assert.That(w.FindWindow(chatId)).IsNull(); + await Assert.That(w.Layout.FindWindow(chatId)).IsNull(); + await Assert.That(w.Windows).HasSingleItem(); + } + + [Test] + public async Task CloseWindow_Unknown_ReturnsFalse() + { + var w = new Workspace(); + await Assert.That(w.CloseWindow("ghost")).IsFalse(); + } + + [Test] + public async Task RouteSpawn_WhileVisible_DoesNotAccrueUnread() + { + var w = new Workspace(); + var chat = w.RouteSpawn("Chat"); // background tab, unread = 1 + w.ActivateWindow(chat.Id); // now the visible active tab, unread cleared + + w.RouteSpawn("Chat"); // routed while visible + + await Assert.That(w.IsVisible(chat.Id)).IsTrue(); + await Assert.That(chat.Unread).IsEqualTo(0); + } +} From ff889ad5c299deb5baea0d8cd29772053dcf7c42 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:46:58 +0000 Subject: [PATCH 07/68] Address CodeRabbit review on the design stack - CharacterDefinition.Password is now [JsonIgnore]: never persisted to plaintext config.json (in-memory/session only; secure credential store is a follow-up). - ConfigurationMigrator: when a v1 world already has characters *and* legacy automation, wire the lifted trigger set into each existing character instead of orphaning it in an unreferenced set. - WorkspaceLayout.DetachWindow: decrement the active index only for a tab strictly before it, so removing the active tab keeps focus on the next tab (consistent regardless of position). - WorkspaceLayout.AddWindow returns bool and fails (no-op) on an unresolved paneId, matching MoveWindowToPane/SplitWithWindow. - WorkspaceLayout doc: note the lone empty-pane terminal state so consumers don't assume ActiveTab is always non-null. - README: drop the unsupported macOS packaging claim (workflow builds linux-x64 + win-x64 only). New tests cover the migration-with-existing-characters case, password non-persistence, and active-tab removal. 357 passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB --- CLAUDE.md | 2 +- README.md | 4 +- .../Configuration/CharacterDefinition.cs | 11 ++- .../Configuration/ConfigurationMigrator.cs | 21 ++++- .../Workspace/WorkspaceLayout.cs | 27 +++++-- .../Configuration/ConfigurationTests.cs | 80 +++++++++++++++++++ .../Workspace/WorkspaceLayoutTests.cs | 36 +++++++++ 7 files changed, 170 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ad5ce64..fc0e3a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ fallbacks) for inline images/maps. ## Repository state **M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all nine projects on -`net10.0`; the solution has **351 passing tests**. In place: +`net10.0`; the solution has **357 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), diff --git a/README.md b/README.md index 8b17a67..70c96e5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ client: rich truecolor text, inline graphics, powerful automation, and full MU\* > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (351 tests across the solution). See +> `MuClient.Core` is fully unit-tested (357 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI @@ -74,7 +74,7 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. follow an MXP/Pueblo/HTML link and read the page as styled, word-wrapped text with clickable links you can navigate in-pane. `` shows as a labelled link (graphics-terminal image rendering reuses the Kitty/Sixel/half-block pipeline). -- **Packaging** — self-contained single-file publishing for Linux/Windows/macOS (see +- **Packaging** — self-contained single-file publishing for Linux/Windows (see [`docs/PACKAGING.md`](docs/PACKAGING.md)); a tagged release workflow builds the binaries. ## Building diff --git a/src/MuClient.Core/Configuration/CharacterDefinition.cs b/src/MuClient.Core/Configuration/CharacterDefinition.cs index 9f3bd25..2d63217 100644 --- a/src/MuClient.Core/Configuration/CharacterDefinition.cs +++ b/src/MuClient.Core/Configuration/CharacterDefinition.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace MuClient.Core.Configuration; /// @@ -9,7 +11,14 @@ public sealed class CharacterDefinition { public string Name { get; set; } = "New Character"; - /// Login password. Should be keychain-backed by the host; avoid persisting in plain JSON. + /// + /// Login password, held in memory for the session only. It is deliberately never + /// serialized () so it can't leak into plaintext + /// config.json; a secure OS credential store (DPAPI/Keychain/libsecret) is the intended + /// backing and is a follow-up. Callers supply it per session, or embed it in + /// if they knowingly accept plaintext. + /// + [JsonIgnore] public string? Password { get; set; } /// The login line to send. Defaults to connect {Name} {Password} when null. diff --git a/src/MuClient.Core/Configuration/ConfigurationMigrator.cs b/src/MuClient.Core/Configuration/ConfigurationMigrator.cs index 1d4d462..49a9ee5 100644 --- a/src/MuClient.Core/Configuration/ConfigurationMigrator.cs +++ b/src/MuClient.Core/Configuration/ConfigurationMigrator.cs @@ -96,7 +96,26 @@ private static void MigrateWorld(JsonObject world, JsonArray triggerSets, HashSe }); } - if (world["characters"] is not JsonArray) + if (world["characters"] is JsonArray characters) + { + // Partially-migrated file: wire the new set into the existing characters so the + // freshly-lifted automation isn't orphaned in an unreferenced set. + if (setName is not null) + { + foreach (var characterNode in characters.OfType()) + { + if (characterNode["triggerSets"] is JsonArray existing) + { + existing.Add(setName); + } + else + { + characterNode["triggerSets"] = new JsonArray(setName); + } + } + } + } + else { var character = new JsonObject { ["name"] = worldName }; if (setName is not null) diff --git a/src/MuClient.Core/Workspace/WorkspaceLayout.cs b/src/MuClient.Core/Workspace/WorkspaceLayout.cs index 3efc399..b0911b3 100644 --- a/src/MuClient.Core/Workspace/WorkspaceLayout.cs +++ b/src/MuClient.Core/Workspace/WorkspaceLayout.cs @@ -3,8 +3,11 @@ namespace MuClient.Core.Workspaces; /// /// The pure, UI-agnostic model of a tmux-style pane workspace: a recursive split tree of panes, /// each hosting a tab strip of window ids, plus focus and zoom state. All mutation goes through -/// this type so the invariants — no empty panes, no single-child splits, always at least one pane, -/// a valid focus — hold after every operation. The Terminal.Gui layer renders from this model. +/// this type so the invariants — no single-child splits, always at least one pane, a valid focus — +/// hold after every operation. Empty panes are pruned, with one exception: closing the final +/// pane leaves a single empty pane (an ActiveTab of null) rather than no pane at all, +/// so consumers such as the Terminal.Gui renderer must not assume every pane has an active tab. +/// The Terminal.Gui layer renders from this model. /// public sealed class WorkspaceLayout { @@ -122,13 +125,20 @@ public void ToggleFreezeFocused() /// /// Adds a window as a tab in a pane (the focused pane by default). When /// is true it becomes the pane's active tab; otherwise it is added in - /// the background (but an empty pane always activates its first tab). + /// the background (but an empty pane always activates its first tab). Returns false without + /// changing anything if a non-null does not resolve, matching + /// / . /// - public void AddWindow(string windowId, string? paneId = null, bool activate = true) + public bool AddWindow(string windowId, string? paneId = null, bool activate = true) { ArgumentNullException.ThrowIfNull(windowId); + var pane = paneId is null ? FocusedPane : FindPane(paneId); + if (pane is null) + { + return false; + } + DetachWindow(windowId); - var pane = paneId is null ? FocusedPane : FindPane(paneId) ?? FocusedPane; pane.Tabs.Add(windowId); if (activate || pane.ActiveIndex < 0) { @@ -136,6 +146,7 @@ public void AddWindow(string windowId, string? paneId = null, bool activate = tr } PruneAndFix(); + return true; } /// Removes a window wherever it lives, pruning any pane it emptied. Returns false if absent. @@ -239,7 +250,11 @@ private bool DetachWindow(string windowId) } pane.Tabs.RemoveAt(index); - if (pane.ActiveIndex >= index && pane.ActiveIndex > 0) + + // Only a tab strictly before the active one shifts the active index down. Removing the + // active tab itself leaves the index pointing at what slid into its slot (the next tab); + // ClampActive handles removing the active tab at the tail. + if (pane.ActiveIndex > index) { pane.ActiveIndex--; } diff --git a/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs b/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs index b1c4b91..81afe5f 100644 --- a/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs +++ b/tests/MuClient.Core.Tests/Configuration/ConfigurationTests.cs @@ -135,6 +135,86 @@ public async Task Deserialize_V1Config_MigratesAutomationIntoTriggerSetsAndChara await Assert.That(set.Macros[0].Command).IsEqualTo("look"); } + [Test] + public async Task Deserialize_V1Config_WithExistingCharacters_WiresMigratedSetIntoEach() + { + // Partially-migrated file: characters already present *and* legacy automation still on the + // world. The lifted set must be referenced by every existing character, not orphaned. + const string v1 = """ + { + "version": 1, + "worlds": [ + { + "name": "Hybrid", + "host": "h", + "port": 1, + "characters": [ + { "name": "Alice", "triggerSets": ["Existing"] }, + { "name": "Bob" } + ], + "triggers": [ { "pattern": "waves" } ] + } + ] + } + """; + + var config = ConfigurationStore.Deserialize(v1); + + await Assert.That(config.TriggerSets).HasSingleItem(); + var setName = config.TriggerSets[0].Name; + await Assert.That(setName).IsEqualTo("Hybrid"); + + var world = config.Worlds[0]; + await Assert.That(world.Characters).Count().IsEqualTo(2); + await Assert.That(world.Characters[0].Name).IsEqualTo("Alice"); + await Assert.That(world.Characters[0].TriggerSets).IsEquivalentTo(new[] { "Existing", "Hybrid" }); + await Assert.That(world.Characters[1].TriggerSets).IsEquivalentTo(new[] { "Hybrid" }); + } + + [Test] + public async Task Deserialize_V1Config_WithExistingCharacters_NoAutomation_LeavesThemUntouched() + { + const string v1 = """ + { + "version": 1, + "worlds": [ + { + "name": "Clean", + "host": "h", + "port": 1, + "characters": [ { "name": "Alice", "triggerSets": ["Existing"] } ] + } + ] + } + """; + + var config = ConfigurationStore.Deserialize(v1); + + await Assert.That(config.TriggerSets).IsEmpty(); + await Assert.That(config.Worlds[0].Characters[0].TriggerSets).IsEquivalentTo(new[] { "Existing" }); + } + + [Test] + public async Task Password_IsNeverSerialized() + { + var config = new AppConfiguration + { + Worlds = + { + new WorldDefinition + { + Name = "W", + Characters = { new CharacterDefinition { Name = "Secret", Password = "swordfish" } }, + }, + }, + }; + + var json = ConfigurationStore.Serialize(config); + + await Assert.That(json).DoesNotContain("swordfish"); + await Assert.That(ConfigurationStore.Deserialize(json).Worlds[0].Characters[0].Password).IsNull(); + } + [Test] public async Task ColorConverter_RoundTripsAllKinds() { diff --git a/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs b/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs index ab3bb43..d222bd9 100644 --- a/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs +++ b/tests/MuClient.Core.Tests/Workspace/WorkspaceLayoutTests.cs @@ -214,6 +214,42 @@ public async Task RemoveWindow_Absent_ReturnsFalse() await Assert.That(w.RemoveWindow("nope")).IsFalse(); } + [Test] + public async Task RemoveWindow_ActiveTabFromMiddle_KeepsFocusOnNextTab() + { + var w = new WorkspaceLayout(new[] { "a", "b", "c" }); + w.SetActiveTab(w.FocusedPaneId, "b"); // active is the middle tab + + w.RemoveWindow("b"); + + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "a", "c" }); + await Assert.That(w.FocusedPane.ActiveTab).IsEqualTo("c"); // the tab that slid into b's slot + } + + [Test] + public async Task RemoveWindow_ActiveTabAtTail_FallsBackToPrevious() + { + var w = new WorkspaceLayout(new[] { "a", "b", "c" }); + w.SetActiveTab(w.FocusedPaneId, "c"); // active is the last tab + + w.RemoveWindow("c"); + + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "a", "b" }); + await Assert.That(w.FocusedPane.ActiveTab).IsEqualTo("b"); // clamped back to the new tail + } + + [Test] + public async Task AddWindow_UnresolvedPaneId_ReturnsFalse_AndDoesNotAdd() + { + var w = new WorkspaceLayout(new[] { "a" }); + + var ok = w.AddWindow("b", "no-such-pane"); + + await Assert.That(ok).IsFalse(); + await Assert.That(w.FindWindow("b")).IsNull(); + await Assert.That(w.FocusedPane.Tabs).IsEquivalentTo(new[] { "a" }); + } + [Test] public async Task ToggleFreezeFocused_TogglesPaneFlag() { From 9be475111ca3345e11e4c5171053cdff6abfb1b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:58:46 +0000 Subject: [PATCH 08/68] Design Step 2/3 (Core): pane geometry solver + prefix commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more pure, UI-free primitives the Terminal.Gui pane hosting needs, kept in Core so they're unit-tested before any view code: - LayoutSolver: turns the split tree + a bounds rect into per-pane PaneRects, reserving a 1-cell divider between siblings, honouring each split's fractional sizes (cumulative rounding so no cell drifts), and collapsing to the single zoomed pane when one is zoomed. Because geometry is derived from the bounds on every call, a terminal resize is just a re-solve with the new bounds — the view re-solves per draw. - PaneCommands: the tmux ⌃B keymap (| - z o x < >) → PaneCommand, plus an Apply that drives the WorkspaceLayout, keeping the keymap out of view code. 379 passing across the solution. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB --- CLAUDE.md | 2 +- README.md | 2 +- src/MuClient.Core/Workspace/LayoutSolver.cs | 98 ++++++++++++++++ src/MuClient.Core/Workspace/PaneCommands.cs | 81 ++++++++++++++ .../Workspace/LayoutSolverTests.cs | 105 ++++++++++++++++++ .../Workspace/PaneCommandsTests.cs | 92 +++++++++++++++ 6 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 src/MuClient.Core/Workspace/LayoutSolver.cs create mode 100644 src/MuClient.Core/Workspace/PaneCommands.cs create mode 100644 tests/MuClient.Core.Tests/Workspace/LayoutSolverTests.cs create mode 100644 tests/MuClient.Core.Tests/Workspace/PaneCommandsTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index fc0e3a8..ec915c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ fallbacks) for inline images/maps. ## Repository state **M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all nine projects on -`net10.0`; the solution has **357 passing tests**. In place: +`net10.0`; the solution has **379 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), diff --git a/README.md b/README.md index 70c96e5..8d24cba 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ client: rich truecolor text, inline graphics, powerful automation, and full MU\* > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (357 tests across the solution). See +> `MuClient.Core` is fully unit-tested (379 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI diff --git a/src/MuClient.Core/Workspace/LayoutSolver.cs b/src/MuClient.Core/Workspace/LayoutSolver.cs new file mode 100644 index 0000000..27fc4d9 --- /dev/null +++ b/src/MuClient.Core/Workspace/LayoutSolver.cs @@ -0,0 +1,98 @@ +namespace MuClient.Core.Workspaces; + +/// An integer cell rectangle in the terminal grid (top-left origin). +public readonly record struct PaneRect(int X, int Y, int Width, int Height) +{ + /// True when the rectangle has no area. + public bool IsEmpty => Width <= 0 || Height <= 0; +} + +/// +/// Turns a split tree into concrete per-pane rectangles for a given +/// bounding area, reserving one cell for the divider between siblings and honouring each split's +/// fractional . Pure and deterministic; the Terminal.Gui layer maps +/// the resulting s onto views. When a pane is zoomed it fills the whole area. +/// +public static class LayoutSolver +{ + /// Divider thickness, in cells, between two sibling panes. + public const int DividerThickness = 1; + + /// + /// Solves pane rectangles within . If + /// names a live pane, only that pane is returned, filling the whole area. Panes that collapse to + /// zero area under a tiny bounds are still included (as empty rects) so callers can decide. + /// + public static IReadOnlyDictionary Solve( + LayoutNode root, + PaneRect bounds, + string? zoomedPaneId = null) + { + ArgumentNullException.ThrowIfNull(root); + var result = new Dictionary(StringComparer.Ordinal); + + if (zoomedPaneId is not null && root.Panes().Any(p => p.Id == zoomedPaneId)) + { + result[zoomedPaneId] = bounds; + return result; + } + + Place(root, bounds, result); + return result; + } + + private static void Place(LayoutNode node, PaneRect rect, Dictionary result) + { + if (node is PaneNode pane) + { + result[pane.Id] = rect; + return; + } + + var split = (SplitNode)node; + var count = split.Children.Count; + var isRow = split.Direction == SplitDirection.Row; + + var span = isRow ? rect.Width : rect.Height; + var forChildren = Math.Max(0, span - (DividerThickness * (count - 1))); + var sizes = Distribute(forChildren, split.Sizes); + + var cursor = isRow ? rect.X : rect.Y; + for (var i = 0; i < count; i++) + { + var childRect = isRow + ? new PaneRect(cursor, rect.Y, sizes[i], rect.Height) + : new PaneRect(rect.X, cursor, rect.Width, sizes[i]); + Place(split.Children[i], childRect, result); + cursor += sizes[i] + DividerThickness; + } + } + + /// + /// Splits cells among as integers that sum + /// exactly to , using cumulative rounding so no cell is lost to drift. + /// + public static int[] Distribute(int total, IReadOnlyList fractions) + { + var result = new int[fractions.Count]; + if (total <= 0) + { + return result; + } + + double accumulated = 0; + var previousBoundary = 0; + for (var i = 0; i < fractions.Count; i++) + { + accumulated += fractions[i]; + var boundary = i == fractions.Count - 1 + ? total + : (int)Math.Round(accumulated * total, MidpointRounding.AwayFromZero); + boundary = Math.Clamp(boundary, previousBoundary, total); + result[i] = boundary - previousBoundary; + previousBoundary = boundary; + } + + return result; + } +} diff --git a/src/MuClient.Core/Workspace/PaneCommands.cs b/src/MuClient.Core/Workspace/PaneCommands.cs new file mode 100644 index 0000000..1a2245d --- /dev/null +++ b/src/MuClient.Core/Workspace/PaneCommands.cs @@ -0,0 +1,81 @@ +namespace MuClient.Core.Workspaces; + +/// A tmux-style pane command, triggered by a key after the ⌃B prefix. +public enum PaneCommand +{ + /// No command bound to the key. + None, + + /// Split the focused pane side-by-side (|), moving its non-active tabs right. + SplitRight, + + /// Split the focused pane stacked (-), moving its non-active tabs down. + SplitDown, + + /// Zoom / unzoom the focused pane (z). + Zoom, + + /// Cycle focus to the next pane (o). + CycleFocus, + + /// Close the focused pane (x). + ClosePane, + + /// Move the active tab one slot left (<). + ReorderTabLeft, + + /// Move the active tab one slot right (>). + ReorderTabRight, +} + +/// +/// Maps prefix keys to s and applies them to a . +/// Pure and UI-agnostic: the Terminal.Gui key handler resolves a key here, then applies the result, +/// keeping the tmux keymap out of the view code and unit-testable. +/// +public static class PaneCommands +{ + /// Resolves a prefix key to its command, or if unbound. + public static PaneCommand Resolve(char key) => key switch + { + '|' => PaneCommand.SplitRight, + '-' => PaneCommand.SplitDown, + 'z' or 'Z' => PaneCommand.Zoom, + 'o' or 'O' => PaneCommand.CycleFocus, + 'x' or 'X' => PaneCommand.ClosePane, + '<' => PaneCommand.ReorderTabLeft, + '>' => PaneCommand.ReorderTabRight, + _ => PaneCommand.None, + }; + + /// + /// Applies a command to the layout. Returns true if the layout changed (or a change was + /// attempted, e.g. cycle/close/zoom), false for and no-op splits. + /// + public static bool Apply(WorkspaceLayout layout, PaneCommand command) + { + ArgumentNullException.ThrowIfNull(layout); + switch (command) + { + case PaneCommand.SplitRight: + return layout.SplitFocused(SplitDirection.Row); + case PaneCommand.SplitDown: + return layout.SplitFocused(SplitDirection.Column); + case PaneCommand.Zoom: + layout.ToggleZoom(); + return true; + case PaneCommand.CycleFocus: + layout.CycleFocus(); + return true; + case PaneCommand.ClosePane: + layout.CloseFocused(); + return true; + case PaneCommand.ReorderTabLeft: + return layout.ReorderActiveTab(-1); + case PaneCommand.ReorderTabRight: + return layout.ReorderActiveTab(1); + default: + return false; + } + } +} diff --git a/tests/MuClient.Core.Tests/Workspace/LayoutSolverTests.cs b/tests/MuClient.Core.Tests/Workspace/LayoutSolverTests.cs new file mode 100644 index 0000000..03316c8 --- /dev/null +++ b/tests/MuClient.Core.Tests/Workspace/LayoutSolverTests.cs @@ -0,0 +1,105 @@ +using MuClient.Core.Workspaces; + +namespace MuClient.Core.Tests.Workspaces; + +public class LayoutSolverTests +{ + private static readonly PaneRect Screen = new(0, 0, 100, 40); + + [Test] + public async Task SinglePane_FillsBounds() + { + var w = new WorkspaceLayout(new[] { "a" }); + + var rects = LayoutSolver.Solve(w.Root, Screen); + + await Assert.That(rects).HasSingleItem(); + await Assert.That(rects[w.FocusedPaneId]).IsEqualTo(Screen); + } + + [Test] + public async Task RowSplit_SplitsWidth_ReservingADivider() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Row); + var ids = w.Panes.Select(p => p.Id).ToArray(); + + var rects = LayoutSolver.Solve(w.Root, Screen); + + var left = rects[ids[0]]; + var right = rects[ids[1]]; + // 100 cells − 1 divider = 99 split 50/50 → 50 and 49 (cumulative rounding). + await Assert.That(left).IsEqualTo(new PaneRect(0, 0, 50, 40)); + await Assert.That(right).IsEqualTo(new PaneRect(51, 0, 49, 40)); + // Full-height, and the gap between them is exactly the divider. + await Assert.That(left.X + left.Width).IsEqualTo(right.X - LayoutSolver.DividerThickness); + } + + [Test] + public async Task ColumnSplit_SplitsHeight_ReservingADivider() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Column); + var ids = w.Panes.Select(p => p.Id).ToArray(); + + var rects = LayoutSolver.Solve(w.Root, Screen); + + await Assert.That(rects[ids[0]]).IsEqualTo(new PaneRect(0, 0, 100, 20)); + await Assert.That(rects[ids[1]]).IsEqualTo(new PaneRect(0, 21, 100, 19)); + } + + [Test] + public async Task NestedSplit_TilesWithoutOverlap_AndCoversTheArea() + { + var w = new WorkspaceLayout(new[] { "a", "b", "c", "d" }); + w.SplitFocused(SplitDirection.Row); // [a] | [b,c,d] + w.CycleFocus(); + w.SplitFocused(SplitDirection.Column); // [b] / [c,d] + + var rects = LayoutSolver.Solve(w.Root, Screen); + + await Assert.That(rects).Count().IsEqualTo(3); + // No two pane rects overlap. + var list = rects.Values.ToArray(); + for (var i = 0; i < list.Length; i++) + { + for (var j = i + 1; j < list.Length; j++) + { + await Assert.That(Overlaps(list[i], list[j])).IsFalse(); + } + } + } + + [Test] + public async Task Zoom_FillsWholeArea_WithOnlyTheZoomedPane() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Row); + var zoomed = w.FocusedPaneId; + + var rects = LayoutSolver.Solve(w.Root, Screen, zoomed); + + await Assert.That(rects).HasSingleItem(); + await Assert.That(rects[zoomed]).IsEqualTo(Screen); + } + + [Test] + public async Task Distribute_SumsExactlyToTotal_EvenWithUnevenFractions() + { + var parts = LayoutSolver.Distribute(100, new[] { 0.3333, 0.3333, 0.3334 }); + + await Assert.That(parts.Sum()).IsEqualTo(100); + await Assert.That(parts.Length).IsEqualTo(3); + } + + [Test] + public async Task Distribute_ZeroOrNegativeTotal_YieldsZeros() + { + await Assert.That(LayoutSolver.Distribute(0, new[] { 0.5, 0.5 })).IsEquivalentTo(new[] { 0, 0 }); + await Assert.That(LayoutSolver.Distribute(-5, new[] { 0.5, 0.5 })).IsEquivalentTo(new[] { 0, 0 }); + } + + private static bool Overlaps(PaneRect a, PaneRect b) => + a.X < b.X + b.Width && b.X < a.X + a.Width && + a.Y < b.Y + b.Height && b.Y < a.Y + a.Height; +} diff --git a/tests/MuClient.Core.Tests/Workspace/PaneCommandsTests.cs b/tests/MuClient.Core.Tests/Workspace/PaneCommandsTests.cs new file mode 100644 index 0000000..397ff44 --- /dev/null +++ b/tests/MuClient.Core.Tests/Workspace/PaneCommandsTests.cs @@ -0,0 +1,92 @@ +using MuClient.Core.Workspaces; + +namespace MuClient.Core.Tests.Workspaces; + +public class PaneCommandsTests +{ + [Test] + [Arguments('|', PaneCommand.SplitRight)] + [Arguments('-', PaneCommand.SplitDown)] + [Arguments('z', PaneCommand.Zoom)] + [Arguments('o', PaneCommand.CycleFocus)] + [Arguments('x', PaneCommand.ClosePane)] + [Arguments('<', PaneCommand.ReorderTabLeft)] + [Arguments('>', PaneCommand.ReorderTabRight)] + [Arguments('q', PaneCommand.None)] + public async Task Resolve_MapsPrefixKeys(char key, PaneCommand expected) + { + await Assert.That(PaneCommands.Resolve(key)).IsEqualTo(expected); + } + + [Test] + public async Task Apply_SplitRight_SplitsFocusedPane() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + + var changed = PaneCommands.Apply(layout, PaneCommand.SplitRight); + + await Assert.That(changed).IsTrue(); + await Assert.That(layout.Root).IsTypeOf(); + await Assert.That(((SplitNode)layout.Root).Direction).IsEqualTo(SplitDirection.Row); + } + + [Test] + public async Task Apply_SplitDown_UsesColumnDirection() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + + PaneCommands.Apply(layout, PaneCommand.SplitDown); + + await Assert.That(((SplitNode)layout.Root).Direction).IsEqualTo(SplitDirection.Column); + } + + [Test] + public async Task Apply_Zoom_TogglesZoomOnFocusedPane() + { + var layout = new WorkspaceLayout(new[] { "a" }); + + PaneCommands.Apply(layout, PaneCommand.Zoom); + + await Assert.That(layout.ZoomedPaneId).IsEqualTo(layout.FocusedPaneId); + } + + [Test] + public async Task Apply_CycleFocus_MovesFocus() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + var before = layout.FocusedPaneId; + + PaneCommands.Apply(layout, PaneCommand.CycleFocus); + + await Assert.That(layout.FocusedPaneId).IsNotEqualTo(before); + } + + [Test] + public async Task Apply_ReorderTabRight_MovesActiveTab() + { + var layout = new WorkspaceLayout(new[] { "a", "b", "c" }); // active "a" at index 0 + + var changed = PaneCommands.Apply(layout, PaneCommand.ReorderTabRight); + + await Assert.That(changed).IsTrue(); + await Assert.That(layout.FocusedPane.Tabs).IsEquivalentTo(new[] { "b", "a", "c" }); + await Assert.That(layout.FocusedPane.ActiveTab).IsEqualTo("a"); + } + + [Test] + public async Task Apply_None_IsNoOp() + { + var layout = new WorkspaceLayout(new[] { "a" }); + + await Assert.That(PaneCommands.Apply(layout, PaneCommand.None)).IsFalse(); + } + + [Test] + public async Task Apply_SplitRight_WithSingleTab_ReturnsFalse() + { + var layout = new WorkspaceLayout(new[] { "only" }); + + await Assert.That(PaneCommands.Apply(layout, PaneCommand.SplitRight)).IsFalse(); + } +} From 12d5588e896e6af75dd8c02f6fd3b2a31d43cb08 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 02:24:05 +0000 Subject: [PATCH 09/68] Switch TUI from Terminal.Gui v2 to SharpConsoleUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the prerelease Terminal.Gui v2 base (whose Application API was [Obsolete] mid-migration and which had no native inline-graphics) with SharpConsoleUI — a stable, net10-targeting compositor framework that natively provides split layouts, tabs, resizable/mouse windows, Spectre-style markup, and the Kitty graphics protocol. The switch is contained to MuClient.Tui because MuClient.Core is UI-agnostic; Core/Scripting/Web/Graphics are untouched. - MuGlyphApp rebuilt on ConsoleWindowSystem + WindowBuilder/Controls: a MarkupControl output pane, a PromptControl command input, a status line, Ctrl+Q quit, background events marshalled via EnqueueOnUIThread, and NAWS re-advertised on window resize. - MarkupFormatter converts the UI-agnostic StyledLine model to Spectre-style markup: truecolor fg/bg via the theme, bold/italic/ underline/etc., and clickable MXP/Pueblo/web spans as [link=…] (custom mux:send:/mux:prompt: schemes dispatched on LinkClicked). Bracket escaping included. - The web view opens as a second window rendering the page as markup. - Removed the Terminal.Gui views (OutputView/CommandInput/WebView/ ColorMapper); swapped the package reference. - New MuClient.Tui.Tests (TUnit) covers MarkupFormatter; wired into CI. 387 passing across the solution (Tui build-verified; visual pass on a real terminal, per project norms). Docs (README/CLAUDE/PLAN) updated to record SharpConsoleUI as the TUI base. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015w2WJ5M22CZmg9md67zpeB --- .github/workflows/ci.yml | 4 + CLAUDE.md | 39 ++- Directory.Packages.props | 4 +- MuGlyph.slnx | 1 + README.md | 17 +- docs/PLAN.md | 11 +- src/MuClient.Tui/ColorMapper.cs | 38 --- src/MuClient.Tui/MarkupFormatter.cs | 138 ++++++++++ src/MuClient.Tui/MuClient.Tui.csproj | 12 +- src/MuClient.Tui/MuGlyphApp.cs | 259 +++++++++--------- src/MuClient.Tui/Program.cs | 18 +- src/MuClient.Tui/Views/CommandInput.cs | 111 -------- src/MuClient.Tui/Views/OutputView.cs | 215 --------------- src/MuClient.Tui/Views/WebView.cs | 145 ---------- .../MarkupFormatterTests.cs | 93 +++++++ .../MuClient.Tui.Tests.csproj | 19 ++ 16 files changed, 428 insertions(+), 696 deletions(-) delete mode 100644 src/MuClient.Tui/ColorMapper.cs create mode 100644 src/MuClient.Tui/MarkupFormatter.cs delete mode 100644 src/MuClient.Tui/Views/CommandInput.cs delete mode 100644 src/MuClient.Tui/Views/OutputView.cs delete mode 100644 src/MuClient.Tui/Views/WebView.cs create mode 100644 tests/MuClient.Tui.Tests/MarkupFormatterTests.cs create mode 100644 tests/MuClient.Tui.Tests/MuClient.Tui.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 522b7f0..5deea0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,7 @@ jobs: - name: Test — Web shell: bash run: dotnet run -c Release --no-build --project tests/MuClient.Web.Tests/MuClient.Web.Tests.csproj + + - name: Test — Tui + shell: bash + run: dotnet run -c Release --no-build --project tests/MuClient.Tui.Tests/MuClient.Tui.Tests.csproj diff --git a/CLAUDE.md b/CLAUDE.md index ec915c8..5b411be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,11 @@ fallbacks) for inline images/maps. ## Locked decisions (do not relitigate without asking) - **Target framework:** `net10.0`. -- **TUI base:** Terminal.Gui **v2** (prerelease) for windows/input/layout/scrollback, plus a - custom placeholder-based `GraphicsView` for images. +- **TUI base:** **SharpConsoleUI** (`nickprotop/ConsoleEx`, stable, net8/9/10) — a compositor-based + framework with split layouts, tabs, resizable/mouse-draggable windows, Spectre-style markup, and + a **native Kitty graphics protocol** (+ Sixel/half-block) for inline images. Replaced Terminal.Gui + v2 (which was prerelease with an `[Obsolete]` mid-migration API); the switch is contained to + `MuClient.Tui` because `MuClient.Core` is UI-agnostic. - **Scripting:** Lua via **MoonSharp** (pure-managed, sandboxed). - **Inline graphics:** in scope from day one (Kitty Unicode placeholders → Sixel → half-block). - **Protocols:** aim for all common MU\* protocols. GMCP/MSSP/CHARSET/NAWS/MTTS/EOR via @@ -28,8 +31,8 @@ fallbacks) for inline images/maps. ## Repository state -**M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all nine projects on -`net10.0`; the solution has **379 passing tests**. In place: +**M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all ten projects on +`net10.0`; the solution has **387 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), @@ -39,7 +42,10 @@ fallbacks) for inline images/maps. - **Graphics** — Kitty encoder + Unicode placeholders, Sixel + half-block fallbacks, capability probe (no UI dependency). - **Scripting** — sandboxed MoonSharp `ScriptHost` (world/output/trigger/alias/timer/gmcp/log). -- **Tui** — Terminal.Gui v2 app (truecolor `OutputView`, `CommandInput`, theming, key routing). +- **Tui** — **SharpConsoleUI** app: a `MarkupControl` output pane (StyledLine → Spectre-style markup + via `MarkupFormatter`, with clickable `[link=…]` MXP/Pueblo/web spans), a `PromptControl` input, + status line, theming, `Ctrl+Q` quit, and NAWS-on-resize. Multi-pane workspace (splits/tabs, driven + by `Core.Workspaces`) layers on top next. ### Notes for future agents (learned the hard way) - **.NET 10 SDK**: install via `apt-get install -y dotnet-sdk-10.0` (the Microsoft CDN is often @@ -51,15 +57,21 @@ fallbacks) for inline images/maps. provides MCCP/MSDP/MXP negotiation itself. `TelnetSession` sets the init-only `CallbackOnByteAsync` reflectively to get raw data bytes (incl. unterminated prompts) — a first-class `OnByte` builder hook is a good upstream PR. -- **Terminal.Gui v2** (2.4.x-develop) dropped `Toplevel`/`TabView`; use `IRunnable`/`Window` and - override `OnDrawingContent(DrawContext)`. The static `Application` API is `[Obsolete]` mid-migration - (suppressed via `NoWarn` in the Tui project). +- **SharpConsoleUI** (package `SharpConsoleUI`, repo `nickprotop/ConsoleEx`): app is + `ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer), new ConsoleWindowSystemOptions())`; + build windows/controls with the fluent `WindowBuilder`/`Controls` factories; `AddControl` is + builder-time (keep control refs and mutate at runtime). Marshal background work with + `system.EnqueueOnUIThread`; global keys via `RegisterGlobalShortcut`; `system.Run()` blocks the + loop, `RequestExit(code)` ends it. Text is Spectre-style markup (`[bold #rrggbb on #rrggbb]…[/]`, + `[[`/`]]` escaping, `[link=url]…[/]` → `MarkupControl.LinkClicked`). A **headless** sandbox can't + run `NetConsoleDriver` (no console) — the Tui is build-verified + unit-tested (`MarkupFormatter`); + visual verification is on the maintainer's machine. ## Architecture rule (non-negotiable) `MuClient.Core` stays **UI-agnostic and fully unit-testable**. All transport, telnet, parsing (ANSI/MXP/Pueblo), GMCP/MSDP routing, scrollback, and trigger/alias/macro engines live there. -Terminal.Gui is referenced **only** from `MuClient.Tui`. +SharpConsoleUI is referenced **only** from `MuClient.Tui`. Planned solution layout: @@ -68,8 +80,8 @@ Planned solution layout: | `MuClient.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, engines, logging (no UI deps) | | `MuClient.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | | `MuClient.Scripting` | MoonSharp host + scripting API | -| `MuClient.Tui` | Terminal.Gui v2 application | -| `MuClient.Core.Tests`, `MuClient.Graphics.Tests` | xUnit | +| `MuClient.Tui` | SharpConsoleUI application | +| `*.Tests` (Core, Graphics, Scripting, Web, Tui) | TUnit | ## Milestone M1 — first task @@ -83,8 +95,9 @@ Planned solution layout: ## Dependency notes / traps - **.NET 10 SDK** may need installing in the sandbox (currently RC, e.g. `10.0.100-rc.1`). -- **Terminal.Gui v2 is a prerelease** — add with `dotnet add package Terminal.Gui --prerelease`. - v1 (stable) has a completely different API; do not use it. +- **SharpConsoleUI** — stable release, multi-targets net8/9/10; MIT. Provides split layouts, tabs, + resizable/mouse windows, and native Kitty graphics, so the multi-pane workspace and inline images + ride on the framework rather than being hand-drawn. - **TelnetNegotiationCore 1.0.0** provides negotiation only (TELOPT, GA, TTYPE/MTTS, EOR, NAWS, CHARSET, MSSP, GMCP). It does **not** provide MCCP, MSDP, MXP, Pueblo, or ANSI parsing — those are our layer. Do not assume APIs for them exist. (Note: the repo owner authored this library, diff --git a/Directory.Packages.props b/Directory.Packages.props index 15125b3..b16544c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ - - + + diff --git a/MuGlyph.slnx b/MuGlyph.slnx index a8fe2b3..a0625cc 100644 --- a/MuGlyph.slnx +++ b/MuGlyph.slnx @@ -10,6 +10,7 @@ + diff --git a/README.md b/README.md index 8d24cba..43cafdb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ client: rich truecolor text, inline graphics, powerful automation, and full MU\* > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (379 tests across the solution). See +> `MuClient.Core` is fully unit-tested (387 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI @@ -21,7 +21,7 @@ fallbacks) for inline images and maps. ## Tech stack - **.NET 10** / C# -- **[Terminal.Gui v2](https://github.com/gui-cs/Terminal.Gui)** — windows, tabs, panes, input, scrollback +- **[SharpConsoleUI](https://github.com/nickprotop/ConsoleEx)** — compositor-based TUI framework: split layouts, tabs, resizable/mouse windows, Spectre-style markup, and a native Kitty graphics protocol (+ Sixel/half-block) - **[TelnetNegotiationCore](https://www.nuget.org/packages/TelnetNegotiationCore/)** — telnet option negotiation (NAWS, MTTS, CHARSET, EOR/GA, MSSP, GMCP) - **[MoonSharp](https://www.moonsharp.org/)** — embedded, sandboxed Lua scripting - Custom app-layer parsers for **ANSI** (256 + 24-bit), **MXP**, and **Pueblo** @@ -39,7 +39,7 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. | `MuClient.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, trigger/alias/macro engines, logging (UI-agnostic) | | `MuClient.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | | `MuClient.Scripting` | MoonSharp host + scripting API | -| `MuClient.Tui` | Terminal.Gui v2 application (windows, panes, settings, wiring) | +| `MuClient.Tui` | SharpConsoleUI application (windows, panes, settings, wiring) | | `*.Tests` | TUnit test projects | ## What works today @@ -67,9 +67,11 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. `timer`/`gmcp`/`log`, with hot-reload. - **Theming** — yazi-style named themes (Dark / Light / Solarized Dark) with a 16-colour palette override and semantic UI colours, serialised to the config as hex. -- **TUI** — a Terminal.Gui v2 app: truecolor output pane with wrapping/scrollback, clickable - MXP/Pueblo links, command input with history and tab-completion, a GMCP-driven stat line, and - key routing. +- **TUI** — a [SharpConsoleUI](https://github.com/nickprotop/ConsoleEx) app: truecolor markup output + pane with clickable MXP/Pueblo/web links (styled spans → Spectre-style markup), a command prompt + with history, a GMCP-driven status line, `Ctrl+Q` quit, and NAWS re-advertised on terminal resize. + The compositor gives split layouts, tabs, resizable/mouse windows, and native inline images for the + multi-pane workspace (layered on the `Core.Workspaces` model) as it lands. - **Web view** — an in-TUI text-mode browser (`MuClient.Web`, AngleSharp): fetch a URL or follow an MXP/Pueblo/HTML link and read the page as styled, word-wrapped text with clickable links you can navigate in-pane. `` shows as a labelled link (graphics-terminal image @@ -92,7 +94,7 @@ dotnet run --project src/MuClient.Tui -- [--tls] [--insecure] [--n muglyph --help # once published ``` -In-app: **PgUp/PgDn** scroll · **Up/Down** input history · **Tab** complete · **Ctrl+Q** quit. +In-app: **Up/Down** input history · **Ctrl+Q** quit. (The window and its panes are mouse-resizable.) ## Testing @@ -104,6 +106,7 @@ dotnet run --project tests/MuClient.Core.Tests dotnet run --project tests/MuClient.Graphics.Tests dotnet run --project tests/MuClient.Scripting.Tests dotnet run --project tests/MuClient.Web.Tests +dotnet run --project tests/MuClient.Tui.Tests ``` ## License diff --git a/docs/PLAN.md b/docs/PLAN.md index 65f23b6..ec84174 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -7,7 +7,7 @@ BeipMU is the best-in-class **Windows-only** MU\* (MUSH/MUCK/MUD) client, but it Key reframing from research: **"GPU-enabled" is a property of the terminal emulator, not our app.** Any TUI running inside Kitty/WezTerm/Ghostty gets GPU-accelerated glyph rendering for free. Our job is to (a) emit rich truecolor/styled text and (b) use the **Kitty graphics protocol** (escape sequences) for inline images/maps, with graceful fallbacks. Both are fully achievable from managed C#. ### Locked decisions (from planning Q&A) -- **Rendering base:** Terminal.Gui v2 for windows/input/layout/scrollback + a custom placeholder-based `GraphicsView` for images. +- **Rendering base:** **SharpConsoleUI** (`nickprotop/ConsoleEx`, stable, net8/9/10) — a compositor-based framework with split layouts, tabs, resizable/mouse windows, Spectre-style markup, and a **native Kitty graphics protocol** (+ Sixel/half-block). Superseded the original Terminal.Gui v2 choice (which was prerelease with an `[Obsolete]` mid-migration API); the switch was contained to `MuClient.Tui` since `MuClient.Core` is UI-agnostic. References below that describe Terminal.Gui reflect the earlier plan. - **Scripting:** Lua via **MoonSharp** (pure-managed, no native deps). - **Inline graphics:** must-have from day one. - **Scope:** broad BeipMU parity (phased into milestones below). @@ -54,7 +54,7 @@ Layered, with a strict separation between **protocol/session** (headless, unit-t - `MuClient.Core` — transport, telnet, ANSI/MXP parsers, GMCP/MSDP routing, scrollback model, trigger/alias/macro engines, logging. **No UI deps.** - `MuClient.Scripting` — MoonSharp host + the scripting API surface (world, output, triggers, timers, gmcp). - `MuClient.Graphics` — Kitty graphics protocol encoder, capability probe, Sixel + half-block fallbacks, `GraphicsView`. -- `MuClient.Tui` — Terminal.Gui v2 app: windows, panes, key routing, settings UI, wiring. +- `MuClient.Tui` — SharpConsoleUI app: windows, panes, key routing, settings UI, wiring. - `MuClient.Core.Tests` / `MuClient.Graphics.Tests` — xUnit. - Target **.NET 10** (confirm TelnetNegotiationCore + Terminal.Gui v2 support net10.0; if a dep lags, reference it via `net8.0` compat and keep our own projects on net10.0). @@ -119,7 +119,7 @@ MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscri ### M5 UI design (in progress) Implementing the multi-pane workspace design (tmux-style pane tree hosting BeipMU-style windows), -in reviewable steps landing on `Core` first (pure + tested), then the Terminal.Gui shell: +in reviewable steps landing on `Core` first (pure + tested), then the SharpConsoleUI shell: - **Config schema** (`Core.Configuration`): worlds (servers) hold **characters**; automation lives in shared, named **trigger sets** that characters opt into. Sessions key on `world.character` and compose engines from the union of a character's sets. Versioned with `ConfigurationMigrator`. @@ -130,8 +130,9 @@ in reviewable steps landing on `Core` first (pure + tested), then the Terminal.G - **Windows & spawn routing** (`Core.Workspaces`): a `Workspace` aggregate ties the layout to a registry of `WorkspaceWindow`s (title, kind, owning `world.character`, unread count, unsent-input marker). `RouteSpawn` finds-or-creates a background spawn window per `TriggerEngine` `SpawnTarget` - and accrues unread while it is not the visible tab; activating a window clears it. The Terminal.Gui - view hosting (panes, dividers, tab strips, rail) renders from this model (next). + and accrues unread while it is not the visible tab; activating a window clears it. The SharpConsoleUI + view hosting (splits, tabs, rail) renders from this model — the base shell (markup output pane with + clickable links, prompt input, status, NAWS-on-resize) is in place; splits/tabs/rail layer on next. ### Still open (M5+) - Dedicated **spawn windows** and **multiple input windows** (capture + routing hooks exist), diff --git a/src/MuClient.Tui/ColorMapper.cs b/src/MuClient.Tui/ColorMapper.cs deleted file mode 100644 index 24a276d..0000000 --- a/src/MuClient.Tui/ColorMapper.cs +++ /dev/null @@ -1,38 +0,0 @@ -using MuClient.Core.Text; -using MuClient.Core.Theming; -using TgAttribute = Terminal.Gui.Drawing.Attribute; -using TgColor = Terminal.Gui.Drawing.Color; - -namespace MuClient.Tui; - -/// Maps MuGlyph's UI-agnostic to Terminal.Gui attributes via a . -internal sealed class ColorMapper(Theme theme) -{ - private readonly Theme _theme = theme; - - public Theme Theme => _theme; - - public TgAttribute ToAttribute(TextStyle style) - { - var reverse = style.HasAttribute(TextAttributes.Reverse); - var fg = _theme.Resolve(style.Foreground, isBackground: false); - var bg = _theme.Resolve(style.Background, isBackground: true); - - // Bold on a base palette colour brightens it, matching common terminal behaviour. - if (style.HasAttribute(TextAttributes.Bold) && - style.Foreground.Kind == TerminalColorKind.Indexed && - style.Foreground.Index < 8) - { - fg = _theme.ResolveIndex(style.Foreground.Index + 8); - } - - if (reverse) - { - (fg, bg) = (bg, fg); - } - - return new TgAttribute(ToColor(fg), ToColor(bg)); - } - - public static TgColor ToColor(Rgb rgb) => new(rgb.R, rgb.G, rgb.B, 255); -} diff --git a/src/MuClient.Tui/MarkupFormatter.cs b/src/MuClient.Tui/MarkupFormatter.cs new file mode 100644 index 0000000..a6ca913 --- /dev/null +++ b/src/MuClient.Tui/MarkupFormatter.cs @@ -0,0 +1,138 @@ +using System.Text; +using MuClient.Core.Text; +using MuClient.Core.Theming; + +namespace MuClient.Tui; + +/// +/// Converts MuGlyph's UI-agnostic model into SharpConsoleUI (Spectre-style) +/// markup: truecolor foreground/background, bold/italic/underline/etc., and clickable +/// s rendered as [link=…] spans. Colours are resolved through the +/// active so palette-indexed and default colours land on real RGB values. +/// +internal sealed class MarkupFormatter(Theme theme) +{ + // Custom link schemes so LinkClicked can tell an MXP/Pueblo command from a web hyperlink. + public const string SendScheme = "mux:send:"; + public const string PromptScheme = "mux:prompt:"; + + private readonly Theme _theme = theme; + + /// Renders a whole line to a single markup string. + public string ToMarkup(StyledLine line) + { + ArgumentNullException.ThrowIfNull(line); + var sb = new StringBuilder(); + foreach (var span in line.Spans) + { + AppendSpan(sb, span); + } + + return sb.ToString(); + } + + private void AppendSpan(StringBuilder sb, StyledSpan span) + { + if (span.Text.Length == 0) + { + return; + } + + var link = LinkFor(span.Interaction); + if (link is not null) + { + sb.Append("[link=").Append(link).Append(']'); + } + + var styleTag = StyleTag(span.Style); + if (styleTag is not null) + { + sb.Append(styleTag); + } + + sb.Append(Escape(span.Text)); + + if (styleTag is not null) + { + sb.Append("[/]"); + } + + if (link is not null) + { + sb.Append("[/]"); + } + } + + /// Builds a markup open tag (e.g. [bold #ffcc00 on #202020]), or null if unstyled. + private string? StyleTag(TextStyle style) + { + var reverse = style.HasAttribute(TextAttributes.Reverse); + var fg = _theme.Resolve(style.Foreground, isBackground: false); + var bg = _theme.Resolve(style.Background, isBackground: true); + + // Bold on a base (0–7) palette colour brightens it, matching common terminal behaviour. + if (style.HasAttribute(TextAttributes.Bold) && + style.Foreground.Kind == TerminalColorKind.Indexed && + style.Foreground.Index < 8) + { + fg = _theme.ResolveIndex(style.Foreground.Index + 8); + } + + if (reverse) + { + (fg, bg) = (bg, fg); + } + + var tokens = new List(6); + if (style.HasAttribute(TextAttributes.Bold)) + { + tokens.Add("bold"); + } + + if (style.HasAttribute(TextAttributes.Faint)) + { + tokens.Add("dim"); + } + + if (style.HasAttribute(TextAttributes.Italic)) + { + tokens.Add("italic"); + } + + if (style.HasAttribute(TextAttributes.Underline)) + { + tokens.Add("underline"); + } + + if (style.HasAttribute(TextAttributes.Strikethrough)) + { + tokens.Add("strikethrough"); + } + + tokens.Add(Hex(fg)); + + // Only paint a background when one is actually set (or reverse swapped one in), so the + // window background shows through normal text. + if (reverse || style.Background.Kind != TerminalColorKind.Default) + { + tokens.Add("on " + Hex(bg)); + } + + return tokens.Count == 0 ? null : $"[{string.Join(' ', tokens)}]"; + } + + private static string? LinkFor(SpanInteraction? interaction) => interaction?.Kind switch + { + InteractionKind.SendCommand when interaction.PromptOnly => + PromptScheme + Uri.EscapeDataString(interaction.Target), + InteractionKind.SendCommand => + SendScheme + Uri.EscapeDataString(interaction.Target), + InteractionKind.Hyperlink => interaction.Target, + _ => null, + }; + + private static string Hex(Rgb rgb) => $"#{rgb.R:x2}{rgb.G:x2}{rgb.B:x2}"; + + /// Escapes markup metacharacters so literal text can't be parsed as tags. + private static string Escape(string text) => text.Replace("[", "[[").Replace("]", "]]"); +} diff --git a/src/MuClient.Tui/MuClient.Tui.csproj b/src/MuClient.Tui/MuClient.Tui.csproj index d8ef0f0..ebd8a9e 100644 --- a/src/MuClient.Tui/MuClient.Tui.csproj +++ b/src/MuClient.Tui/MuClient.Tui.csproj @@ -4,12 +4,6 @@ Exe MuClient.Tui muglyph - - $(NoWarn);CS0618 + net10.0 latest @@ -11,10 +11,10 @@ true true Harry Cordewener - MuGlyph + SharpMUTerm Copyright (c) 2026 Harry Cordewener MIT - https://github.com/HarryCordewener/MuGlyph + https://github.com/SharpMUSH/SharpMUTerm diff --git a/README.md b/README.md index ee9392d..8966d52 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,25 @@ -# MuGlyph +# SharpMUTerm A hyper-modern, cross-platform **TUI client for MU\*** (MUSH / MUCK / MUD) worlds, built for GPU-accelerated terminals (Kitty, WezTerm, Ghostty) on **Windows and Linux**. +Part of the [SharpMUSH](https://github.com/SharpMUSH) family: **SharpMUSH** is the server, +[**SharpClient**](https://github.com/SharpMUSH/SharpClient) is the graphical client, and +**SharpMUTerm** is the terminal one. It speaks plain telnet, so it connects to any MU\* world — +not just SharpMUSH ones. + The goal is [BeipMU](https://beipdev.github.io/BeipMU/)-class feature parity in a terminal-native client: rich truecolor text, inline graphics, powerful automation, and full MU\* protocol support. > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (391 tests across the solution). See +> `SharpMUTerm.Core` is fully unit-tested (514 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI "GPU acceleration" is a property of the terminal *emulator*, not the app. Any TUI running inside -Kitty / WezTerm / Ghostty gets GPU-accelerated glyph rendering for free. MuGlyph focuses on emitting +Kitty / WezTerm / Ghostty gets GPU-accelerated glyph rendering for free. SharpMUTerm focuses on emitting rich truecolor/styled text and using the **Kitty graphics protocol** (with Sixel and half-block fallbacks) for inline images and maps. @@ -36,10 +41,10 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. | Project | Responsibility | |---|---| -| `MuClient.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, trigger/alias/macro engines, logging (UI-agnostic) | -| `MuClient.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | -| `MuClient.Scripting` | MoonSharp host + scripting API | -| `MuClient.Tui` | SharpConsoleUI application (windows, panes, settings, wiring) | +| `SharpMUTerm.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, trigger/alias/macro engines, logging (UI-agnostic) | +| `SharpMUTerm.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | +| `SharpMUTerm.Scripting` | MoonSharp host + scripting API | +| `SharpMUTerm.Tui` | SharpConsoleUI application (windows, panes, settings, wiring) | | `*.Tests` | TUnit test projects | ## What works today @@ -75,7 +80,7 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. character-bound input prompt + destination/draft gutter. Output is truecolor markup with clickable MXP/Pueblo/web spans; NAWS is re-advertised on resize. `Ctrl+N` next window · `Ctrl+O` next pane · `Ctrl+W` close · `Ctrl+Q` quit. -- **Web view** — an in-TUI text-mode browser (`MuClient.Web`, AngleSharp): fetch a URL or +- **Web view** — an in-TUI text-mode browser (`SharpMUTerm.Web`, AngleSharp): fetch a URL or follow an MXP/Pueblo/HTML link and read the page as styled, word-wrapped text with clickable links you can navigate in-pane. `` shows as a labelled link (graphics-terminal image rendering reuses the Kitty/Sixel/half-block pipeline). @@ -87,14 +92,14 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. Requires the **.NET 10 SDK**. ```bash -dotnet build MuGlyph.slnx -c Release +dotnet build SharpMUTerm.slnx -c Release ``` ## Running ```bash -dotnet run --project src/MuClient.Tui -- [--tls] [--insecure] [--name NAME] -muglyph --help # once published +dotnet run --project src/SharpMUTerm.Tui -- [--tls] [--insecure] [--name NAME] +sharpmuterm --help # once published ``` In-app: **Up/Down** input history · **Ctrl+N** next window · **Ctrl+W** close window · **Ctrl+Q** quit. (Windows/panes are mouse-resizable; each window keeps its own input draft.) @@ -105,11 +110,11 @@ The test projects use [TUnit], which runs on the Microsoft.Testing.Platform. Run (the classic `dotnet test`/VSTest path is not used by MTP on .NET 10): ```bash -dotnet run --project tests/MuClient.Core.Tests -dotnet run --project tests/MuClient.Graphics.Tests -dotnet run --project tests/MuClient.Scripting.Tests -dotnet run --project tests/MuClient.Web.Tests -dotnet run --project tests/MuClient.Tui.Tests +dotnet run --project tests/SharpMUTerm.Core.Tests +dotnet run --project tests/SharpMUTerm.Graphics.Tests +dotnet run --project tests/SharpMUTerm.Scripting.Tests +dotnet run --project tests/SharpMUTerm.Web.Tests +dotnet run --project tests/SharpMUTerm.Tui.Tests ``` ## License diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index e82ce90..112d315 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -1,29 +1,29 @@ -# Packaging MuGlyph +# Packaging SharpMUTerm -MuGlyph publishes as a **self-contained, single-file** executable — no .NET runtime required on +SharpMUTerm publishes as a **self-contained, single-file** executable — no .NET runtime required on the target machine. ## Supported runtimes `linux-x64`, `linux-arm64`, `win-x64`, `osx-x64`, `osx-arm64` (declared in -`src/MuClient.Tui/MuClient.Tui.csproj`). +`src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj`). ## Local publish -Publish profiles live in `src/MuClient.Tui/Properties/PublishProfiles/`: +Publish profiles live in `src/SharpMUTerm.Tui/Properties/PublishProfiles/`: ```bash -# Linux x64 → publish output contains a single `muglyph` binary -dotnet publish src/MuClient.Tui -p:PublishProfile=linux-x64 -o out/linux-x64 +# Linux x64 → publish output contains a single `sharpmuterm` binary +dotnet publish src/SharpMUTerm.Tui -p:PublishProfile=linux-x64 -o out/linux-x64 -# Windows x64 → `muglyph.exe` -dotnet publish src/MuClient.Tui -p:PublishProfile=win-x64 -o out/win-x64 +# Windows x64 → `sharpmuterm.exe` +dotnet publish src/SharpMUTerm.Tui -p:PublishProfile=win-x64 -o out/win-x64 ``` For a RID without a profile, pass the flags directly: ```bash -dotnet publish src/MuClient.Tui -c Release -r osx-arm64 \ +dotnet publish src/SharpMUTerm.Tui -c Release -r osx-arm64 \ --self-contained -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true -o out/osx-arm64 ``` diff --git a/docs/PLAN.md b/docs/PLAN.md index b307893..f106e63 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -7,7 +7,7 @@ BeipMU is the best-in-class **Windows-only** MU\* (MUSH/MUCK/MUD) client, but it Key reframing from research: **"GPU-enabled" is a property of the terminal emulator, not our app.** Any TUI running inside Kitty/WezTerm/Ghostty gets GPU-accelerated glyph rendering for free. Our job is to (a) emit rich truecolor/styled text and (b) use the **Kitty graphics protocol** (escape sequences) for inline images/maps, with graceful fallbacks. Both are fully achievable from managed C#. ### Locked decisions (from planning Q&A) -- **Rendering base:** **SharpConsoleUI** (`nickprotop/ConsoleEx`, stable, net8/9/10) — a compositor-based framework with split layouts, tabs, resizable/mouse windows, Spectre-style markup, and a **native Kitty graphics protocol** (+ Sixel/half-block). Superseded the original Terminal.Gui v2 choice (which was prerelease with an `[Obsolete]` mid-migration API); the switch was contained to `MuClient.Tui` since `MuClient.Core` is UI-agnostic. References below that describe Terminal.Gui reflect the earlier plan. +- **Rendering base:** **SharpConsoleUI** (`nickprotop/ConsoleEx`, stable, net8/9/10) — a compositor-based framework with split layouts, tabs, resizable/mouse windows, Spectre-style markup, and a **native Kitty graphics protocol** (+ Sixel/half-block). Superseded the original Terminal.Gui v2 choice (which was prerelease with an `[Obsolete]` mid-migration API); the switch was contained to `SharpMUTerm.Tui` since `SharpMUTerm.Core` is UI-agnostic. References below that describe Terminal.Gui reflect the earlier plan. - **Scripting:** Lua via **MoonSharp** (pure-managed, no native deps). - **Inline graphics:** must-have from day one. - **Scope:** broad BeipMU parity (phased into milestones below). @@ -51,11 +51,11 @@ Layered, with a strict separation between **protocol/session** (headless, unit-t ``` ### Solution structure (proposed) -- `MuClient.Core` — transport, telnet, ANSI/MXP parsers, GMCP/MSDP routing, scrollback model, trigger/alias/macro engines, logging. **No UI deps.** -- `MuClient.Scripting` — MoonSharp host + the scripting API surface (world, output, triggers, timers, gmcp). -- `MuClient.Graphics` — Kitty graphics protocol encoder, capability probe, Sixel + half-block fallbacks, `GraphicsView`. -- `MuClient.Tui` — SharpConsoleUI app: windows, panes, key routing, settings UI, wiring. -- `MuClient.Core.Tests` / `MuClient.Graphics.Tests` — xUnit. +- `SharpMUTerm.Core` — transport, telnet, ANSI/MXP parsers, GMCP/MSDP routing, scrollback model, trigger/alias/macro engines, logging. **No UI deps.** +- `SharpMUTerm.Scripting` — MoonSharp host + the scripting API surface (world, output, triggers, timers, gmcp). +- `SharpMUTerm.Graphics` — Kitty graphics protocol encoder, capability probe, Sixel + half-block fallbacks, `GraphicsView`. +- `SharpMUTerm.Tui` — SharpConsoleUI app: windows, panes, key routing, settings UI, wiring. +- `SharpMUTerm.Core.Tests` / `SharpMUTerm.Graphics.Tests` — xUnit. - Target **.NET 10** (confirm TelnetNegotiationCore + Terminal.Gui v2 support net10.0; if a dep lags, reference it via `net8.0` compat and keep our own projects on net10.0). --- @@ -75,7 +75,7 @@ Layered, with a strict separation between **protocol/session** (headless, unit-t ## Rendering & graphics - **Text UI**: Terminal.Gui v2 provides the window manager, tabbed worlds, dockable panes, scrollback view, multi-input, focus, and truecolor cell rendering. Advertise UTF-8 + truecolor to servers. -- **`GraphicsView`** (`MuClient.Graphics`): renders images (maps, avatars, inline media) using **Kitty Unicode placeholders** so images occupy real cells and scroll/clip via Terminal.Gui's layout. Pipeline: probe capability → upload image once (base64-chunked `APC _G` transmit) → paint placeholder runes/colors into the view's cells → manage image lifecycle (`a=d` delete on close/replace). +- **`GraphicsView`** (`SharpMUTerm.Graphics`): renders images (maps, avatars, inline media) using **Kitty Unicode placeholders** so images occupy real cells and scroll/clip via Terminal.Gui's layout. Pipeline: probe capability → upload image once (base64-chunked `APC _G` transmit) → paint placeholder runes/colors into the view's cells → manage image lifecycle (`a=d` delete on close/replace). - **Capability probe + fallbacks**: query terminal for Kitty graphics; else **Sixel**; else Unicode **half-block/quadrant** approximation; else a text placeholder. Selection is per-session and user-overridable in settings. - **Map rendering**: `MapModel` (rooms/exits/z-levels) rendered either as box-drawing/Unicode vector art in a normal view *or* as a rasterized image through `GraphicsView` — start with box-drawing (works everywhere), add rasterized mode where graphics are available. @@ -99,7 +99,7 @@ Transport (TCP + SslStream TLS + IPv6); TelnetSession over TelnetNegotiationCore `TriggerEngine` (regex, gag/highlight/rewrite/spawn actions), `AliasEngine`, `MacroEngine`/keybinds, timers. Settings UI for all of them. Per-world profiles (JSON). **M3 — Graphics day-one payoff** -`MuClient.Graphics`: Kitty placeholder `GraphicsView` + Sixel/half-block fallbacks + capability probe. Inline **image viewer**; **map** view (box-drawing first, rasterized where supported); **stat panes** driven by GMCP. +`SharpMUTerm.Graphics`: Kitty placeholder `GraphicsView` + Sixel/half-block fallbacks + capability probe. Inline **image viewer**; **map** view (box-drawing first, rasterized where supported); **stat panes** driven by GMCP. **M4 — Scripting** MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscriptions from Lua, hot-reload. @@ -113,7 +113,7 @@ MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscri in the TUI. **Emoji** substitution (`EmojiSubstitutor`), opt-in per world. GMCP-driven **stat line**, **spawn** capture, ReDoS-guarded regex engines, and self-contained **single-file packaging** (`docs/PACKAGING.md`) + a tagged release workflow. -- **In-TUI web view** (`MuClient.Web` + `WebView`): fetch a URL or follow an MXP/Pueblo/HTML link +- **In-TUI web view** (`SharpMUTerm.Web` + `WebView`): fetch a URL or follow an MXP/Pueblo/HTML link and read the page as styled, word-wrapped text with clickable in-pane navigation (AngleSharp → `StyledLine`s, reusing `SpanInteraction`). `` renders as a labelled link today. @@ -163,11 +163,13 @@ prompt with a destination/draft gutter. Built on these `Core` pieces (pure + tes - **Unit tests** (`Core.Tests`): ANSI/SGR parser (256 + truecolor + edge sequences), telnet negotiation round-trips, MCCP decompression against captured zlib streams, trigger/alias regex + action application, GMCP JSON routing. - **Graphics tests**: Kitty placeholder-sequence encoder golden-output tests; capability-probe fallback selection. - **Manual/integration**: connect to a public test MU\* (and a local throwaway server) from **Kitty, WezTerm, Ghostty, and a non-graphics terminal**; verify truecolor, prompts on input line, logging, triggers firing, an inline image rendering under Kitty and degrading to half-block elsewhere. Run on both **Windows and Linux**. -- Use `dotnet test` in CI (GitHub Actions matrix: windows-latest + ubuntu-latest). +- Run the tests in CI with `dotnet run --project ` per test project (GitHub Actions matrix: + windows-latest + ubuntu-latest). TUnit runs on Microsoft.Testing.Platform, where the classic + `dotnet test`/VSTest path is unsupported on .NET 10 and later. --- ## Open items to confirm before/at M1 - Confirm TelnetNegotiationCore + Terminal.Gui v2 both build against **net10.0** (fallback: consume via net8.0 compat). - Which servers you actually play on (helps prioritize protocol edge cases; all are targeted regardless). -- Final project/repo **name** (currently scaffolded as `MuGlyph` — trivially renamable). +- Final project/repo **name** (currently scaffolded as `SharpMUTerm` — trivially renamable). diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md index 39c06f3..86892bb 100644 --- a/docs/SCREENSHOTS.md +++ b/docs/SCREENSHOTS.md @@ -1,12 +1,12 @@ # Screenshots & demos (headless) -MuGlyph can render its UI to an image **without a terminal or a live connection**, so +SharpMUTerm can render its UI to an image **without a terminal or a live connection**, so documentation images and CI visual checks work anywhere the .NET build runs. ## How it works SharpConsoleUI ships a `HeadlessConsoleDriver` that renders to a captured buffer instead -of a real console. `muglyph --snapshot` builds the app on that driver, loads a +of a real console. `sharpmuterm --snapshot` builds the app on that driver, loads a representative demo scene (a room, a `Chat` spawn window with unread, an input draft), renders one frame, and writes the raw ANSI to stdout (or `--out file`). @@ -16,19 +16,19 @@ a character grid and emits a **self-contained SVG** (great for embedding in Mark ```bash # one-shot -muglyph --snapshot --size 100x30 | python3 tools/ansi_frame_to_image.py > shot.svg +sharpmuterm --snapshot --size 100x30 | python3 tools/ansi_frame_to_image.py > shot.svg # regenerate the committed screenshots tools/make-screenshots.sh ``` The frame is deterministic (the desktop panels/clock are disabled under the headless -driver), so `muglyph --snapshot` output can also serve as a **golden file** for CI. +driver), so `sharpmuterm --snapshot` output can also serve as a **golden file** for CI. ## Animated demos (VHS) For animated GIFs/MP4s of real usage (typing, spawn windows appearing), use [charmbracelet/vhs](https://github.com/charmbracelet/vhs): write a `.tape` script and run it in CI with [`charmbracelet/vhs-action`](https://github.com/charmbracelet/vhs-action). -VHS drives the published `muglyph` binary in a headless terminal and records the result — +VHS drives the published `sharpmuterm` binary in a headless terminal and records the result — complementary to the static SVG snapshots above. diff --git a/docs/design/README.md b/docs/design/README.md index aac8713..6c8de0c 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -1,22 +1,22 @@ -# Handoff: MuGlyph multi-pane workspace, spawn windows & settings (M5 UI) +# Handoff: SharpMUTerm multi-pane workspace, spawn windows & settings (M5 UI) ## Overview -A design for MuGlyph's TUI shell at M5 scope: a tmux-style pane tree hosting BeipMU-style +A design for SharpMUTerm's TUI shell at M5 scope: a tmux-style pane tree hosting BeipMU-style spawn windows, a worlds→characters connection model, trigger sets assignable to characters, a searchable command surface, and per-tab input drafts. -This covers the UI layer only. It assumes the existing `MuClient.Core` engines +This covers the UI layer only. It assumes the existing `SharpMUTerm.Core` engines (`TriggerEngine`, `AliasEngine`, `IntervalScheduler`, `ScrollbackBuffer`, `SessionManager`) -and asks for two **schema changes** in `MuClient.Core.Configuration` — see *Schema changes* below. +and asks for two **schema changes** in `SharpMUTerm.Core.Configuration` — see *Schema changes* below. ## About the design files -`Glyph-TUI-v3.dc.html` is a **design reference written in HTML**, not production code and not +`SharpMUTerm-TUI-v3.dc.html` is a **design reference written in HTML**, not production code and not something to port. It is a browser mock of a terminal UI: every "pane border", "block meter" and "box-drawing glyph" is HTML standing in for what SharpConsoleUI will draw as real cells. -The task is to **rebuild these screens in `MuClient.Tui`** using SharpConsoleUI views and the +The task is to **rebuild these screens in `SharpMUTerm.Tui`** using SharpConsoleUI views and the existing `Theme`/`ColorMapper` pipeline. Do not translate the HTML structure; translate the layout, the interaction model, and the information hierarchy. @@ -28,7 +28,7 @@ Open the file in a browser to interact with it (typing, pane splits, ⌃P, F2– low-fidelity for colour.** Every colour in the mock is a literal hex, because HTML has no theme layer. In the real client -these must resolve through `MuClient.Core.Theming.Theme` — do not hardcode the mock's hexes. +these must resolve through `SharpMUTerm.Core.Theming.Theme` — do not hardcode the mock's hexes. The mapping table under *Design tokens* gives the theme field or ANSI index each mock colour stands for. @@ -104,7 +104,7 @@ world's imported character, preserving today's behaviour. The whole client. Five regions, top to bottom: header, [rail | pane area], input, status bar. -**Header** (1 row): `☰ glyph·tui` at far left is the menu affordance and opens the command +**Header** (1 row): `☰ muterm` at far left is the menu affordance and opens the command surface (the caret flips `☰`→`▾` while open). Right side carries a `⌃B` prefix indicator (shown only while armed), the log indicator (`◉ LOG 1284` / `◉ LOG off`), and a clock. @@ -337,7 +337,7 @@ None. No images, no icon fonts — glyphs only. ## Files -- `Glyph TUI v3.dc.html` — the interactive design reference (open in a browser) +- `SharpMUTerm-TUI-v3.dc.html` — the interactive design reference (open in a browser) - `support.js` — runtime for the above; not part of the design Repo files each screen maps to are tabulated in `github.md` at the project root. diff --git a/docs/design/Glyph-TUI-v3.dc.html b/docs/design/SharpMUTerm-TUI-v3.dc.html similarity index 99% rename from docs/design/Glyph-TUI-v3.dc.html rename to docs/design/SharpMUTerm-TUI-v3.dc.html index 8288595..00cea19 100644 --- a/docs/design/Glyph-TUI-v3.dc.html +++ b/docs/design/SharpMUTerm-TUI-v3.dc.html @@ -22,7 +22,7 @@
-
{{ brandCaret }}glyph·tui
+
{{ brandCaret }}muterm
⌃B — awaiting | - z o x
diff --git a/examples/example.lua b/examples/example.lua index 296d5de..779f976 100644 --- a/examples/example.lua +++ b/examples/example.lua @@ -1,4 +1,4 @@ --- Example MuGlyph Lua script. +-- Example SharpMUTerm Lua script. -- Loaded per-world; the sandbox exposes: world, output, trigger, alias, timer, gmcp, log. -- (No io/os.execute/require — scripting is sandboxed.) diff --git a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs index 470cac6..b9958c5 100644 --- a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs +++ b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs @@ -3,7 +3,7 @@ namespace SharpMUTerm.Core.Configuration; -/// Top-level MuGlyph configuration: global preferences, saved worlds, and shared trigger sets. +/// Top-level SharpMUTerm configuration: global preferences, saved worlds, and shared trigger sets. public sealed class AppConfiguration { /// The current on-disk schema version. Older configs are upgraded by . diff --git a/src/SharpMUTerm.Core/Configuration/ConfigurationStore.cs b/src/SharpMUTerm.Core/Configuration/ConfigurationStore.cs index 479ed35..ceb254a 100644 --- a/src/SharpMUTerm.Core/Configuration/ConfigurationStore.cs +++ b/src/SharpMUTerm.Core/Configuration/ConfigurationStore.cs @@ -35,7 +35,7 @@ public static string DefaultPath baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); } - return Path.Combine(baseDir, "MuGlyph", "config.json"); + return Path.Combine(baseDir, "SharpMUTerm", "config.json"); } } diff --git a/src/SharpMUTerm.Core/Logging/HtmlLogSink.cs b/src/SharpMUTerm.Core/Logging/HtmlLogSink.cs index 232e3b5..0b981fd 100644 --- a/src/SharpMUTerm.Core/Logging/HtmlLogSink.cs +++ b/src/SharpMUTerm.Core/Logging/HtmlLogSink.cs @@ -18,7 +18,7 @@ public sealed class HtmlLogSink : ILogSink private readonly object _gate = new(); private bool _closed; - public HtmlLogSink(TextWriter writer, string title = "MuGlyph session log", bool ownsWriter = true) + public HtmlLogSink(TextWriter writer, string title = "SharpMUTerm session log", bool ownsWriter = true) { _writer = writer ?? throw new ArgumentNullException(nameof(writer)); _ownsWriter = ownsWriter; @@ -26,7 +26,7 @@ public HtmlLogSink(TextWriter writer, string title = "MuGlyph session log", bool } /// Opens an HTML log file, creating parent directories as needed. - public static HtmlLogSink CreateFile(string path, string title = "MuGlyph session log") + public static HtmlLogSink CreateFile(string path, string title = "SharpMUTerm session log") { ArgumentException.ThrowIfNullOrEmpty(path); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); diff --git a/src/SharpMUTerm.Graphics/CapabilityProbe.cs b/src/SharpMUTerm.Graphics/CapabilityProbe.cs index b524356..20ab3b9 100644 --- a/src/SharpMUTerm.Graphics/CapabilityProbe.cs +++ b/src/SharpMUTerm.Graphics/CapabilityProbe.cs @@ -13,7 +13,7 @@ namespace SharpMUTerm.Graphics; public static class CapabilityProbe { /// Explicit override: forces a specific protocol regardless of heuristics. - public const string OverrideVariable = "MUGLYPH_GRAPHICS"; + public const string OverrideVariable = "SHARPMUTERM_GRAPHICS"; // Terminals known to speak Sixel that do not otherwise advertise it via TERM. private static readonly string[] KnownSixelTermPrograms = @@ -49,7 +49,7 @@ public static TerminalCapabilities Detect(IReadOnlyDictionary e var supportsSixel = Contains(term, "sixel") || - Equals(Get(environment, "MUGLYPH_SIXEL"), "1") || + Equals(Get(environment, "SHARPMUTERM_SIXEL"), "1") || IsKnownSixelProgram(termProgram); // An explicit override wins over everything, but we still report the individual diff --git a/src/SharpMUTerm.Tui/DemoScene.cs b/src/SharpMUTerm.Tui/DemoScene.cs index 33efdfe..ad6cc52 100644 --- a/src/SharpMUTerm.Tui/DemoScene.cs +++ b/src/SharpMUTerm.Tui/DemoScene.cs @@ -36,7 +36,7 @@ private static void AddWorlds(AppConfiguration config) UseTls = true, Encoding = "UTF-8", KeepaliveSeconds = 30, - Accent = MuGlyphApp.AccentPalette[0], + Accent = SharpMUTermApp.AccentPalette[0], Characters = { new CharacterDefinition @@ -57,7 +57,7 @@ private static void AddWorlds(AppConfiguration config) Host = "grapevine.haus", Port = 4000, Encoding = "ISO-8859-1", - Accent = MuGlyphApp.AccentPalette[1], + Accent = SharpMUTermApp.AccentPalette[1], Characters = { new CharacterDefinition { Name = "Thistle" } }, }); } diff --git a/src/SharpMUTerm.Tui/Glyphs.cs b/src/SharpMUTerm.Tui/Glyphs.cs index b323b5f..1734dae 100644 --- a/src/SharpMUTerm.Tui/Glyphs.cs +++ b/src/SharpMUTerm.Tui/Glyphs.cs @@ -1,7 +1,7 @@ namespace SharpMUTerm.Tui; /// -/// Centralised Nerd Font (v3) icon glyphs for MuGlyph's chrome. The terminals MuGlyph targets +/// Centralised Nerd Font (v3) icon glyphs for SharpMUTerm's chrome. The terminals SharpMUTerm targets /// (Kitty, WezTerm, Ghostty) are routinely run with Nerd Font builds, so these icons render as /// crisp symbols there; on a plain font they degrade to a "tofu" box. Keeping them in one place /// means a future MTTS-driven fallback to plain geometric glyphs is a single-file change rather diff --git a/src/SharpMUTerm.Tui/MarkupFormatter.cs b/src/SharpMUTerm.Tui/MarkupFormatter.cs index bb4f20c..4e38634 100644 --- a/src/SharpMUTerm.Tui/MarkupFormatter.cs +++ b/src/SharpMUTerm.Tui/MarkupFormatter.cs @@ -5,7 +5,7 @@ namespace SharpMUTerm.Tui; /// -/// Converts MuGlyph's UI-agnostic model into SharpConsoleUI (Spectre-style) +/// Converts SharpMUTerm's UI-agnostic model into SharpConsoleUI (Spectre-style) /// markup: truecolor foreground/background, bold/italic/underline/etc., and clickable /// s rendered as [link=…] spans. Colours are resolved through the /// active so palette-indexed and default colours land on real RGB values. diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs index d342d5b..09377fc 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -35,7 +35,7 @@ private static int Main(string[] args) } var (width, height) = ParseSize(args); - var app = new MuGlyphApp(config, capabilities, new HeadlessConsoleDriver(width, height)); + var app = new SharpMUTermApp(config, capabilities, new HeadlessConsoleDriver(width, height)); var frame = app.RenderSnapshot(GetOption(args, "--view")); var outPath = GetOption(args, "--out"); if (outPath is not null) @@ -54,7 +54,7 @@ private static int Main(string[] args) } var world = ResolveWorld(args, config); - var liveApp = new MuGlyphApp(config, capabilities); + var liveApp = new SharpMUTermApp(config, capabilities); var exitCode = liveApp.Run(world); // blocks on the SharpConsoleUI main loop until exit // Persist the workspace so the next launch resumes where this one left off. @@ -101,10 +101,10 @@ private static AppConfiguration LoadConfiguration() private static TerminalCapabilities DetectCapabilities(AppConfiguration config) { - // A config graphics override maps onto the same MUGLYPH_GRAPHICS mechanism the probe reads. + // A config graphics override maps onto the same SHARPMUTERM_GRAPHICS mechanism the probe reads. if (!string.IsNullOrEmpty(config.GraphicsOverride)) { - Environment.SetEnvironmentVariable("MUGLYPH_GRAPHICS", config.GraphicsOverride); + Environment.SetEnvironmentVariable("SHARPMUTERM_GRAPHICS", config.GraphicsOverride); } return CapabilityProbe.DetectFromEnvironment(); @@ -142,9 +142,9 @@ private static TerminalCapabilities DetectCapabilities(AppConfiguration config) private static void PrintUsage() { - Console.WriteLine("MuGlyph — a cross-platform TUI MU* client."); + Console.WriteLine("SharpMUTerm — a cross-platform TUI MU* client."); Console.WriteLine(); - Console.WriteLine("Usage: muglyph [host] [port] [options]"); + Console.WriteLine("Usage: sharpmuterm [host] [port] [options]"); Console.WriteLine(); Console.WriteLine(" host Server hostname or IP (IPv4/IPv6)."); Console.WriteLine(" port Server port (default 4000)."); diff --git a/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj b/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj index d8c3ba2..9ebfcd2 100644 --- a/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj +++ b/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj @@ -3,7 +3,7 @@ Exe SharpMUTerm.Tui - muglyph + sharpmuterm