Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- add `window.opacity` for a transparent background (compositor-dependent)

### Fixed
- honour SGR colon subparameters: `4:0` now turns underline off (was leaving the whole screen underlined) and `38:2:r:g:b` / `48:5:n` colours are applied

## [0.10.0] - 2026-07-25

### Added
Expand Down
40 changes: 36 additions & 4 deletions src/terminal/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ fn sgr_should_reset(ps: &[u16]) -> bool {
ps.is_empty() || (ps.len() == 1 && ps[0] == 0)
}

/// Colon-form colour: `38:5:n`, `38:2:r:g:b` or `38:2::r:g:b` (with colour-space id).
fn parse_color_from_subparams(g: &[u16], palette: &[Color; 16]) -> Option<Color> {
match g.get(1)? {
5 => g.get(2).map(|&n| color256(n as u8, palette)),
2 => {
// 6 entries means the colour-space id slot is present and must be skipped.
let start = if g.len() >= 6 { 3 } else { 2 };
let r = *g.get(start)? as u8;
let gr = *g.get(start + 1)? as u8;
let b = *g.get(start + 2)? as u8;
Some(Color::rgb(r, gr, b))
}
_ => None,
}
}

struct Performer<'a> {
grid: &'a mut Grid,
dcs_kind: Option<DcsKind>,
Expand Down Expand Up @@ -158,19 +174,34 @@ impl Performer<'_> {
}
}

fn handle_sgr(&mut self, ps: &[u16]) {
/// `groups[i]` holds parameter `i` with its colon-separated subparameters;
/// `ps[i] == groups[i][0]` is the flat view used by the legacy semicolon forms.
fn handle_sgr(&mut self, ps: &[u16], groups: &[&[u16]]) {
if sgr_should_reset(ps) {
self.grid.reset_sgr();
return;
}
let mut i = 0;
while i < ps.len() {
let sub = groups.get(i).map(|g| &g[..]).unwrap_or(&[]);
match ps[i] {
0 => self.grid.reset_sgr(),
// `4:0` disables underline; `4:1`..`4:5` pick a style we render as a
// plain underline. A bare `4` (no subparameter) also enables it.
4 => self.grid.underline = sub.get(1).copied().unwrap_or(1) != 0,
// Colon form of 38/48 — the whole colour lives in this one parameter.
38 | 48 if sub.len() > 1 => {
if let Some(c) = parse_color_from_subparams(sub, &self.grid.palette) {
if ps[i] == 38 {
self.grid.fg = c;
} else {
self.grid.bg = c;
}
}
}
1 => self.grid.bold = true,
2 => self.grid.dim = true,
3 => self.grid.italic = true,
4 => self.grid.underline = true,
5 => self.grid.blink = true,
7 => self.grid.reverse = true,
9 => self.grid.strikethrough = true,
Expand Down Expand Up @@ -265,7 +296,8 @@ impl Perform for Performer<'_> {
}

fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
let ps: Vec<u16> = params.iter().map(|p| p[0]).collect();
let groups: Vec<&[u16]> = params.iter().collect();
let ps: Vec<u16> = groups.iter().map(|g| g[0]).collect();
let p0 = ps.first().copied().unwrap_or(0);
let p1 = ps.get(1).copied().unwrap_or(0);

Expand All @@ -292,7 +324,7 @@ impl Perform for Performer<'_> {
}
'J' => self.handle_erase_display(p0),
'K' => self.handle_erase_line(p0),
'm' => self.handle_sgr(&ps),
'm' => self.handle_sgr(&ps, &groups),
'S' => self.grid.scroll_up(param_or_one(p0)),
'T' => self.grid.scroll_down(param_or_one(p0)),
// Insert Line
Expand Down
57 changes: 57 additions & 0 deletions src/terminal/parser_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,63 @@ fn sgr_underline_off_code_24() {
assert!(!p.grid.underline);
}

#[test]
fn sgr_underline_style_subparam_enables() {
let mut p = make_parser(10, 5);
p.process(b"\x1b[4:3m"); // curly underline
assert!(p.grid.underline);
}

#[test]
fn sgr_underline_subparam_zero_disables() {
let mut p = make_parser(10, 5);
p.process(b"\x1b[4:3m");
p.process(b"\x1b[4:0m"); // style "none" turns underline off
assert!(!p.grid.underline);
}

#[test]
fn sgr_underline_subparam_zero_disables_within_group() {
let mut p = make_parser(10, 5);
p.process(b"\x1b[4m");
p.process(b"\x1b[1;4:0;3m"); // mixed params around the colon form
assert!(!p.grid.underline);
assert!(p.grid.bold);
assert!(p.grid.italic);
}

#[test]
fn sgr_underline_colon_style_does_not_leak_into_italic() {
let mut p = make_parser(10, 5);
p.process(b"\x1b[4:3m"); // the ":3" must not be read as SGR 3
assert!(!p.grid.italic);
}

#[test]
fn sgr_truecolor_colon_form_foreground() {
let mut p = make_parser(10, 5);
p.process(b"\x1b[38:2:255:128:0m");
p.process(b"X");
assert_eq!(p.grid.cell(0, 0).fg, Color::rgb(255, 128, 0));
}

#[test]
fn sgr_truecolor_colon_form_with_colorspace_id() {
let mut p = make_parser(10, 5);
p.process(b"\x1b[38:2::255:128:0m");
p.process(b"X");
assert_eq!(p.grid.cell(0, 0).fg, Color::rgb(255, 128, 0));
}

#[test]
fn sgr_256_colon_form_background() {
let mut p = make_parser(10, 5);
p.process(b"\x1b[48:5:21m");
p.process(b"X");
let expected = color256(21, &[Color::BLACK; 16]);
assert_eq!(p.grid.cell(0, 0).bg, expected);
}

#[test]
fn sgr_256_color_foreground() {
let mut p = make_parser(10, 5);
Expand Down
39 changes: 39 additions & 0 deletions src/terminal/scenario_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,42 @@ fn scenario_osc8_link_spans_correct_cells() {
// " for more." → no URL
assert!(p.grid.cell(12, 0).url.is_none());
}

// ── Scenario 7: TUI that styles with SGR colon subparameters ────────────────
//
// Regression: a full-screen TUI (Claude Code's session picker) draws with
// `CSI 4:3 m` (curly underline) and turns it back off with `CSI 4:0 m`.
// When subparameters were dropped, `4:0` was read as a bare `4` and *enabled*
// underline instead — so every cell written after the TUI exited stayed
// underlined for the rest of the session.

#[test]
fn scenario_tui_curly_underline_does_not_leak_after_exit() {
let mut p = term(40, 5);

// TUI enters the alternate screen and draws an underlined entry
p.process(b"\x1b[?1049h");
p.process(b"\x1b[4:3msession 1\x1b[4:0m");
// ...and some truecolor text in colon form while it is up
p.process(b"\x1b[38:2:255:128:0m ok\x1b[0m");

// The underlined label is underlined; what follows `4:0` is not
assert!(p.grid.cell(0, 0).underline, "'s' of 'session 1'");
assert!(!p.grid.cell(9, 0).underline, "space after 4:0");
assert_eq!(p.grid.cell(10, 0).fg, Color::rgb(255, 128, 0));

// TUI exits back to the primary screen and the shell keeps printing
p.process(b"\x1b[?1049l");
p.process(b"back in the shell");

assert!(
!p.grid.underline,
"pen state must not be left underlined after the TUI exits"
);
for col in 0..17 {
assert!(
!p.grid.cell(col, 0).underline,
"col {col} of post-TUI output must not be underlined"
);
}
}