protocol v6: per-severity diagnostic underline colors (SGR 58)

M4.6 follow-up piece 2. `Style` gains `underline_color: Color`
(Default = follow the text color) so a diagnostic squiggle can be
red/yellow/cyan/gray without clobbering the syntax color of the
text it underlines — exactly why error_style() left its 'red'
unwired until now.

The wire consequence: Style rides inside Cell / CellDelta /
Snapshot / StyleSpans, so this is the protocol's first
encoding-breaking change. PROTOCOL_VERSION 5 → 6 and
SUPPORTED_PROTOCOL_VERSIONS narrows to [6]: postcard is not
self-describing, so no per-session send gate can keep a v5 peer
decoding v6 cells — a mismatched pair now fails the handshake with
a clean VersionMismatch instead of garbling mid-session. Version
policy tests rewritten to pin the new contract.

Surface wiring:
- diag.rs: per-severity underline_color (indexed 1/3/6/8).
- frontend.rs: kitty-style CSI 4:N for Double/Curly/Dotted/Dashed
  (previously flattened to plain SGR 4) + SGR 58:5/58:2 emission.
- ansi.rs: parse SGR 58/59 with the 38/48 extended-color grammar.
- overlay.rs merge_styles: non-default-wins, like fg/bg/underline.
- lua_bindings.rs: underline_color on Lua style tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 19:16:42 -04:00
parent a0bd4d7f6c
commit 72799f771a
8 changed files with 265 additions and 109 deletions

View File

@ -125,6 +125,11 @@ pub struct Style {
pub underline: UnderlineStyle,
/// Reverse video.
pub reverse: bool,
/// Underline color (SGR 58/59). `Color::Default` means "follow
/// the text color": the underline draws in `fg`. Diagnostics set
/// this per severity so the squiggle color can differ from the
/// syntax-colored text it underlines (T M4.6, protocol v6).
pub underline_color: Color,
}
/// A non-text attachment carried in a cell (TUI ignores this).

View File

@ -1020,7 +1020,18 @@ pub enum ResourceBody {
/// frontend sends `Pointer` only when the instance's
/// `Hello.protocol_version >= 5`, because an older instance would
/// hard-error decoding the unknown variant.
pub const PROTOCOL_VERSION: u32 = 5;
///
/// T M4.6 (diagnostic surface): bumped from 5 to 6 for
/// `Style::underline_color`. Unlike every previous bump, this one
/// changes the *encoding of an existing struct* — `Style` rides
/// inside `Cell` / `CellDelta` / `Snapshot` / `StyleSpans`, messages
/// every session receives — so per-session send gating cannot
/// preserve compatibility (postcard is not self-describing; a v5
/// decoder mis-reads any v6 `Style`). v6 binaries therefore accept
/// only v6 peers: a version-mismatched pair fails the handshake with
/// [`GoodbyeReason::VersionMismatch`] instead of garbling cell
/// traffic mid-session.
pub const PROTOCOL_VERSION: u32 = 6;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1044,7 +1055,15 @@ pub const PROTOCOL_VERSION: u32 = 5;
/// Mouse framing Q#M1: extended to `[1, 2, 3, 4, 5]`. v5 peers may
/// send `FrontendEvent::Pointer`; the frontend-side gate (see
/// [`PROTOCOL_VERSION`]) keeps the variant off wires negotiated `< 5`.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2, 3, 4, 5];
///
/// T M4.6: narrowed to `[6]`. `Style::underline_color` changed the
/// postcard encoding of every cell-carrying message, so v6 binaries
/// cannot exchange cell traffic with any earlier wire. The v1v5
/// compat ladder (additive variants, per-session filtering) assumed
/// shared-struct encodings never changed; this bump is the first
/// that breaks that assumption, and slice membership is how the
/// handshake communicates it.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -931,6 +931,15 @@ impl AnsiParser {
}
}
49 => self.current_style.bg = Color::Default,
// 58/59 (underline color, kitty/mintty extension):
// same extended-color grammar as 38/48 (T M4.6).
58 => {
if let Some((color, extra)) = parse_extended_color(p, &params[i + 1..]) {
self.current_style.underline_color = color;
consumed_extra = extra;
}
}
59 => self.current_style.underline_color = Color::Default,
90..=97 => {
self.current_style.fg =
Color::Indexed(u8::try_from(p.main - 90 + 8).unwrap_or(0));
@ -1266,6 +1275,35 @@ mod tests {
);
}
/// Underline color via SGR 58 (both colon-subparam and semicolon
/// grammars, mirroring 38/48), reset via SGR 59 (T M4.6).
#[test]
fn m4_6_underline_color() {
let cases: &[(&[u8], Color)] = &[
(b"\x1b[58:5:1m", Color::Indexed(1)),
(b"\x1b[58;5;124m", Color::Indexed(124)),
(b"\x1b[58:2::255:0:0m", Color::Rgb(255, 0, 0)),
];
for (input, expected) in cases {
let mut p = AnsiParser::new();
let evs = p.feed(input);
let styles = collect_styles(&evs);
assert_eq!(
styles.first().map(|s| s.underline_color),
Some(*expected),
"input {input:?} should produce underline color {expected:?}; got {evs:?}"
);
}
// 59 resets to follow-text-color.
let mut p = AnsiParser::new();
let evs = p.feed(b"\x1b[58:5:1m\x1b[59m");
let styles = collect_styles(&evs);
assert_eq!(
styles.last().map(|s| s.underline_color),
Some(Color::Default)
);
}
// -----------------------------------------------------------------
// Intra-line motion and erase
// -----------------------------------------------------------------

View File

@ -332,14 +332,14 @@ pub fn make_shared_store() -> SharedDiagStore {
const TAB_WIDTH: u32 = 8;
/// Style applied to bytes covered by an `Error` diagnostic. Wavy
/// underline in red so it composes with whatever the syntax view
/// painted on top.
/// underline colored via `underline_color` (not `fg`) so the
/// squiggle reads red while the syntax view's text color survives
/// underneath (T M4.6, protocol v6).
fn error_style() -> Style {
Style {
underline: UnderlineStyle::Curly,
// Red. Indexed 1 is portable across 8/16-color terminals.
fg: Color::Default,
bg: Color::Default,
// Indexed 1 (red) is portable across 8/16-color terminals.
underline_color: Color::Indexed(1),
..Style::default()
}
}
@ -348,6 +348,8 @@ fn error_style() -> Style {
fn warning_style() -> Style {
Style {
underline: UnderlineStyle::Curly,
// Indexed 3: yellow.
underline_color: Color::Indexed(3),
..Style::default()
}
}
@ -356,6 +358,8 @@ fn warning_style() -> Style {
fn info_style() -> Style {
Style {
underline: UnderlineStyle::Single,
// Indexed 6: cyan.
underline_color: Color::Indexed(6),
..Style::default()
}
}
@ -364,6 +368,9 @@ fn info_style() -> Style {
fn hint_style() -> Style {
Style {
underline: UnderlineStyle::Dotted,
// Indexed 8: bright black ("gray") — present on 16-color
// terminals, subtle by design for hints.
underline_color: Color::Indexed(8),
..Style::default()
}
}
@ -791,6 +798,33 @@ mod tests {
}
}
#[test]
fn severity_styles_color_the_underline_not_the_text() {
// T M4.6: each severity gets a distinct underline color via
// `underline_color` (SGR 58); `fg` stays Default so the
// syntax view's text color survives the merge.
let mut seen = Vec::new();
for s in [
DiagnosticSeverity::Error,
DiagnosticSeverity::Warning,
DiagnosticSeverity::Information,
DiagnosticSeverity::Hint,
] {
let style = style_for(s);
assert_eq!(style.fg, Color::Default, "{s:?} must not set fg");
assert_ne!(
style.underline_color,
Color::Default,
"{s:?} must color its underline"
);
assert!(
!seen.contains(&style.underline_color),
"{s:?} reuses another severity's underline color"
);
seen.push(style.underline_color);
}
}
#[test]
fn view_advertises_diagnostic_kind() {
// `pmacs.window._overlay_kinds()` introspection (task #23 wire-up,
@ -845,4 +879,5 @@ mod tests {
"stale diagnostics must not underline shifted TUI bytes"
);
}
}

View File

@ -634,16 +634,30 @@ fn apply_style<W: Write>(w: &mut W, style: &Style) -> io::Result<()> {
}
match style.underline {
crate::cell::UnderlineStyle::None => {}
crate::cell::UnderlineStyle::Single
| crate::cell::UnderlineStyle::Double
| crate::cell::UnderlineStyle::Curly
| crate::cell::UnderlineStyle::Dotted
| crate::cell::UnderlineStyle::Dashed => {
// crossterm exposes Underlined; richer styles need a manual
// CSI 4:N m emit, which most terminals fall back to plain
// underline on. M2 can wire CSI 4:N m for kitty/iTerm.
crate::cell::UnderlineStyle::Single => {
queue!(w, SetAttribute(Attribute::Underlined))?;
}
// Kitty-style underline subparameters (CSI 4:N m). Terminals
// that predate the extension treat the whole sequence as
// SGR 4 (plain underline) or ignore the subparameter, so the
// fallback is a straight underline — same as the previous
// flatten-to-`Underlined` behavior (T M4.6).
crate::cell::UnderlineStyle::Double => write!(w, "\x1b[4:2m")?,
crate::cell::UnderlineStyle::Curly => write!(w, "\x1b[4:3m")?,
crate::cell::UnderlineStyle::Dotted => write!(w, "\x1b[4:4m")?,
crate::cell::UnderlineStyle::Dashed => write!(w, "\x1b[4:5m")?,
}
// Underline color (SGR 58, colon subparameter form). Emitted only
// when set and an underline is present: the SGR 0 at the top of
// this function already reset the underline color to
// follow-text-color (SGR 59 state), which is what
// `Color::Default` means.
if style.underline != crate::cell::UnderlineStyle::None {
match style.underline_color {
Color::Default => {}
Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m")?,
Color::Indexed(n) => write!(w, "\x1b[58:5:{n}m")?,
}
}
if style.reverse {
queue!(w, SetAttribute(Attribute::Reverse))?;
@ -771,6 +785,54 @@ mod tests {
assert!(s.contains("\x1b[1m"), "missing bold-on in {s:?}");
}
#[test]
fn curly_underline_emits_csi_4_3_and_underline_color() {
// A diagnostic-style cell: curly underline colored red via
// SGR 58 (T M4.6). The text color must NOT be touched.
let span = DiffSpan {
start: CellCoord::new(0, 0),
cells: vec![Cell {
glyph: Glyph::Char('x'),
style: Style {
underline: crate::cell::UnderlineStyle::Curly,
underline_color: crate::cell::Color::Indexed(1),
..Style::default()
},
attachment: None,
}],
};
let mut out = Vec::new();
emit_span(&mut out, &span).unwrap();
let s = String::from_utf8_lossy(&out);
assert!(s.contains("\x1b[4:3m"), "missing curly underline in {s:?}");
assert!(
s.contains("\x1b[58:5:1m"),
"missing underline color in {s:?}"
);
}
#[test]
fn default_underline_color_emits_no_sgr_58() {
// A plain single underline with follow-text color: no SGR 58
// on the wire (the reset at the start of every style apply
// already put the terminal in SGR 59 state).
let span = DiffSpan {
start: CellCoord::new(0, 0),
cells: vec![Cell {
glyph: Glyph::Char('x'),
style: Style {
underline: crate::cell::UnderlineStyle::Single,
..Style::default()
},
attachment: None,
}],
};
let mut out = Vec::new();
emit_span(&mut out, &span).unwrap();
let s = String::from_utf8_lossy(&out);
assert!(!s.contains("\x1b[58"), "unexpected SGR 58 in {s:?}");
}
// ---- status overlay (T M5.8) -----------------------------------------
#[test]

View File

@ -6248,6 +6248,7 @@ fn lua_to_style(t: &Table) -> mlua::Result<Style> {
let fg: mlua::Value = t.get("fg").unwrap_or(mlua::Value::Nil);
let bg: mlua::Value = t.get("bg").unwrap_or(mlua::Value::Nil);
let underline: mlua::Value = t.get("underline").unwrap_or(mlua::Value::Nil);
let underline_color: mlua::Value = t.get("underline_color").unwrap_or(mlua::Value::Nil);
Ok(Style {
fg: lua_to_color(&fg)?,
bg: lua_to_color(&bg)?,
@ -6255,17 +6256,19 @@ fn lua_to_style(t: &Table) -> mlua::Result<Style> {
italic: t.get("italic").unwrap_or(false),
underline: lua_to_underline(&underline)?,
reverse: t.get("reverse").unwrap_or(false),
underline_color: lua_to_color(&underline_color)?,
})
}
fn style_to_lua(lua: &Lua, style: Style) -> mlua::Result<Table> {
let t = lua.create_table_with_capacity(0, 6)?;
let t = lua.create_table_with_capacity(0, 7)?;
t.set("fg", color_to_lua(lua, style.fg)?)?;
t.set("bg", color_to_lua(lua, style.bg)?)?;
t.set("bold", style.bold)?;
t.set("italic", style.italic)?;
t.set("underline", underline_to_lua(style.underline))?;
t.set("reverse", style.reverse)?;
t.set("underline_color", color_to_lua(lua, style.underline_color)?)?;
Ok(t)
}

View File

@ -125,12 +125,12 @@ impl View for StyleSpanOverlay {
/// Merge `overlay` over `base`, producing a new [`Style`].
///
/// `fg`/`bg`/`underline` use "non-default wins" --- the overlay's
/// value applies only when it is non-default; otherwise the base
/// value is preserved. Boolean attributes (`bold`, `italic`,
/// `reverse`) OR-blend so that two stacked overlays can each
/// contribute. Pulled out of [`StyleSpanOverlay`] so
/// [`crate::highlight::SyntaxHighlightView`] can reuse the same
/// `fg`/`bg`/`underline`/`underline_color` use "non-default wins"
/// --- the overlay's value applies only when it is non-default;
/// otherwise the base value is preserved. Boolean attributes
/// (`bold`, `italic`, `reverse`) OR-blend so that two stacked
/// overlays can each contribute. Pulled out of [`StyleSpanOverlay`]
/// so [`crate::highlight::SyntaxHighlightView`] can reuse the same
/// composition rule (T M4.3).
#[must_use]
pub fn merge_styles(base: Style, overlay: Style) -> Style {
@ -154,6 +154,11 @@ pub fn merge_styles(base: Style, overlay: Style) -> Style {
overlay.underline
},
reverse: base.reverse || overlay.reverse,
underline_color: if overlay.underline_color == Color::Default {
base.underline_color
} else {
overlay.underline_color
},
}
}
@ -614,6 +619,34 @@ mod tests {
}
}
#[test]
fn merge_styles_underline_color_non_default_wins() {
use crate::cell::Color;
let base = Style {
fg: Color::Indexed(2),
underline: UnderlineStyle::Single,
underline_color: Color::Indexed(6),
..Default::default()
};
// Overlay sets its own underline color: it wins, while the
// base's fg (syntax color) survives untouched.
let diag_overlay = Style {
underline: UnderlineStyle::Curly,
underline_color: Color::Indexed(1),
..Default::default()
};
let merged = merge_styles(base, diag_overlay);
assert_eq!(merged.underline_color, Color::Indexed(1));
assert_eq!(merged.fg, Color::Indexed(2));
// Overlay with default underline color: base's is preserved.
let plain_overlay = Style {
bold: true,
..Default::default()
};
let merged = merge_styles(base, plain_overlay);
assert_eq!(merged.underline_color, Color::Indexed(6));
}
#[test]
fn out_of_viewport_spans_are_silently_skipped() {
let buf = Buffer::from_bytes(BufferId::next(), "t", b"x\n");

View File

@ -1683,34 +1683,34 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_five_for_pointer_events() {
fn protocol_version_is_six_for_underline_color() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
// bumped 3→4 (DispatchIdle for the optimistic-apply gate).
// The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer,
// byte-position gestures from semantic frontends). The
// current binary serves v1..=v5 sessions — the slice-
// membership handshake makes the relaxation symmetric.
assert_eq!(PROTOCOL_VERSION, 5);
// The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer).
// T M4.6 bumped 5→6 (`Style::underline_color`) — the first
// bump that changed an existing struct's postcard encoding,
// so v6 binaries serve v6 sessions only.
assert_eq!(PROTOCOL_VERSION, 6);
}
#[test]
fn supported_protocol_versions_includes_one_through_five() {
// T M10.5: v1.0 binaries accept v1+v2. T M11.1: v1.1 binaries
// accept v1+v2+v3. T M11.6: v4 binaries accept v1..=v4. Mouse
// framing Q#M1: v5 binaries accept v1..=v5. The check is
// slice membership, not strict equality, so older binaries
// keep connecting to current binaries unchanged. v6+ is
// rejected until the next bump.
assert!(is_supported_protocol_version(1));
assert!(is_supported_protocol_version(2));
assert!(is_supported_protocol_version(3));
assert!(is_supported_protocol_version(4));
assert!(is_supported_protocol_version(5));
assert!(!is_supported_protocol_version(0));
assert!(!is_supported_protocol_version(6));
assert!(!is_supported_protocol_version(u32::MAX));
fn supported_protocol_versions_is_exactly_v6() {
// T M4.6: `Style::underline_color` changed the encoding of
// every cell-carrying message (`Cell` / `CellDelta` /
// `Snapshot` / `StyleSpans`). The v1v5 compat ladder relied
// on shared-struct encodings never changing — additive enum
// variants filtered per session — so the ladder ends here:
// pre-v6 peers are refused at the handshake (a clean
// VersionMismatch) rather than garbling postcard mid-session.
assert!(is_supported_protocol_version(6));
for rejected in [0, 1, 2, 3, 4, 5, 7, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v6 binary"
);
}
}
#[test]
@ -2041,79 +2041,40 @@ mod tests {
}
// -----------------------------------------------------------------
// T M10.5 — backward-compat handshake matrix tests.
// Handshake version-policy tests.
//
// Four cases per the framing-pass handshake matrix:
// 1. v1 daemon ↔ v1 frontend: pre-existing behavior; not retested.
// 2. v1 daemon ↔ v2 frontend: rejected with VersionMismatch.
// 3. v2 daemon ↔ v1 frontend: success; v1 session.
// 4. v2 daemon ↔ v2 frontend: success; v2 session.
//
// These tests exercise `is_supported_protocol_version` directly
// since the full daemon-attach path requires socket setup that's
// in m5_5_acceptance.rs. The version-check predicate is the
// load-bearing piece; daemon-level integration tests are in the
// separate integration test file.
// History: T M10.5 introduced the slice-membership relaxation
// (`is_supported_protocol_version`) so v1.0 daemons could accept
// v0.1 frontends, and the ladder grew through v5 (T M11.1, T
// M11.6, mouse framing Q#M1) — all additive enum variants,
// filtered per session, with shared-struct encodings untouched.
// T M4.6 (`Style::underline_color`) changed a shared struct's
// postcard encoding, ending the ladder: v6 binaries accept only
// v6 peers. These tests exercise the predicate directly; the
// daemon-level handshake integration lives in m5_5_acceptance.rs.
// -----------------------------------------------------------------
#[test]
fn m10_5_handshake_matrix_v2_daemon_accepts_v1_frontend() {
// The relaxation that makes §sec:m10-backward-compat hold.
assert!(
is_supported_protocol_version(1),
"v2 daemon must accept v1 frontend per §sec:m10-backward-compat"
);
}
#[test]
fn m10_5_handshake_matrix_v2_daemon_accepts_v2_frontend() {
// The new case M10.5 enables.
assert!(
is_supported_protocol_version(2),
"v2 daemon must accept v2 frontend (the v1.0 happy path)"
);
}
#[test]
fn m10_5_handshake_matrix_versions_outside_range_rejected() {
// v1 daemon's strict-equality behavior is documented at the
// v0.1 code level (different binary); the current daemon's
// range check accepts v1..=v5 (T M11.1 added v3; T M11.6
// added v4; the mouse framing Q#M1 added v5) and rejects v6+
// until the next protocol bump.
assert!(!is_supported_protocol_version(0));
assert!(!is_supported_protocol_version(6));
assert!(!is_supported_protocol_version(u32::MAX));
}
#[test]
fn m10_5_strict_equality_v1_frontend_simulation() {
// T M10.5 framing-pass risk #5 verification: existing v1
// frontends (the v0.1.0 release codebase, pre-M10.5) do
// strict equality on Hello.protocol_version. Simulate that
// check explicitly so the audit doc has empirical evidence
// of the actual backward-compat surface.
//
// Before M10.5: `if hello.protocol_version != 1 { reject }`.
// After M10.5: `if !is_supported_protocol_version(...) { reject }`.
//
// For a v1-strict-frontend connecting to a v2 daemon: the
// daemon's Hello carries protocol_version=2; the v1-strict
// frontend rejects with VersionMismatch.
fn v1_strict_check(hello_version: u32) -> bool {
hello_version == 1
fn m4_6_handshake_rejects_every_pre_v6_wire() {
// A v5-or-older peer cannot decode v6 cell traffic (postcard
// is not self-describing), so the handshake must refuse the
// session up front with VersionMismatch — slice membership is
// how that policy is expressed.
for old in 1..=5 {
assert!(
!is_supported_protocol_version(old),
"v6 binary must refuse a v{old} peer: its Style encoding \
predates underline_color and would mis-decode every CellDelta"
);
}
// v1-strict frontend hitting v2 daemon's Hello: rejected.
}
#[test]
fn m4_6_handshake_accepts_v6_peer() {
assert!(
!v1_strict_check(2),
"v1-strict frontend rejects v2 daemon's Hello — pre-M10.5 binaries \
can NOT connect to v2 daemons even though v2 daemons accept their requests"
is_supported_protocol_version(PROTOCOL_VERSION),
"the current wire version must accept itself"
);
// v1-strict frontend hitting v1 daemon's Hello: accepted.
assert!(v1_strict_check(1));
// For comparison, M10.5's relaxed check (v2 frontend after this milestone):
assert!(is_supported_protocol_version(1));
assert!(is_supported_protocol_version(2));
}
// T M10.6 — PresenceUpdate wire shape tests.