Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d0374a0
Add design for decoding wire protocols into MarkupText and rendering …
claude Nov 6, 2026
752f7de
Plan each source-to-target conversion for the display pipeline, pictu…
claude Nov 6, 2026
08f1876
Mark the display pipeline design and plan accepted
claude Nov 6, 2026
e464530
Add display styles, span and cell renderers, menu and highlight markup
claude Nov 6, 2026
ad94728
Add terminal decoding plugins, conceal, and terminal styles for display
claude Nov 6, 2026
147e39e
Test the tokenizer, terminal plugins and renderers
claude Nov 6, 2026
d5df555
Decode Kitty, iTerm2 and sixel pictures into a picture store
claude Nov 6, 2026
788f716
Break over-long lines, blank split wide characters, end MSLP links at…
claude Nov 6, 2026
390ff5a
Decode MXP: line modes, tags, entities, custom elements, menus, answers
claude Nov 6, 2026
4140c9d
Decode Pueblo
claude Nov 6, 2026
5bb2972
Document pictures, MXP and Pueblo decoding
claude Nov 6, 2026
00e6d60
Remove the display pipeline design and plan; add changelog entry
claude Nov 6, 2026
4f88c70
Point the package READMEs at decoding
claude Nov 6, 2026
3d655b8
Smoke-test decoding under native AOT
claude Nov 6, 2026
76a4cdb
Decode Kitty animation frames, keep the OSC 8 id as a link group, red…
claude Nov 6, 2026
88d96c5
Let a host inject the picture cache
claude Nov 6, 2026
65b7328
Add injection seams and a dependency injection package
claude Nov 6, 2026
e9765d9
Ask IUrlPolicy in every writer, and log what decoders drop
claude Nov 6, 2026
5a6d0ae
Release 2.26.0 in the changelog
claude Nov 6, 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 @@ -23,7 +23,7 @@ jobs:
# SDK (the MSBuild build host crashes). Whitespace rules are syntactic, so nothing is lost.
- name: Verify formatting
run: |
for dir in MarkupString MarkupString.Ansi MarkupString.Html MarkupString.Mxp MarkupString.Pueblo MarkupString.Tests MarkupString.AotSmoke; do
for dir in MarkupString MarkupString.Ansi MarkupString.Extensions.DependencyInjection MarkupString.Html MarkupString.Mxp MarkupString.Pueblo MarkupString.Tests MarkupString.AotSmoke; do
dotnet format whitespace --folder "$dir" --exclude "**/bin/**" --exclude "**/obj/**" --verify-no-changes
done

Expand Down
49 changes: 47 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,56 @@
# Changelog

All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`, `MarkupString.Mxp`
and `MarkupString.Pueblo`. The packages share one version and are released together.
All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`, `MarkupString.Mxp`,
`MarkupString.Pueblo` and `MarkupString.Extensions.DependencyInjection`. The packages share one version and are released together.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 2.26.0 — 2026-11-06

### Added

- **Decoding server output.** `MarkupString.Decoding` reads what a server sends into `MarkupText` lines: a VT
tokenizer, a `DecoderRegistry` of plugins (`IDecoderPlugin`, handlers tried newest first), and a `Decoder` per
connection with `Feed`, `EndLine` and `EndPrompt`. Carriage return, backspace and erase overwrite in place, so a
progress bar ends as its last state. Titles, palette changes, the clipboard, variables and replies to send back
are `DecodeEvent`s; `ClientIdentity` says what the client answers, and `ToMtts` gives its MTTS bits. See
[Decoding](docs/decoding.md).
- **Terminal plugins** in `MarkupString.Ansi.Decoding`: `WithVt()` reads SGR (colon subparameters included),
the C0 controls, the DEC special graphics set, OSC 8 and MSLP links, titles, palette, clipboard, and answers
device attributes, XTVERSION, size and colour queries. The cursor position is not answered unless the host
allows it.
- **Pictures**: `WithPictures()` reads Kitty graphics (placeholders included), iTerm2 inline images and sixel into
`ImageMarkup` rows, with PNG and sixel decoders and a per-connection `PictureStore` that is also the
`ITerminalPictureSource` to write them out again. What the ANSI writer sends reads back cell for cell.
- **MXP and Pueblo decoding**: `WithMxp()` in `MarkupString.Mxp` (line modes, open and secure tags, `SEND`
menus, custom elements, entities, `VERSION` and `SUPPORT`) and `WithPueblo()` in `MarkupString.Pueblo`. What
each package writes reads back to the same markup. `MarkupString.Mxp` now depends on `MarkupString.Ansi`.
- **Display.** `MarkupString.Display` resolves markup to RGB styles for an output, degraded to its
`OutputCapabilities`: `SpanRenderer` for a web page (spans, inline CSS, classes) and `CellRenderer` for a cell
grid (wide characters, tabs, wrapping, picture cells, hit testing). `DisplayRegistry.Core.WithAnsi()` folds
terminal styles; `TerminalProfile.ToOutputCapabilities` and `ToTerminalFeatures` convert between the two
models. See [Display](docs/display.md).
- `CommandMenuMarkup` (a menu of commands, or a prompt), `HighlightMarkup` (a background) and `LinkGroupMarkup`
(an OSC 8 `id`, which becomes `Interaction.Group`) in core, with codecs.
- **`MarkupString.Extensions.DependencyInjection`**: `AddMarkupString()` registers the markup, decoder and display
registries, a shared picture cache, an `IDecoderFactory`, default `DisplayOptions` and the link policy as singletons,
configured in code or with `BindConfiguration()` (`MarkupStringOptions`, AOT-safe). See
[Dependency injection](docs/dependency-injection.md).
- Interfaces to inject: `IDecoderFactory` (a `DecoderRegistry` is one), `IStyleResolver` (a `DisplayRegistry` is one;
`SpanRenderer` and `CellRenderer` take it), and `IUrlPolicy` with `UrlPolicy.Safe` and `UrlPolicy.WithSchemes`, set as
`DecoderOptions.Links`, `DisplayOptions.Links`, `MarkupRegistry.WithLinks` (every writer asks it) and
`HtmlTagPolicy.Links`.
- Decode diagnostics are reported on the `MarkupString.Decoding` meter (`markupstring.decoding.dropped`, by `kind`),
through `DecoderOptions.Meters` when a host gives its `IMeterFactory`, and raised as `DecodeDiagnostics.Dropped`;
the DI package logs them at debug level to the host's `ILoggerFactory`.
- `IPictureCache` and `MemoryPictureCache`: `WithPictures(cache)` shares decoded pictures between connections, and a
host can keep them in a cache of its own.
- Kitty animation frames (`a=f`, and frame times with `a=a`) decode into a moving `StoredPicture`, given as a GIF.
- Spans and cells at `ColorLevel.Standard` or `Xterm256` get the nearest colour of that palette.
- `AnsiStyle.Concealed` (SGR 8), written as SGR 8 and as `ms-conceal` in HTML.
- `PictureRow.FirstColumn`, for a picture row shown from a column other than its first.

## 2.25.0 — 2026-11-02

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
</PropertyGroup>

<!--
Package metadata that is identical across the five shipping packages. Everything that
Package metadata that is identical across the shipping packages. Everything that
differs per package — PackageId, AssemblyName, Description, project references — stays in
the project file. Test and smoke projects set IsPackable=false, so these are inert there.
-->
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>

<!-- Keep the package family recognizable with one embedded NuGet icon. -->
<PropertyGroup Condition="'$(PackageId)' == 'MarkupString' OR '$(PackageId)' == 'MarkupString.Ansi' OR '$(PackageId)' == 'MarkupString.Html' OR '$(PackageId)' == 'MarkupString.Mxp' OR '$(PackageId)' == 'MarkupString.Pueblo'">
<PropertyGroup Condition="'$(PackageId)' == 'MarkupString' OR '$(PackageId)' == 'MarkupString.Ansi' OR '$(PackageId)' == 'MarkupString.Html' OR '$(PackageId)' == 'MarkupString.Mxp' OR '$(PackageId)' == 'MarkupString.Pueblo' OR '$(PackageId)' == 'MarkupString.Extensions.DependencyInjection'">
<PackageIcon>markupstring-icon.png</PackageIcon>
</PropertyGroup>
<ItemGroup Condition="'$(PackageIcon)' == 'markupstring-icon.png'">
Expand Down
1 change: 1 addition & 0 deletions MarkupString.Ansi/AnsiCss.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public static class AnsiCss
".ms-underline { text-decoration: underline; }\n" +
".ms-strike { text-decoration: line-through; }\n" +
".ms-overline { text-decoration: overline; }\n" +
".ms-conceal { color: transparent; }\n" +
".ms-blink { animation: ms-blink 1s step-start infinite; }\n" +
".ms-invert { color: var(--ms-bg, #000); background-color: var(--ms-fg, #fff); }\n" +
".ms-cmd-link { cursor: pointer; text-decoration: underline dotted; }\n" +
Expand Down
2 changes: 2 additions & 0 deletions MarkupString.Ansi/AnsiMarkupCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public void Write(Utf8JsonWriter writer, IMarkup markup)
if (style.Overlined) writer.WriteNumber("ov", 1);
if (style.Underlined) writer.WriteNumber("un", 1);
if (style.StrikeThrough) writer.WriteNumber("st", 1);
if (style.Concealed) writer.WriteNumber("cn", 1);
if (style.BlinkOff) writer.WriteNumber("nbl", 1);
if (style.BoldOff) writer.WriteNumber("nbo", 1);
if (style.InvertedOff) writer.WriteNumber("nin", 1);
Expand Down Expand Up @@ -80,6 +81,7 @@ public IMarkup Read(JsonElement element) =>
Overlined = element.TryGetProperty("ov", out _),
Underlined = element.TryGetProperty("un", out _),
StrikeThrough = element.TryGetProperty("st", out _),
Concealed = element.TryGetProperty("cn", out _),
BlinkOff = element.TryGetProperty("nbl", out _),
BoldOff = element.TryGetProperty("nbo", out _),
InvertedOff = element.TryGetProperty("nin", out _),
Expand Down
7 changes: 6 additions & 1 deletion MarkupString.Ansi/AnsiStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public readonly record struct AnsiStyle
public bool Inverted { get; init; }
public bool StrikeThrough { get; init; }

/// <summary>Hidden (SGR 8): drawn in the background colour, as a password is, but still text to copy.</summary>
public bool Concealed { get; init; }

/// <summary>
/// Set by the <c>F</c> code: blink is off in this span, whatever encloses it. See <see cref="Combine"/>.
/// </summary>
Expand Down Expand Up @@ -87,6 +90,7 @@ public readonly record struct AnsiStyle
| (InvertedOff ? 1 << 10 : 0)
| (UnderlinedOff ? 1 << 11 : 0)
| (Clear ? 1 << 12 : 0)
| (Concealed ? 1 << 13 : 0)
| ((int)LinkKind << 16);

/// <summary>Field-by-field equality, written out because a style is compared each time a markup set is looked up.</summary>
Expand All @@ -108,7 +112,7 @@ public override int GetHashCode() =>
/// </summary>
public bool HasAnyAttribute =>
Foreground is not null || Background is not null ||
Bold || Faint || Italic || Underlined || Overlined || Blink || Inverted || StrikeThrough || Clear ||
Bold || Faint || Italic || Underlined || Overlined || Blink || Inverted || StrikeThrough || Concealed || Clear ||
BlinkOff || BoldOff || InvertedOff || UnderlinedOff;

/// <summary>
Expand Down Expand Up @@ -156,6 +160,7 @@ public AnsiStyle Combine(AnsiStyle inner)
Blink = (outer.Blink || inner.Blink) && !inner.BlinkOff,
Inverted = (outer.Inverted || inner.Inverted) && !inner.InvertedOff,
StrikeThrough = outer.StrikeThrough || inner.StrikeThrough,
Concealed = outer.Concealed || inner.Concealed,
// Still off for whatever this span encloses, unless it turns the attribute back on.
BlinkOff = inner.BlinkOff || (outer.BlinkOff && !inner.Blink),
BoldOff = inner.BoldOff || (outer.BoldOff && !inner.Bold),
Expand Down
184 changes: 184 additions & 0 deletions MarkupString.Ansi/Decoding/AnsiDisplay.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using MarkupString.Display;
namespace MarkupString.Ansi.Decoding;

/// <summary>Registers terminal styles on a <see cref="DisplayRegistry"/>, and reads a terminal's capabilities.</summary>
public static class AnsiDisplay
{
/// <summary>The registry with <see cref="AnsiStyles"/> added.</summary>
/// <param name="registry">The registry.</param>
/// <param name="boldIsBright">Draw bold text in a standard colour in its bright version, as many MUD clients do.</param>
public static DisplayRegistry WithAnsi(this DisplayRegistry registry, bool boldIsBright = false)
{
ArgumentNullException.ThrowIfNull(registry);
return registry.With(boldIsBright ? AnsiStyles.BoldIsBright : AnsiStyles.Instance);
}

/// <summary>The shared vocabulary and terminal styles.</summary>
public static DisplayRegistry Default { get; } = DisplayRegistry.Core.WithAnsi();

/// <summary>What a terminal shows, from its profile and the colour depth the host settled on.</summary>
public static OutputCapabilities ToOutputCapabilities(this TerminalProfile profile, AnsiColorDepth depth)
{
ArgumentNullException.ThrowIfNull(profile);
var features = profile.Features;
var pictures = PictureMethods.None;
if ((features & TerminalFeatures.KittyGraphics) != 0) pictures |= PictureMethods.KittyPlaceholders;
if ((features & TerminalFeatures.InlineImages) != 0) pictures |= PictureMethods.InlineImages;
if ((features & TerminalFeatures.Sixel) != 0) pictures |= PictureMethods.Sixel;
if ((features & TerminalFeatures.BlockArt) != 0) pictures |= PictureMethods.HalfBlocks;

var interactions = InteractionKinds.None;
if ((features & TerminalFeatures.Hyperlinks) != 0) interactions |= InteractionKinds.Url;
if ((features & TerminalFeatures.CommandLinks) != 0) interactions |= InteractionKinds.Command;

return new OutputCapabilities
{
Colors = depth switch
{
AnsiColorDepth.TrueColor => ColorLevel.TrueColor,
AnsiColorDepth.Xterm256 => ColorLevel.Xterm256,
AnsiColorDepth.Standard => ColorLevel.Standard,
AnsiColorDepth.Attributes => ColorLevel.Attributes,
_ => ColorLevel.None,
},
Attributes = TextAttributes.All,
Interactions = interactions,
Pictures = pictures,
MovingPictures = (features & TerminalFeatures.MovingPictures) != 0,
};
}

/// <summary>The colour depth an output shows, for an ANSI writer.</summary>
public static AnsiColorDepth ToColorDepth(this OutputCapabilities capabilities)
{
ArgumentNullException.ThrowIfNull(capabilities);
return capabilities.Colors switch
{
ColorLevel.TrueColor => AnsiColorDepth.TrueColor,
ColorLevel.Xterm256 => AnsiColorDepth.Xterm256,
ColorLevel.Standard => AnsiColorDepth.Standard,
ColorLevel.Attributes => AnsiColorDepth.Attributes,
_ => AnsiColorDepth.None,
};
}

/// <summary>The terminal features an output has, for an ANSI writer.</summary>
public static TerminalFeatures ToTerminalFeatures(this OutputCapabilities capabilities)
{
ArgumentNullException.ThrowIfNull(capabilities);
var features = TerminalFeatures.None;
if ((capabilities.Interactions & InteractionKinds.Url) != 0) features |= TerminalFeatures.Hyperlinks;
if ((capabilities.Interactions & InteractionKinds.Command) != 0) features |= TerminalFeatures.CommandLinks;
if ((capabilities.Pictures & PictureMethods.KittyPlaceholders) != 0) features |= TerminalFeatures.KittyGraphics;
if ((capabilities.Pictures & PictureMethods.InlineImages) != 0) features |= TerminalFeatures.InlineImages;
if ((capabilities.Pictures & PictureMethods.Sixel) != 0) features |= TerminalFeatures.Sixel;
if ((capabilities.Pictures & PictureMethods.HalfBlocks) != 0) features |= TerminalFeatures.BlockArt;
if (capabilities.MovingPictures) features |= TerminalFeatures.MovingPictures;
return features;
}
}

/// <summary>
/// Folds every layer with a terminal style (<see cref="IAnsiStyleSource"/>: SGR, an MXP colour, an HTML
/// <c>&lt;b&gt;</c>) into colours, attributes and a link, outermost first as <see cref="AnsiStyle.Combine"/> nests them.
/// </summary>
public sealed class AnsiStyles : IStyleContributor
{
private readonly bool _boldIsBright;

private AnsiStyles(bool boldIsBright) => _boldIsBright = boldIsBright;

/// <summary>Colours as they are written.</summary>
public static AnsiStyles Instance { get; } = new(false);

/// <summary>Bold text in a standard colour drawn in its bright version.</summary>
public static AnsiStyles BoldIsBright { get; } = new(true);

/// <inheritdoc/>
public void Contribute(MarkupSet markups, StyleBuilder style, DisplayOptions options)
{
ArgumentNullException.ThrowIfNull(markups);
ArgumentNullException.ThrowIfNull(style);
ArgumentNullException.ThrowIfNull(options);

var folded = AnsiStyle.None;
var any = false;
for (var i = markups.Count - 1; i >= 0; i--)
{
if (markups[i] is IAnsiStyleSource source && source.TryGetAnsiStyle(MarkupFormat.Ansi, out var layer))
{
folded = folded.Combine(layer);
any = true;
}
}

if (!any) return;

var colors = options.Colors ?? AnsiColors.Default;
var foreground = folded.Foreground;
if (_boldIsBright && folded.Bold && foreground is AnsiColor.Standard { Bright: false } standard)
{
foreground = AnsiColor.StandardOf(standard.Index, true);
}

if (Resolve(foreground, colors) is { } fg) style.Foreground = fg;
if (Resolve(folded.Background, colors) is { } bg) style.Background ??= bg;

var attributes = TextAttributes.None;
if (folded.Bold) attributes |= TextAttributes.Bold;
if (folded.Faint) attributes |= TextAttributes.Faint;
if (folded.Italic) attributes |= TextAttributes.Italic;
if (folded.Underlined) attributes |= TextAttributes.Underline;
if (folded.Blink) attributes |= TextAttributes.Blink;
if (folded.Inverted) attributes |= TextAttributes.Inverse;
if (folded.Concealed) attributes |= TextAttributes.Conceal;
if (folded.StrikeThrough) attributes |= TextAttributes.Strikethrough;
if (folded.Overlined) attributes |= TextAttributes.Overline;
style.Attributes |= attributes;

if (!string.IsNullOrEmpty(folded.LinkUrl) && style.Interaction is null)
{
if (folded.LinkKind == LinkKind.Command)
{
style.Interaction = new Interaction(InteractionKind.Command, folded.LinkUrl) { Hint = folded.LinkText };
}
else if (options.Links.IsAllowed(folded.LinkUrl))
{
style.Interaction = new Interaction(InteractionKind.Url, folded.LinkUrl) { Hint = folded.LinkText };
}
}
}

private static RgbColor? Resolve(AnsiColor? color, IColorResolver colors) => color switch
{
AnsiColor.Standard standard => colors.Standard(standard.Index, standard.Bright),
AnsiColor.Xterm { Index: < 16 } xterm => colors.Standard(xterm.Index & 7, xterm.Index >= 8),
AnsiColor.Xterm xterm => colors.Xterm(xterm.Index),
AnsiColor.Rgb rgb => new RgbColor(rgb.R, rgb.G, rgb.B),
_ => null,
};
}

/// <summary>The xterm palette, as <see cref="AnsiPalette"/> has it, as an <see cref="IColorResolver"/>.</summary>
public class AnsiColors : IColorResolver
{
/// <summary>The xterm palette, text left as it is.</summary>
public static AnsiColors Default { get; } = new();

/// <inheritdoc/>
public virtual RgbColor Standard(int index, bool bright)
{
var rgb = AnsiPalette.Standard((byte)(index & 7), bright);
return new RgbColor(rgb.R, rgb.G, rgb.B);
}

/// <inheritdoc/>
public virtual RgbColor Xterm(int index)
{
var rgb = AnsiPalette.Xterm((byte)index);
return new RgbColor(rgb.R, rgb.G, rgb.B);
}

/// <inheritdoc/>
public virtual RgbColor Legible(RgbColor foreground, RgbColor background) => foreground;
}
Loading
Loading