Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
35603d3
F2 Triggers: apply the F5 panel treatment
HarryCordewener Jul 28, 2026
3e86427
F3 Aliases and F4 Keypad: apply the F5 panel treatment
HarryCordewener Jul 28, 2026
3a50a60
F6 Timers: apply the F5 panel treatment
HarryCordewener Jul 28, 2026
0198a1d
F7/F8/F9 Options: apply the treatment, then consolidate the chrome
HarryCordewener Jul 28, 2026
1c86928
F4 Keypad: align the numpad grid whatever is bound
HarryCordewener Jul 28, 2026
0e5ac5c
Settings screens: wire live keyboard interaction
HarryCordewener Jul 28, 2026
823c78d
Wire mouse drag-to-split panes
HarryCordewener Jul 28, 2026
029772c
Wire inline images into the web view via SharpConsoleUI
HarryCordewener Jul 28, 2026
449b52a
HANDOFF: restate the backlog as what is actually left
HarryCordewener Jul 28, 2026
f34293c
Settings screens: text, number and enum field editing
HarryCordewener Jul 28, 2026
dad7696
Settings screens: row buttons, route radios, colour picker
HarryCordewener Jul 29, 2026
7843a51
F2: route-to takes a typed window name, not a fixed choice
HarryCordewener Jul 29, 2026
7137b65
Settings screens: make read-only rows look read-only
HarryCordewener Jul 29, 2026
69a1724
Settings screens: editable names and add/remove on every list
HarryCordewener Jul 29, 2026
e121a4f
F2: expose the trigger actions that had no UI, drop the route radios
HarryCordewener Jul 29, 2026
06a385b
F5: own the character's logging and the world's TLS flags
HarryCordewener Jul 29, 2026
f95c450
Settings screens: show the options a field will accept
HarryCordewener Jul 29, 2026
ce12965
Make the settings actually reach the client
HarryCordewener Jul 29, 2026
37c5f6a
F4: make macros fire, make keys rebindable, stop handing out dead keys
HarryCordewener Jul 29, 2026
7851230
Make trigger sets manageable
HarryCordewener Jul 29, 2026
7b6b202
Settings screens: size the layout to its content
HarryCordewener Jul 29, 2026
8951259
HANDOFF: keepalive is unblocked upstream, waiting on a release
HarryCordewener Jul 29, 2026
64fb1a3
Wire the world's keepalive to the telnet session
HarryCordewener Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
shell: bash
run: |
dotnet run -c Release --no-build --project src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj -- \
--snapshot --size 120x30 --out snapshot.ans </dev/null
--snapshot --demo-config --size 120x30 --out snapshot.ans </dev/null
test -s snapshot.ans || { echo "snapshot produced no output"; exit 1; }
for token in CONNECTIONS Aetherfall Corvid palette; do
grep -qF "$token" snapshot.ans || { echo "snapshot missing token: $token"; exit 1; }
Expand Down
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,18 @@ fallbacks) for inline images/maps.
## Repository state

**M1 delivered, plus substantial M2–M4 work.** `SharpMUTerm.slnx` builds all ten projects on
`net10.0`; the solution has **514 passing tests**. In place:
`net10.0`; the solution has **1129 tests**, all passing. 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, 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).
probe, and `InlineImagePolicy` — the Kitty → Sixel → half-block → text degradation chain (no UI
dependency). Inside the TUI the *pixels* are drawn by SharpConsoleUI's `ImageControl`; ours
supplies the policy, because only the framework's renderer can put an image into compositor cells.
See `docs/HANDOFF.md` §2 for why, including the framework's missing Sixel back-end.
- **Scripting** — sandboxed MoonSharp `ScriptHost` (world/output/trigger/alias/timer/gmcp/log).
- **Tui** — **SharpConsoleUI** app: a `TabControl` of output windows (main + trigger-routed **spawn
windows** + web view, with unread badges), each a `MarkupControl` fed StyledLine → Spectre-style
Expand Down Expand Up @@ -79,7 +82,7 @@ Planned solution layout:
| Project | Responsibility |
|---|---|
| `SharpMUTerm.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, engines, logging (no UI deps) |
| `SharpMUTerm.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` |
| `SharpMUTerm.Graphics` | Kitty/Sixel encoders, capability probe, half-block fallback, `InlineImagePolicy` (no UI deps) |
| `SharpMUTerm.Scripting` | MoonSharp host + scripting API |
| `SharpMUTerm.Tui` | SharpConsoleUI application |
| `*.Tests` (Core, Graphics, Scripting, Web, Tui) | TUnit |
Expand Down
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<PackageVersion Include="SharpConsoleUI" Version="2.5.14" />

<!-- Telnet negotiation: TELOPT/NAWS/MTTS/EOR/CHARSET/MSSP/GMCP/MSDP/MCCP/MXP negotiation. -->
<PackageVersion Include="TelnetNegotiationCore" Version="2.5.3" />
<PackageVersion Include="TelnetNegotiationCore" Version="2.6.0" />

<!-- Sandboxed Lua scripting (pure-managed, no native deps). -->
<PackageVersion Include="MoonSharp" Version="2.0.0" />
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji.
are recognised and discarded.
- **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. User regexes run with a ReDoS match-timeout guard.
script), **aliases** (capture-group expansion, multi-command), **macros/keybinds** (F-keys and
Ctrl/Alt chords — the numpad is not deliverable through the terminal, and F4 says so per
binding), and a 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** `<SEND>`/`<A>` links and commands (`SpanInteraction`), colours, entities, and
line breaks. Selectable per world.
Expand Down
770 changes: 695 additions & 75 deletions docs/HANDOFF.md

Large diffs are not rendered by default.

17 changes: 11 additions & 6 deletions docs/SCREENSHOTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,29 @@ 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. `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`).
of a real console. `sharpmuterm --snapshot` builds the app on that driver, renders one
frame, and writes the raw ANSI to stdout (or `--out file`).

It renders **your own configuration**, like any other way of running the client. Add
`--demo-config` to render the built-in demo scene instead — a room, a `Chat` spawn window
with unread, an input draft. Every published image and golden frame uses it, since one
that changed with the developer's own worlds would be no use as either.

`tools/ansi_frame_to_image.py` parses that frame — cursor-addressed truecolor SGR — into
a character grid and emits a **self-contained SVG** (great for embedding in Markdown) or
**HTML** (`.html` output, or `--html`). No external dependencies.

```bash
# one-shot
sharpmuterm --snapshot --size 100x30 | python3 tools/ansi_frame_to_image.py > shot.svg
# one-shot, off the demo scene
sharpmuterm --snapshot --demo-config --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 `sharpmuterm --snapshot` output can also serve as a **golden file** for CI.
driver), so `sharpmuterm --snapshot --demo-config` output can also serve as a **golden
file** for CI — which is exactly what the CI smoke check greps.

## Animated demos (VHS)

Expand Down
74 changes: 69 additions & 5 deletions src/SharpMUTerm.Core/Automation/Alias.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,65 @@ namespace SharpMUTerm.Core.Automation;
public sealed class Alias
{
private Regex? _compiled;
private bool _caseSensitive;
private string _pattern = string.Empty;

public string Name { get; init; } = string.Empty;
/// <summary>
/// What the alias is called, for the lists that show it. Settable so the F3 screen can rename one
/// live; unlike <see cref="Pattern"/> and <see cref="CaseSensitive"/> nothing is derived from it —
/// expansion matches on the pattern and never looks an alias up by name — so there is no cache to
/// drop.
/// </summary>
public string Name { get; set; } = string.Empty;

public required string Pattern { get; init; }
/// <summary>
/// The .NET regular expression matched against typed input. Settable so the F3 settings screen can
/// edit it live; writing it drops the cached <see cref="Regex"/> so the next match recompiles
/// against the new pattern rather than silently going on matching the old one.
/// </summary>
public required string Pattern
{
get => _pattern;
set
{
if (string.Equals(_pattern, value, StringComparison.Ordinal))
{
return;
}

_pattern = value;
_compiled = null;
}
}

public bool Enabled { get; set; } = true;

public bool CaseSensitive { get; init; }
/// <summary>
/// Match case exactly. Settable so the F3 settings screen can flip it live; writing it drops the
/// cached <see cref="Regex"/> so the next match recompiles with the new casing rather than
/// silently keeping the old options.
/// </summary>
public bool CaseSensitive
{
get => _caseSensitive;
set
{
if (_caseSensitive == value)
{
return;
}

_caseSensitive = value;
_compiled = null;
}
}

/// <summary>The expansion template. May contain multiple newline-separated commands.</summary>
public string Substitution { get; init; } = string.Empty;
/// <summary>
/// The expansion template. May contain multiple newline-separated commands. Settable so the F3
/// screen can edit it live; the engine reads it per expansion, so a change applies to the next
/// line typed. Nothing is cached from it.
/// </summary>
public string Substitution { get; set; } = string.Empty;

/// <summary>Optional named script callback invoked instead of / in addition to expansion.</summary>
public string? ScriptCallback { get; init; }
Expand All @@ -31,4 +79,20 @@ public sealed class Alias
Pattern,
RegexOptions.Compiled | (CaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase),
AutomationDefaults.RegexMatchTimeout);

/// <summary>
/// A copy of this alias — the F3 screen's <c>duplicate</c> button is the caller. Every part is a
/// value or an immutable string, so nothing is shared; the compiled <see cref="Regex"/> is
/// deliberately not carried over, so the copy builds its own on first use and a later pattern or
/// casing edit on either alias cannot be seen by the other.
/// </summary>
public Alias Clone() => new()
{
Name = Name,
Pattern = Pattern,
Enabled = Enabled,
CaseSensitive = CaseSensitive,
Substitution = Substitution,
ScriptCallback = ScriptCallback,
};
}
155 changes: 150 additions & 5 deletions src/SharpMUTerm.Core/Automation/Macro.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Globalization;

namespace SharpMUTerm.Core.Automation;

/// <summary>
Expand All @@ -7,20 +9,50 @@ namespace SharpMUTerm.Core.Automation;
/// </summary>
public sealed class Macro
{
public string Name { get; init; } = string.Empty;
/// <summary>
/// What the binding is called, for the lists that show it. Settable so the F4 screen can rename one
/// live; nothing is derived from it — <see cref="MacroEngine"/> resolves on <see cref="Key"/>, never
/// on the name — so there is no cache to drop.
/// </summary>
public string Name { get; set; } = string.Empty;

/// <summary>The normalised key descriptor that triggers this macro.</summary>
public required string Key { get; init; }
/// <summary>
/// The normalised key descriptor that triggers this macro. Settable so the F4 screen can rebind one
/// live, through its key-capture mode rather than a text buffer.
/// <para>
/// It <em>is</em> what <see cref="MacroEngine"/> looks a keystroke up by, so it is precisely the
/// property that must not be cached anywhere: the engine therefore reads it per press rather than
/// holding a dictionary keyed on the string it was handed at construction, which would leave a
/// rebound macro still answering to the key it no longer carries until the next reconnect. That is
/// the same trap <see cref="Trigger.Pattern"/> and <see cref="Alias.CaseSensitive"/> guard against
/// by dropping their compiled matcher on write.
/// </para>
/// </summary>
public required string Key { get; set; }

public bool Enabled { get; set; } = true;

/// <summary>The command to send when the key is pressed.</summary>
public string Command { get; init; } = string.Empty;
/// <summary>
/// The command to send when the key is pressed. Settable so the F4 screen can edit it live; the
/// engine reads it per press, so a change applies to the next one. Nothing is cached from it.
/// </summary>
public string Command { get; set; } = string.Empty;

/// <summary>Optional named script callback (resolved by the scripting layer).</summary>
public string? ScriptCallback { get; init; }
}

/// <summary>
/// A key descriptor taken apart: the modifiers held down, and the name of the key itself. Produced by
/// <see cref="MacroKey.TryParse"/> so a descriptor can be reasoned about — is it a function key, does
/// it carry Ctrl — without every caller re-splitting the string.
/// </summary>
/// <param name="Key">The base key's canonical name (<c>F1</c>, <c>K</c>, <c>Num5</c>, <c>Up</c>).</param>
/// <param name="Ctrl">Whether Ctrl is part of the chord.</param>
/// <param name="Alt">Whether Alt is part of the chord.</param>
/// <param name="Shift">Whether Shift is part of the chord.</param>
public readonly record struct MacroKeyParts(string Key, bool Ctrl, bool Alt, bool Shift);

/// <summary>Builds and normalises key descriptor strings so bindings compare consistently.</summary>
public static class MacroKey
{
Expand Down Expand Up @@ -50,4 +82,117 @@ public static string Describe(string key, bool ctrl = false, bool alt = false, b
parts.Add(key);
return string.Join('+', parts);
}

/// <summary>
/// Splits a descriptor into its modifiers and its base key, settling the spelling of both. Modifier
/// words are matched case-insensitively and may appear in any order (<c>shift+ctrl+f1</c> parses);
/// the base key is normalised through <see cref="Normalise"/> so the several spellings a key is
/// written with in the wild (<c>NumPad5</c>/<c>Num5</c>, <c>PgUp</c>/<c>PageUp</c>, <c>esc</c>)
/// arrive as one.
/// <para>
/// Returns false for a descriptor with no base key, an empty component, or a word before the last
/// <c>+</c> that names no modifier — a caller that cannot say what a descriptor <em>is</em> must not
/// pretend it knows, because the answer decides whether a binding is drawn as one that fires.
/// </para>
/// </summary>
public static bool TryParse(string? descriptor, out MacroKeyParts parts)
{
parts = default;
if (string.IsNullOrWhiteSpace(descriptor))
{
return false;
}

var words = descriptor.Trim().Split('+');
bool ctrl = false, alt = false, shift = false;
for (var i = 0; i < words.Length - 1; i++)
{
switch (words[i].Trim().ToLowerInvariant())
{
case "ctrl" or "control": ctrl = true; break;
case "alt": alt = true; break;
case "shift": shift = true; break;
default: return false;
}
}

var key = words[^1].Trim();
if (key.Length == 0)
{
return false;
}

parts = new MacroKeyParts(Normalise(key), ctrl, alt, shift);
return true;
}

/// <summary>
/// The canonical spelling of a descriptor — the form a capture writes and the form a stored binding
/// is compared in — or null when it does not parse. <c>shift+ctrl+f1</c> and <c>Ctrl+Shift+F1</c>
/// are the same binding and come back identical; <c>Num5</c> and <c>Ctrl+F1</c>, the two shapes
/// already in configurations, come back untouched.
/// </summary>
public static string? Canonicalise(string? descriptor) =>
TryParse(descriptor, out var parts) ? Describe(parts.Key, parts.Ctrl, parts.Alt, parts.Shift) : null;

/// <summary>
/// The canonical name of a base key. Letters upper-case, function keys <c>F1</c>–<c>F24</c>, numpad
/// digits <c>Num0</c>–<c>Num9</c>, and one spelling each for the navigation and editing keys. A name
/// this does not recognise is kept verbatim rather than rejected: a configuration may name a key this
/// client has never heard of, and silently renaming it would be worse than leaving it alone.
/// </summary>
private static string Normalise(string key)
{
var lower = key.ToLowerInvariant();

if (lower.Length is 1 && char.IsAsciiLetter(lower[0]))
{
return lower.ToUpperInvariant();
}

if ((Digits(lower, "numpad") ?? Digits(lower, "num")) is { } pad)
{
return "Num" + pad.ToString(CultureInfo.InvariantCulture);
}

if (Digits(lower, "f") is { } function && function is >= 1 and <= 24)
{
return "F" + function.ToString(CultureInfo.InvariantCulture);
}

return lower switch
{
"up" or "uparrow" => "Up",
"down" or "downarrow" => "Down",
"left" or "leftarrow" => "Left",
"right" or "rightarrow" => "Right",
"home" => "Home",
"end" => "End",
"pageup" or "pgup" => "PageUp",
"pagedown" or "pgdn" or "pagedn" => "PageDown",
"insert" or "ins" => "Insert",
"delete" or "del" => "Delete",
"enter" or "return" => "Enter",
"escape" or "esc" => "Escape",
"tab" => "Tab",
"backspace" => "Backspace",
"space" or "spacebar" => "Space",
_ => key,
};
}

/// <summary>The number after a prefix (<c>f11</c> → 11, <c>num5</c> → 5), or null when it isn't one.</summary>
private static int? Digits(string lower, string prefix)
{
if (!lower.StartsWith(prefix, StringComparison.Ordinal))
{
return null;
}

var rest = lower[prefix.Length..];
return rest.Length > 0
&& int.TryParse(rest, NumberStyles.None, CultureInfo.InvariantCulture, out var value)
? value
: null;
}
}
Loading
Loading