Merge pull request #64 from levineuwirth/session-m46-diagnostic-surface

M4.6 TUI diagnostic surface: mode-line counts, colored squiggles (protocol v6), line markers
This commit is contained in:
Levi Neuwirth 2026-06-12 10:00:05 -04:00 committed by GitHub
commit 50709412d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 651 additions and 120 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()
}
}
@ -377,6 +384,25 @@ fn style_for(severity: DiagnosticSeverity) -> Style {
}
}
/// Style of the column-0 line marker — the TUI's gutter sign
/// (T M4.6). The TUI reserves no gutter column, so the sign is a
/// severity-colored *background* on the line's first cell: the
/// glyph and its syntax color survive (the view contract is
/// style-only), and zero-width diagnostics — invisible to the
/// underline pass — still get a visible artifact.
fn marker_style_for(severity: DiagnosticSeverity) -> Style {
let bg = match severity {
DiagnosticSeverity::Error => Color::Indexed(1),
DiagnosticSeverity::Warning => Color::Indexed(3),
DiagnosticSeverity::Information => Color::Indexed(6),
DiagnosticSeverity::Hint => Color::Indexed(8),
};
Style {
bg,
..Style::default()
}
}
/// View that consumes the shared diagnostic store and underlines
/// affected ranges. Composes over [`crate::text_view::TextView`] per
/// the M2 view-composition contract --- never writes glyphs, only
@ -449,6 +475,12 @@ impl View for DiagnosticView {
let max_cols = viewport.cell_size.cols;
let cell_origin = viewport.cell_origin;
// Column-0 line markers (gutter signs, T M4.6): most severe
// diagnostic per visible row wins. `Ord` on the severity enum
// follows LSP numbering, so "most severe" is the minimum.
let mut line_markers: std::collections::HashMap<u32, DiagnosticSeverity> =
std::collections::HashMap::new();
for diag in &diags {
let style = style_for(diag.severity);
// Apply to each line the diagnostic touches. LSP ranges
@ -471,6 +503,13 @@ impl View for DiagnosticView {
if row_offset >= max_rows {
break;
}
// Record the line marker before any byte-range work:
// zero-width ranges (`byte_end <= byte_start` below)
// skip the underline but still mark the line.
line_markers
.entry(row_offset)
.and_modify(|s| *s = (*s).min(diag.severity))
.or_insert(diag.severity);
let line_start = line_offsets[line as usize];
let line_end = line_offsets
.get(line as usize + 1)
@ -497,16 +536,8 @@ impl View for DiagnosticView {
} else {
line_byte_len
};
if byte_end <= byte_start {
// Empty range on a line --- still flag the
// gutter character (the cell at column 0). For
// a multi-line diagnostic with end_col=0, this
// path is hit on the first / last line; the
// visible column already moved on.
continue;
}
let (start_col, end_col) =
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_end as usize);
underline_cols_for_line(line_bytes, byte_start, byte_end);
if end_col <= start_col {
continue;
}
@ -519,6 +550,19 @@ impl View for DiagnosticView {
}
}
}
// Paint the column-0 markers last so a marker is visible even
// when an underline span also touches column 0 (bg and
// underline merge independently).
if max_cols > 0 {
for (row_offset, severity) in line_markers {
let cell = cells.at(CellCoord::new(
cell_origin.row + row_offset,
cell_origin.col,
));
cell.style = merge_styles(cell.style, marker_style_for(severity));
}
}
}
}
@ -545,6 +589,25 @@ fn line_at_offset(line_offsets: &[u32], offset: u32) -> u32 {
}
}
/// Resolve the display-column span to underline for one line of a
/// diagnostic. Zero-width ranges — the shape parsers use for
/// "expected COMMA"-style errors anchored one past the last token
/// (rust-analyzer reports a missing comma as `col 12 → col 12` at
/// end of line, and the caller's `.min(line_byte_len)` clamps
/// collapse any past-EOL anchor the same way) — get a single-cell
/// span at the anchor: one past EOL is a blank cell inside the
/// window, and a squiggled space is exactly how other editors
/// surface it.
fn underline_cols_for_line(line_bytes: &[u8], byte_start: u32, byte_end: u32) -> (u32, u32) {
if byte_end <= byte_start {
let (anchor, _) =
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_start as usize);
(anchor, anchor + 1)
} else {
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_end as usize)
}
}
fn byte_range_to_display_cols(line_bytes: &[u8], byte_start: usize, byte_end: usize) -> (u32, u32) {
let bs = byte_start.min(line_bytes.len());
let be = byte_end.min(line_bytes.len());
@ -791,6 +854,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 +935,143 @@ mod tests {
"stale diagnostics must not underline shifted TUI bytes"
);
}
#[test]
fn column_zero_marker_shows_most_severe_diagnostic_per_line() {
use crate::cell::{Cell, CellSize, Glyph, UnderlineStyle};
let store = make_shared_store();
{
let mut guard = store.lock().expect("diag store");
// Line 0: a Hint and an Error overlap — the marker must
// show the Error. Line 1: a zero-width Warning (start ==
// end), invisible to the underline pass but still marked.
guard.set(
"file:///a",
vec![
diag(0, DiagnosticSeverity::Hint, "h"),
diag(0, DiagnosticSeverity::Error, "e"),
Diagnostic {
start_line: 1,
start_col: 2,
end_line: 1,
end_col: 2,
severity: DiagnosticSeverity::Warning,
message: "w".to_owned(),
source: None,
code: None,
},
],
);
}
let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.c");
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"hello\nworld\nclean\n",
})
.expect("seed buffer");
let mut view = DiagnosticView::new("file:///a", store);
let mut backing = vec![Cell::default(); 30];
// Pre-paint glyphs at column 0 to pin the style-only contract.
backing[0].glyph = Glyph::Char('h');
let mut grid = CellGrid {
cells: &mut backing,
stride: 10,
size: CellSize::new(3, 10),
};
view.render(
&buf,
Viewport {
buffer_start: 0,
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(3, 10),
},
&mut grid,
);
// Line 0: red (error) marker wins over the hint's gray; the
// glyph survives untouched.
assert_eq!(grid.get(CellCoord::new(0, 0)).style.bg, Color::Indexed(1));
assert_eq!(grid.get(CellCoord::new(0, 0)).glyph, Glyph::Char('h'));
// Line 1: zero-width warning gets a marker and a single-cell
// squiggle at its anchor column — and only there.
assert_eq!(grid.get(CellCoord::new(1, 0)).style.bg, Color::Indexed(3));
assert_eq!(
grid.get(CellCoord::new(1, 2)).style.underline,
UnderlineStyle::Curly
);
assert_eq!(
grid.get(CellCoord::new(1, 3)).style.underline,
UnderlineStyle::None
);
// Line 2: clean — no marker.
assert_eq!(grid.get(CellCoord::new(2, 0)).style.bg, Color::Default);
}
#[test]
fn end_of_line_zero_width_error_squiggles_the_cell_past_eol() {
// The missing-comma shape: rust-analyzer anchors "expected
// COMMA" as a zero-width range one past the line's last
// character (`b: 2` → col 12..12 on a 12-byte line). The
// squiggle must land on the blank cell just past EOL, not
// vanish in the empty-range clamp.
use crate::cell::{Cell, CellSize, UnderlineStyle};
let store = make_shared_store();
store.lock().expect("diag store").set(
"file:///a",
vec![Diagnostic {
start_line: 0,
start_col: 5, // one past "hello" (5 bytes)
end_line: 0,
end_col: 5,
severity: DiagnosticSeverity::Error,
message: "expected COMMA".to_owned(),
source: None,
code: None,
}],
);
let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.rs");
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"hello\nworld\n",
})
.expect("seed buffer");
let mut view = DiagnosticView::new("file:///a", store);
let mut backing = vec![Cell::default(); 20];
let mut grid = CellGrid {
cells: &mut backing,
stride: 10,
size: CellSize::new(2, 10),
};
view.render(
&buf,
Viewport {
buffer_start: 0,
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(2, 10),
},
&mut grid,
);
// Single-cell red squiggle on the blank cell past "hello".
let cell = grid.get(CellCoord::new(0, 5));
assert_eq!(cell.style.underline, UnderlineStyle::Curly);
assert_eq!(cell.style.underline_color, Color::Indexed(1));
// Nothing under the word itself or beyond the anchor.
assert_eq!(
grid.get(CellCoord::new(0, 4)).style.underline,
UnderlineStyle::None
);
assert_eq!(
grid.get(CellCoord::new(0, 6)).style.underline,
UnderlineStyle::None
);
}
}

View File

@ -1207,6 +1207,7 @@ pub fn paint_frame(
// Render every window.
let registry = core.registry.clone();
let reg = registry.borrow();
let diag_store = state.lsp_manager.borrow().diag_store();
for (id, window) in &mut core.windows {
let Some(rect) = placements.get(id).copied() else {
continue;
@ -1247,6 +1248,15 @@ pub fn paint_frame(
window.text_view.line_count(),
coord.row as usize,
);
// Lock scoped to the summary computation only: the overlay
// renders above include `DiagnosticView`, which takes this
// same mutex — holding the guard across the loop deadlocked
// the daemon on the first frame after a file (and thus a
// diagnostic overlay) was opened.
let diags = {
let guard = diag_store.lock().expect("diag store mutex poisoned");
diag_mode_line_summary(&guard, buf)
};
paint_mode_line(
grid,
&rect,
@ -1256,6 +1266,7 @@ pub fn paint_frame(
coord.row,
coord.col,
&scroll,
&diags,
);
}
drop(reg);
@ -1398,9 +1409,43 @@ fn paint_local_selection(
}
}
/// Format the mode-line diagnostic readout for a buffer: `"E:2 W:5"`
/// with only the nonzero severities (errors, then warnings; info and
/// hints stay off the mode line). Empty when the buffer has no file
/// path, no diagnostics, or the stored diagnostics are stale — the
/// document was edited since the last `publishDiagnostics`, so the
/// counts would describe text that no longer exists (T M4.6).
fn diag_mode_line_summary(
store: &crate::diag::DiagnosticStore,
buf: &crate::buffer::Buffer,
) -> String {
let Some(path) = buf.file_path() else {
return String::new();
};
let uri = crate::lsp::path_to_file_uri(path);
if store.is_stale(&uri) {
return String::new();
}
let mut errors = 0usize;
let mut warnings = 0usize;
for d in store.for_uri(&uri) {
match d.severity {
crate::diag::DiagnosticSeverity::Error => errors += 1,
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
_ => {}
}
}
match (errors, warnings) {
(0, 0) => String::new(),
(e, 0) => format!("E:{e}"),
(0, w) => format!("W:{w}"),
(e, w) => format!("E:{e} W:{w}"),
}
}
#[allow(
clippy::too_many_arguments,
reason = "the mode line packs eight unrelated facts; bundling them into a struct just adds ceremony"
reason = "the mode line packs nine unrelated facts; bundling them into a struct just adds ceremony"
)]
fn paint_mode_line(
grid: &mut crate::cell::CellGrid<'_>,
@ -1411,6 +1456,7 @@ fn paint_mode_line(
cursor_row: u32,
cursor_col: u32,
scroll: &str,
diags: &str,
) {
if rect.size.rows == 0 || rect.size.cols == 0 {
return;
@ -1419,7 +1465,11 @@ fn paint_mode_line(
let marker = if modified { '*' } else { ' ' };
let active_marker = if is_active { '+' } else { '-' };
let left = format!(" {active_marker}{marker} {name} ");
let right = format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1);
let right = if diags.is_empty() {
format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
} else {
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
};
// Fill the row with reverse-video spaces.
let mode_style = crate::cell::Style {
@ -5089,6 +5139,125 @@ mod tests {
}
}
/// Give the active buffer a file path and return its `file://`
/// URI, so diag-store entries can be keyed to it.
fn set_active_buffer_path(s: &EditorState, path: &str) -> String {
let core = s.core.borrow();
let registry = core.registry.clone();
let mut reg = registry.borrow_mut();
let buf = reg.get_mut(core.active_buffer_id()).unwrap();
buf.set_file_path(Some(std::path::PathBuf::from(path)));
crate::lsp::path_to_file_uri(buf.file_path().unwrap())
}
fn diag_with_severity(severity: crate::diag::DiagnosticSeverity) -> crate::diag::Diagnostic {
crate::diag::Diagnostic {
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 1,
severity,
message: "boom".into(),
source: None,
code: None,
}
}
#[test]
fn render_mode_line_shows_diagnostic_counts() {
use crate::diag::DiagnosticSeverity::{Error, Hint, Warning};
let s = fresh_with(b"hello\n");
let uri = set_active_buffer_path(&s, "/tmp/modeline_diag.rs");
let store = s.lsp_manager.borrow().diag_store();
store.lock().unwrap().set(
uri,
vec![
diag_with_severity(Error),
diag_with_severity(Error),
diag_with_severity(Warning),
diag_with_severity(Hint), // hints stay off the mode line
],
);
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let mode_text = row_text(&cells, stride, 22, 80);
assert!(
mode_text.contains("E:2 W:1"),
"mode line missing diagnostic counts: {mode_text:?}"
);
assert!(
!mode_text.contains("H:"),
"hints should not appear on the mode line: {mode_text:?}"
);
}
#[test]
fn render_mode_line_hides_diagnostic_counts_while_stale() {
use crate::diag::DiagnosticSeverity::Error;
let s = fresh_with(b"hello\n");
let uri = set_active_buffer_path(&s, "/tmp/modeline_stale.rs");
let store = s.lsp_manager.borrow().diag_store();
{
let mut guard = store.lock().unwrap();
guard.set(uri.clone(), vec![diag_with_severity(Error)]);
guard.mark_stale(uri);
}
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let mode_text = row_text(&cells, stride, 22, 80);
assert!(
!mode_text.contains("E:"),
"stale diagnostics must not reach the mode line: {mode_text:?}"
);
}
#[test]
fn render_with_attached_diagnostic_view_does_not_deadlock() {
// Regression: paint_frame once held the diag-store mutex
// across the whole window loop, and `DiagnosticView::render`
// (attached as a window overlay when a file with an LSP
// opens) locks the same mutex — the daemon froze on the
// first frame after C-x C-f. This test renders the full
// paint_frame path with a real DiagnosticView attached; it
// hangs the suite if the lock is ever widened again.
use crate::diag::DiagnosticSeverity::Error;
let s = fresh_with(b"hello\n");
let uri = set_active_buffer_path(&s, "/tmp/modeline_overlay.rs");
let store = s.lsp_manager.borrow().diag_store();
store
.lock()
.unwrap()
.set(uri.clone(), vec![diag_with_severity(Error)]);
{
let mut core = s.core.borrow_mut();
core.active_window_mut()
.push_overlay(Box::new(crate::diag::DiagnosticView::new(uri, store)));
}
let (cells, stride, _) = render_to_grid(&s, 24, 80);
// Both surfaces of the same store: the overlay's underline
// and the mode line's count.
assert_eq!(
cells[0].style.underline,
crate::cell::UnderlineStyle::Curly,
"diagnostic overlay should underline the error range"
);
let mode_text = row_text(&cells, stride, 22, 80);
assert!(
mode_text.contains("E:1"),
"mode line missing count: {mode_text:?}"
);
}
#[test]
fn render_mode_line_omits_diagnostic_counts_when_clean() {
let s = fresh_with(b"hello\n");
let _uri = set_active_buffer_path(&s, "/tmp/modeline_clean.rs");
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let mode_text = row_text(&cells, stride, 22, 80);
assert!(
!mode_text.contains("E:") && !mode_text.contains("W:"),
"clean buffer must not show diagnostic counts: {mode_text:?}"
);
}
#[test]
fn render_places_cursor_on_active_window() {
let mut s = fresh_with(b"abc\n");

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.

View File

@ -494,6 +494,18 @@ mod tests {
}
handles.push(h);
}
// Drop may discard still-queued work after setting shutdown,
// so on a loaded CI runner it can win the race before any
// worker finishes even one job, flaking the `count > 0`
// assert below (observed on the macOS runner). ~2/3 of the
// queue is non-cancelled, so one completion must land unless
// workers are wedged — wait for it, bounded.
let wait_start = std::time::Instant::now();
while completed.load(Ordering::Relaxed) == 0
&& wait_start.elapsed() < std::time::Duration::from_secs(10)
{
std::thread::yield_now();
}
// Drain shutdown synchronously via Drop: this returns only
// after every queued, non-cancelled job has run (or every
// cancelled job has either run-then-noop or been silently