diff --git a/Cargo.lock b/Cargo.lock index 11a5b65e..848fb81b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -612,6 +612,7 @@ name = "embers-core" version = "0.2.0" dependencies = [ "serde", + "serde_json", "thiserror 2.0.18", "tracing", "tracing-appender", diff --git a/crates/embers-cli/tests/interactive.rs b/crates/embers-cli/tests/interactive.rs index f18460f5..2c458f53 100644 --- a/crates/embers-cli/tests/interactive.rs +++ b/crates/embers-cli/tests/interactive.rs @@ -634,9 +634,11 @@ async fn embers_without_subcommand_starts_server_and_client() { .read_until_contains("[main]", STARTUP_TIMEOUT) .expect("client starts and renders"); - let output = run_pane_command(&mut harness, "embers list-sessions", "1\tmain"); + // The pane renders tab cells as spaces (styled-snapshot projection), so the + // tab-separated `1\tmain` shows with the tab expanded to the next tab stop. + let output = run_pane_command(&mut harness, "embers list-sessions", "1 main"); assert!( - output.contains("1\tmain"), + output.contains("1 main"), "expected list-sessions output in pane:\n{output}" ); @@ -700,9 +702,10 @@ async fn attach_subcommand_connects_to_running_server() { .read_until_contains("[main]", STARTUP_TIMEOUT) .expect("attach client renders"); - let output = run_pane_command(&mut harness, "embers list-sessions", "1\tmain"); + // The pane renders tab cells as spaces (styled-snapshot projection). + let output = run_pane_command(&mut harness, "embers list-sessions", "1 main"); assert!( - output.contains("1\tmain"), + output.contains("1 main"), "expected list-sessions output in attached pane:\n{output}" ); @@ -1372,7 +1375,7 @@ async fn fullscreen_terminal_transitions_render_in_the_live_client_pty() { && snapshot .lines .iter() - .any(|line| line.contains("PTY-FULLSCREEN")) + .any(|line| line.text.contains("PTY-FULLSCREEN")) }) .await; assert!(live.alternate_screen); @@ -1385,7 +1388,7 @@ async fn fullscreen_terminal_transitions_render_in_the_live_client_pty() { && snapshot .lines .iter() - .any(|line| line.contains("PTY-RESTORED")) + .any(|line| line.text.contains("PTY-RESTORED")) }) .await; assert!(!restored.alternate_screen); diff --git a/crates/embers-client/src/grid.rs b/crates/embers-client/src/grid.rs index de8d9137..9393b57d 100644 --- a/crates/embers-client/src/grid.rs +++ b/crates/embers-client/src/grid.rs @@ -1,6 +1,6 @@ use std::fmt::Write; -use embers_core::{CursorShape, Rect}; +use embers_core::{CellAttrs, CursorShape, Rect, SnapshotLine, StyledRun, TermColor}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -21,16 +21,33 @@ impl From for Color { } } +/// A cell color as it travels toward SGR output. +/// +/// Indexed colors emit `38;5;n` / `48;5;n` so the outer terminal's palette +/// resolves them; only true-color values carry explicit RGB. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalColor { + Rgb(Color), + Indexed(u8), +} + +impl From for TerminalColor { + fn from(value: Color) -> Self { + Self::Rgb(value) + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct CellStyle { - pub fg: Option, - pub bg: Option, + pub fg: Option, + pub bg: Option, pub bold: bool, pub italic: bool, pub underline: bool, pub dim: bool, pub reverse: bool, pub blink: bool, + pub strikeout: bool, } impl CellStyle { @@ -62,14 +79,15 @@ impl CellStyle { impl From<&crate::scripting::StyleSpec> for CellStyle { fn from(value: &crate::scripting::StyleSpec) -> Self { Self { - fg: value.fg.map(Into::into), - bg: value.bg.map(Into::into), + fg: value.fg.map(|color| TerminalColor::Rgb(color.into())), + bg: value.bg.map(|color| TerminalColor::Rgb(color.into())), bold: value.bold, italic: value.italic, underline: value.underline, dim: value.dim, reverse: false, blink: value.blink, + strikeout: false, } } } @@ -184,6 +202,92 @@ impl RenderGrid { } } + /// Draw a styled snapshot line, mapping each grapheme to the style of the + /// run that contains its first byte. + /// + /// Mirrors [`truncate`]'s column budget: when the text is wider than `width`, + /// it draws up to `width - 1` columns then a default-styled `~` marker. + /// Hidden runs draw spaces (keeping fg/bg) so the glyph is blanked. + pub fn put_snapshot_line(&mut self, x: u16, y: u16, width: u16, line: &SnapshotLine) { + if width == 0 || y >= self.height || x >= self.width { + return; + } + + let truncated = UnicodeWidthStr::width(line.text.as_str()) > usize::from(width); + let budget = if truncated { + width.saturating_sub(1) + } else { + width + }; + + let mut column = 0_u16; + let mut byte_offset = 0_usize; + let mut runs = RunCursor::new(&line.runs); + for grapheme in UnicodeSegmentation::graphemes(line.text.as_str(), true) { + let grapheme_width = grapheme_width(grapheme); + if column.saturating_add(grapheme_width) > budget { + break; + } + + let run = runs.run_at(byte_offset); + let style = run.map(style_for_run).unwrap_or_default(); + let hidden = run.is_some_and(|run| run.attrs.contains(CellAttrs::HIDDEN)); + + let draw_x = x.saturating_add(column); + if hidden { + self.put_str_styled(draw_x, y, &" ".repeat(usize::from(grapheme_width)), style); + } else { + self.put_str_styled(draw_x, y, grapheme, style); + } + + column = column.saturating_add(grapheme_width); + byte_offset += grapheme.len(); + } + + if truncated { + self.put_char_styled(x.saturating_add(column), y, '~', CellStyle::default()); + } + } + + /// Recolor an existing span of cells in place by applying `restyle` to each + /// cell's current style. + /// + /// Columns are pane-relative to `x`. The span snaps outward across wide-char + /// continuation cells so a lead and its continuation are always restyled + /// together. Used by overlays (selection, search) so they compose on top of + /// content styling instead of replacing it. + pub fn restyle_range( + &mut self, + x: u16, + y: u16, + start_col: u16, + end_col: u16, + restyle: impl Fn(CellStyle) -> CellStyle, + ) { + if y >= self.height || start_col >= end_col { + return; + } + let abs_start = x.saturating_add(start_col).min(self.width); + let abs_end = x.saturating_add(end_col).min(self.width); + if abs_start >= abs_end { + return; + } + + let mut start = abs_start; + while start > 0 && self.cells[self.index(start, y)].continuation { + start -= 1; + } + let mut end = abs_end; + while end < self.width && self.cells[self.index(end, y)].continuation { + end += 1; + } + + for column in start..end { + let idx = self.index(column, y); + self.cells[idx].style = restyle(self.cells[idx].style); + } + } + pub fn draw_hline(&mut self, x: u16, y: u16, width: u16, ch: char) { self.draw_hline_styled(x, y, width, ch, CellStyle::default()); } @@ -381,6 +485,66 @@ fn grapheme_width(grapheme: &str) -> u16 { u16::try_from(width.max(1)).unwrap_or(u16::MAX) } +/// Walks run-length style annotations in step with a byte cursor that only +/// advances, resolving the run covering a given byte offset in amortized O(1). +struct RunCursor<'a> { + runs: &'a [StyledRun], + index: usize, + run_end: usize, +} + +impl<'a> RunCursor<'a> { + fn new(runs: &'a [StyledRun]) -> Self { + let run_end = runs.first().map_or(0, |run| run.len as usize); + Self { + runs, + index: 0, + run_end, + } + } + + /// The run containing `byte_offset`, or `None` past the last run (plain). + fn run_at(&mut self, byte_offset: usize) -> Option<&'a StyledRun> { + while self.index < self.runs.len() && byte_offset >= self.run_end { + self.index += 1; + if let Some(run) = self.runs.get(self.index) { + self.run_end += run.len as usize; + } + } + self.runs.get(self.index) + } +} + +/// Map a content style run to a renderable [`CellStyle`]. Double underline folds +/// to a single underline (the client has no distinct double-underline SGR). +pub(crate) fn style_for_run(run: &StyledRun) -> CellStyle { + let attrs = run.attrs; + CellStyle { + fg: term_color_to_cell(run.fg), + bg: term_color_to_cell(run.bg), + bold: attrs.contains(CellAttrs::BOLD), + italic: attrs.contains(CellAttrs::ITALIC), + underline: attrs.contains(CellAttrs::UNDERLINE) + || attrs.contains(CellAttrs::DOUBLE_UNDERLINE), + dim: attrs.contains(CellAttrs::DIM), + reverse: attrs.contains(CellAttrs::INVERSE), + blink: false, + strikeout: attrs.contains(CellAttrs::STRIKEOUT), + } +} + +fn term_color_to_cell(color: TermColor) -> Option { + match color { + TermColor::Default => None, + TermColor::Indexed(index) => Some(TerminalColor::Indexed(index)), + TermColor::Rgb { r, g, b } => Some(TerminalColor::Rgb(Color { + red: r, + green: g, + blue: b, + })), + } +} + fn write_style_transition(output: &mut String, from: CellStyle, to: CellStyle) { if from == to { return; @@ -404,11 +568,36 @@ fn write_style_transition(output: &mut String, from: CellStyle, to: CellStyle) { if to.reverse { output.push_str("\x1b[7m"); } + if to.strikeout { + output.push_str("\x1b[9m"); + } if let Some(fg) = to.fg { - let _ = write!(output, "\x1b[38;2;{};{};{}m", fg.red, fg.green, fg.blue); + match fg { + TerminalColor::Rgb(color) => { + let _ = write!( + output, + "\x1b[38;2;{};{};{}m", + color.red, color.green, color.blue + ); + } + TerminalColor::Indexed(index) => { + let _ = write!(output, "\x1b[38;5;{index}m"); + } + } } if let Some(bg) = to.bg { - let _ = write!(output, "\x1b[48;2;{};{};{}m", bg.red, bg.green, bg.blue); + match bg { + TerminalColor::Rgb(color) => { + let _ = write!( + output, + "\x1b[48;2;{};{};{}m", + color.red, color.green, color.blue + ); + } + TerminalColor::Indexed(index) => { + let _ = write!(output, "\x1b[48;5;{index}m"); + } + } } } @@ -448,8 +637,123 @@ impl BorderStyle { #[cfg(test)] mod tests { - use super::{CellStyle, Color, GridCursor, RenderGrid}; - use embers_core::{CursorShape, Point, Rect, Size}; + use super::{Cell, CellStyle, Color, GridCursor, RenderGrid, TerminalColor}; + use embers_core::{ + CellAttrs, CursorShape, Point, Rect, Size, SnapshotLine, StyledRun, TermColor, + }; + + /// Read a cell directly (the test module descends from the grid module, so + /// the private cell storage is visible here). + fn cell(grid: &RenderGrid, x: u16, y: u16) -> &Cell { + &grid.cells[grid.index(x, y)] + } + + fn one_run_line(text: &str, fg: TermColor, attrs: u16) -> SnapshotLine { + SnapshotLine { + text: text.to_owned(), + runs: vec![StyledRun { + len: text.len() as u32, + fg, + bg: TermColor::Default, + attrs: CellAttrs(attrs), + }], + } + } + + #[test] + fn put_snapshot_line_truncates_with_default_styled_marker() { + let mut grid = RenderGrid::new(4, 1); + // Whole line is styled red and overflows width 4, so it draws three + // styled columns then a default-styled `~` in the reserved last column. + grid.put_snapshot_line(0, 0, 4, &one_run_line("abcdef", TermColor::Indexed(5), 0)); + + assert_eq!(grid.lines()[0], "abc~"); + assert_eq!(cell(&grid, 0, 0).text, "a"); + assert_eq!( + cell(&grid, 0, 0).style.fg, + Some(TerminalColor::Indexed(5)), + "styled content keeps its color" + ); + assert_eq!(cell(&grid, 3, 0).text, "~"); + assert_eq!( + cell(&grid, 3, 0).style, + CellStyle::default(), + "the truncation marker is default-styled" + ); + } + + #[test] + fn put_snapshot_line_blanks_hidden_run_preserving_width() { + let mut grid = RenderGrid::new(4, 1); + // A hidden wide grapheme followed by a visible plain one. The wide char + // is blanked to spaces but still occupies two columns, so `x` lands at + // column 2; the blanked cells keep the run's foreground. + let line = SnapshotLine { + text: "界x".to_owned(), + runs: vec![ + StyledRun { + len: "界".len() as u32, + fg: TermColor::Indexed(1), + bg: TermColor::Default, + attrs: CellAttrs(CellAttrs::HIDDEN), + }, + StyledRun { + len: 1, + fg: TermColor::Default, + bg: TermColor::Default, + attrs: CellAttrs::empty(), + }, + ], + }; + grid.put_snapshot_line(0, 0, 4, &line); + + assert_eq!(grid.lines()[0], " x "); + assert_eq!(cell(&grid, 0, 0).text, " "); + assert_eq!(cell(&grid, 1, 0).text, " "); + assert!( + !cell(&grid, 0, 0).continuation && !cell(&grid, 1, 0).continuation, + "blanked cells are independent spaces, not a wide-char pair" + ); + assert_eq!(cell(&grid, 0, 0).style.fg, Some(TerminalColor::Indexed(1))); + assert_eq!(cell(&grid, 1, 0).style.fg, Some(TerminalColor::Indexed(1))); + assert_eq!(cell(&grid, 2, 0).text, "x"); + assert_eq!(cell(&grid, 2, 0).style, CellStyle::default()); + } + + #[test] + fn restyle_range_snaps_both_sides_of_a_wide_grapheme() { + let mark = |style: CellStyle| CellStyle { + reverse: true, + ..style + }; + + // Snap the start backward: the range names only the continuation column + // (2), so the lead (1) is pulled in and both halves are restyled. + let mut grid = RenderGrid::new(6, 1); + grid.put_str(0, 0, "a界b"); + grid.restyle_range(0, 0, 2, 3, mark); + assert!(!cell(&grid, 0, 0).style.reverse, "'a' untouched"); + assert!(cell(&grid, 1, 0).style.reverse, "wide lead restyled"); + assert!( + cell(&grid, 2, 0).style.reverse, + "wide continuation restyled" + ); + assert!(!cell(&grid, 1, 0).continuation && cell(&grid, 2, 0).continuation); + assert!(!cell(&grid, 3, 0).style.reverse, "'b' untouched"); + + // Snap the end forward: the range names only the lead column (1), so the + // continuation (2) is pulled in and both halves are restyled. + let mut grid = RenderGrid::new(6, 1); + grid.put_str(0, 0, "a界b"); + grid.restyle_range(0, 0, 1, 2, mark); + assert!(!cell(&grid, 0, 0).style.reverse, "'a' untouched"); + assert!(cell(&grid, 1, 0).style.reverse, "wide lead restyled"); + assert!( + cell(&grid, 2, 0).style.reverse, + "wide continuation restyled" + ); + assert!(!cell(&grid, 3, 0).style.reverse, "'b' untouched"); + } #[test] fn render_preserves_plain_text_rows() { @@ -468,11 +772,11 @@ mod tests { 0, "ab", CellStyle { - fg: Some(Color { + fg: Some(TerminalColor::Rgb(Color { red: 1, green: 2, blue: 3, - }), + })), bold: true, ..CellStyle::default() }, @@ -484,6 +788,27 @@ mod tests { assert!(line.contains("ab")); } + #[test] + fn ansi_lines_emit_indexed_and_strikeout() { + let mut grid = RenderGrid::new(4, 1); + grid.put_str_styled( + 0, + 0, + "x", + CellStyle { + fg: Some(TerminalColor::Indexed(5)), + bg: Some(TerminalColor::Indexed(12)), + strikeout: true, + ..CellStyle::default() + }, + ); + + let line = &grid.ansi_lines()[0]; + assert!(line.contains("\x1b[38;5;5m"), "line: {line:?}"); + assert!(line.contains("\x1b[48;5;12m"), "line: {line:?}"); + assert!(line.contains("\x1b[9m"), "line: {line:?}"); + } + #[test] fn wide_graphemes_preserve_cell_alignment() { let mut grid = RenderGrid::new(4, 1); diff --git a/crates/embers-client/src/lib.rs b/crates/embers-client/src/lib.rs index ecd978f0..68021acc 100644 --- a/crates/embers-client/src/lib.rs +++ b/crates/embers-client/src/lib.rs @@ -22,7 +22,7 @@ pub use configured_client::ConfiguredClient; pub use controller::{ Controller, KeyEvent, MouseButton, MouseEvent, MouseEventKind, MouseModifiers, }; -pub use grid::{BorderStyle, CellStyle, Color, GridCursor, RenderGrid}; +pub use grid::{BorderStyle, CellStyle, Color, GridCursor, RenderGrid, TerminalColor}; pub use input::{ BindingMatch, BindingSpec, COPY_MODE, FallbackPolicy, InputResolution, InputState, KeyParseError, KeySequence, KeyToken, ModeSpec, NORMAL_MODE, SEARCH_MODE, SELECT_MODE, diff --git a/crates/embers-client/src/renderer.rs b/crates/embers-client/src/renderer.rs index c6ea613d..ce8d311f 100644 --- a/crates/embers-client/src/renderer.rs +++ b/crates/embers-client/src/renderer.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use embers_core::{ActivityState, Point, Rect, Size}; +use embers_core::{ActivityState, Point, Rect, Size, SnapshotLine}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -211,7 +211,7 @@ impl Renderer { rendered_lines.map_or(0, |lines| { let significant_len = lines .iter() - .rposition(|line| !line.is_empty()) + .rposition(|line| !line.text.is_empty()) .map(|index| index + 1) .unwrap_or(0); significant_len.saturating_sub(content_rows) @@ -237,7 +237,7 @@ impl Renderer { let Some(row) = u16::try_from(row).ok() else { break; }; - grid.put_str(x, y + 1 + row, &truncate(line, width)); + grid.put_snapshot_line(x, y + 1 + row, width, line); } } @@ -387,7 +387,7 @@ fn render_search_overlay( y: u16, width: u16, top_line: u64, - lines: &[String], + lines: &[SnapshotLine], search_state: &crate::state::SearchState, ) { let Some(active_index) = search_state.active_match_index else { @@ -404,22 +404,21 @@ fn render_search_overlay( if relative_row >= u16::try_from(lines.len()).unwrap_or(u16::MAX) { continue; } - let line = &lines[usize::from(relative_row)]; - overlay_display_range( - grid, - OverlayLine { - x, - y: y.saturating_add(relative_row), - width, - text: line, - }, - search_match.start_column, - search_match.end_column, - if index == active_index { - active_search_style() - } else { - search_style() - }, + let start_column = search_match.start_column.min(width); + let end_column = search_match.end_column.min(width); + let overlay = if index == active_index { + active_search_style() + } else { + search_style() + }; + // Search highlights compose: OR the overlay's attributes onto the content + // style and only override fg/bg where the overlay defines them. + grid.restyle_range( + x, + y.saturating_add(relative_row), + start_column, + end_column, + |base| compose_overlay(base, overlay), ); } } @@ -430,7 +429,7 @@ fn render_selection_overlay( y: u16, width: u16, top_line: u64, - lines: &[String], + lines: &[SnapshotLine], selection_state: &SelectionState, ) { for (row, line) in lines.iter().enumerate() { @@ -439,25 +438,41 @@ fn render_selection_overlay( }; let line_number = top_line.saturating_add(u64::try_from(row).unwrap_or(u64::MAX)); let Some((start_column, end_column)) = - selection_range_for_line(selection_state, line_number, width, line) + selection_range_for_line(selection_state, line_number, width, &line.text) else { continue; }; - overlay_display_range( - grid, - OverlayLine { - x, - y: y.saturating_add(row_u16), - width, - text: line, + // Selection toggles reverse on the underlying content style so colored + // text stays colored while the selection reads as inverted. + grid.restyle_range( + x, + y.saturating_add(row_u16), + start_column.min(width), + end_column.min(width), + |base| CellStyle { + reverse: !base.reverse, + ..base }, - start_column, - end_column, - selection_style(), ); } } +/// Compose an overlay style onto a base content style: attributes are OR-ed and +/// the overlay's colors win only where it defines them. +fn compose_overlay(base: CellStyle, overlay: CellStyle) -> CellStyle { + CellStyle { + fg: overlay.fg.or(base.fg), + bg: overlay.bg.or(base.bg), + bold: base.bold || overlay.bold, + italic: base.italic || overlay.italic, + underline: base.underline || overlay.underline, + dim: base.dim || overlay.dim, + reverse: base.reverse || overlay.reverse, + blink: base.blink || overlay.blink, + strikeout: base.strikeout || overlay.strikeout, + } +} + fn selection_range_for_line( selection_state: &SelectionState, line_number: u64, @@ -517,43 +532,6 @@ fn ordered_points(left: SelectionPoint, right: SelectionPoint) -> (SelectionPoin } } -struct OverlayLine<'a> { - x: u16, - y: u16, - width: u16, - text: &'a str, -} - -fn overlay_display_range( - grid: &mut RenderGrid, - line: OverlayLine<'_>, - start_column: u16, - end_column: u16, - style: CellStyle, -) { - if start_column >= end_column || line.width == 0 { - return; - } - - let visible_end = end_column.min(line.width); - let mut column = 0_u16; - for grapheme in UnicodeSegmentation::graphemes(line.text, true) { - let grapheme_width = display_width(grapheme).max(1); - let next_column = column.saturating_add(grapheme_width); - if next_column > start_column && column < visible_end { - grid.put_str_styled(line.x.saturating_add(column), line.y, grapheme, style); - } - column = next_column; - if column >= visible_end { - return; - } - } - - for column in column.max(start_column)..visible_end { - grid.put_char_styled(line.x.saturating_add(column), line.y, ' ', style); - } -} - fn format_tab_label(tab: &crate::presentation::TabItem, width: u16) -> String { if width == 0 { return String::new(); @@ -710,10 +688,3 @@ fn active_search_style() -> CellStyle { ..CellStyle::default() } } - -fn selection_style() -> CellStyle { - CellStyle { - reverse: true, - ..CellStyle::default() - } -} diff --git a/crates/embers-client/src/scripting/context.rs b/crates/embers-client/src/scripting/context.rs index d842d1ee..a5745ad3 100644 --- a/crates/embers-client/src/scripting/context.rs +++ b/crates/embers-client/src/scripting/context.rs @@ -160,7 +160,13 @@ impl Context { let snapshot_lines = state .snapshots .get(&buffer.id) - .map(|snapshot| snapshot.lines.clone()) + .map(|snapshot| { + snapshot + .lines + .iter() + .map(|line| line.text.clone()) + .collect() + }) .unwrap_or_default(); ( buffer.id, diff --git a/crates/embers-client/src/state.rs b/crates/embers-client/src/state.rs index b0864c68..33542005 100644 --- a/crates/embers-client/src/state.rs +++ b/crates/embers-client/src/state.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use embers_core::{BufferId, NodeId, SessionId}; +use embers_core::{BufferId, NodeId, SessionId, SnapshotLine}; use embers_protocol::NodeRecordKind; use embers_protocol::{ BufferRecord, ServerEvent, SessionRecord, SessionSnapshot, VisibleSnapshotResponse, @@ -48,7 +48,7 @@ pub struct BufferViewState { pub visible_line_count: u16, pub total_line_count: u64, pub alternate_screen: bool, - pub visible_lines: Vec, + pub visible_lines: Vec, pub search_state: Option, pub selection_state: Option, } @@ -292,7 +292,7 @@ impl ClientState { &mut self, node_id: NodeId, scroll_top_line: u64, - lines: Vec, + lines: Vec, ) -> Option<()> { let state = self.view_state.get_mut(&node_id)?; let scroll_top_line = clamp_top_line( diff --git a/crates/embers-client/tests/configured_client.rs b/crates/embers-client/tests/configured_client.rs index 0e8d1506..5d8757a9 100644 --- a/crates/embers-client/tests/configured_client.rs +++ b/crates/embers-client/tests/configured_client.rs @@ -6,7 +6,9 @@ use embers_client::{ ConfigDiscoveryOptions, ConfigManager, ConfiguredClient, FakeTransport, KeyEvent, MouseButton, MouseEvent, MouseEventKind, MouseModifiers, MuxClient, PresentationModel, ScriptedTransport, }; -use embers_core::{ActivityState, BufferId, NodeId, PtySize, RequestId, SessionId, Size}; +use embers_core::{ + ActivityState, BufferId, NodeId, PtySize, RequestId, SessionId, Size, SnapshotLine, +}; use embers_protocol::{ BufferCreatedEvent, BufferRecord, BufferRecordKind, BufferRecordState, BufferResponse, BufferViewRecord, BuffersResponse, ClientChangedEvent, ClientMessage, ClientRecord, @@ -111,7 +113,10 @@ fn scrollback_slice_response( buffer_id, start_line, total_lines, - lines: lines.iter().map(|line| (*line).to_owned()).collect(), + lines: lines + .iter() + .map(|line| SnapshotLine::plain(*line)) + .collect(), } } @@ -227,7 +232,7 @@ fn second_session_state() -> embers_client::ClientState { buffer_id: SECOND_BUFFER_ID, sequence: 1, size: PtySize::new(80, 20), - lines: vec!["other pane".to_owned()], + lines: vec![SnapshotLine::plain("other pane")], title: Some("other pane".to_owned()), cwd: None, viewport_top_line: 0, @@ -676,7 +681,10 @@ async fn page_up_scrolls_locally_with_scrollback_slices() { let snapshot = state.snapshots.get_mut(&BufferId(4)).unwrap(); snapshot.total_lines = 60; snapshot.viewport_top_line = 36; - snapshot.lines = vec!["tail one".to_owned(), "tail two".to_owned()]; + snapshot.lines = vec![ + SnapshotLine::plain("tail one"), + SnapshotLine::plain("tail two"), + ]; let view = state.view_state_mut(FOCUSED_LEAF_ID).unwrap(); view.total_line_count = 60; view.scroll_top_line = 36; @@ -706,7 +714,7 @@ async fn page_up_scrolls_locally_with_scrollback_slices() { .expect("focused view state"); assert_eq!(view.scroll_top_line, 12); assert!(!view.follow_output); - assert_eq!(view.visible_lines[0], "history line"); + assert_eq!(view.visible_lines[0].text, "history line"); assert!(matches!( transport.requests()[0], ClientMessage::Buffer(embers_protocol::BufferRequest::ScrollbackSlice { @@ -1303,7 +1311,7 @@ async fn render_session_refreshes_invalidated_snapshot_before_rendering_title_an .snapshots .get_mut(&FOCUSED_BUFFER_ID) .unwrap(); - snapshot.lines = vec!["fresh render line".to_owned()]; + snapshot.lines = vec![SnapshotLine::plain("fresh render line")]; snapshot.title = Some("fresh-title".to_owned()); transport.push_response(ServerResponse::VisibleSnapshot( @@ -1374,7 +1382,7 @@ async fn render_session_replaces_stale_scrolled_cache_when_snapshot_switches_to_ view.follow_output = false; view.scroll_top_line = 12; view.total_line_count = 60; - view.visible_lines = vec!["stale scrolled line".to_owned()]; + view.visible_lines = vec![SnapshotLine::plain("stale scrolled line")]; stale_state.apply_event(&ServerEvent::RenderInvalidated(RenderInvalidatedEvent { buffer_id: FOCUSED_BUFFER_ID, })); @@ -1384,7 +1392,7 @@ async fn render_session_replaces_stale_scrolled_cache_when_snapshot_switches_to_ .snapshots .get_mut(&FOCUSED_BUFFER_ID) .unwrap(); - snapshot.lines = vec!["alternate screen live".to_owned()]; + snapshot.lines = vec![SnapshotLine::plain("alternate screen live")]; snapshot.alternate_screen = true; snapshot.viewport_top_line = 0; snapshot.total_lines = 24; @@ -1419,7 +1427,10 @@ async fn render_session_replaces_stale_scrolled_cache_when_snapshot_switches_to_ .view_state(FOCUSED_LEAF_ID) .expect("focused view state"); assert!(view.alternate_screen); - assert_eq!(view.visible_lines, vec!["alternate screen live".to_owned()]); + assert_eq!( + view.visible_lines, + vec![SnapshotLine::plain("alternate screen live")] + ); } #[tokio::test] diff --git a/crates/embers-client/tests/e2e.rs b/crates/embers-client/tests/e2e.rs index a6a1e2ac..9fc02fc2 100644 --- a/crates/embers-client/tests/e2e.rs +++ b/crates/embers-client/tests/e2e.rs @@ -11,6 +11,15 @@ use embers_protocol::{ use embers_test_support::{TestConnection, TestServer, cargo_bin}; use tokio::time::{Duration, Instant}; +/// Join styled snapshot lines into newline-delimited plain text for assertions. +fn lines_text(lines: &[embers_core::SnapshotLine]) -> String { + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>() + .join("\n") +} + fn run_cli(server: &TestServer, args: &[&str]) -> Output { let output = cargo_bin("embers") .arg("--socket") @@ -1063,7 +1072,7 @@ async fn fullscreen_fixture_enters_alternate_screen_and_restores_primary_screen( buffer.id, Duration::from_secs(3), |snapshot| { - let text = snapshot.lines.join("\n"); + let text = lines_text(&snapshot.lines); snapshot.alternate_screen && snapshot.title.as_deref() == Some("fullscreen-live-title") && text.contains("fullscreen-live") @@ -1071,7 +1080,7 @@ async fn fullscreen_fixture_enters_alternate_screen_and_restores_primary_screen( }, ) .await; - let live_text = live.lines.join("\n"); + let live_text = lines_text(&live.lines); assert!(!live_text.contains("main-before")); let mut client = MuxClient::connect(server.socket_path()) @@ -1087,7 +1096,7 @@ async fn fullscreen_fixture_enters_alternate_screen_and_restores_primary_screen( buffer.id, Duration::from_secs(4), |snapshot| { - let text = snapshot.lines.join("\n"); + let text = lines_text(&snapshot.lines); !snapshot.alternate_screen && snapshot.title.as_deref() == Some("primary-restored-title") && text.contains("main-before") @@ -1095,7 +1104,7 @@ async fn fullscreen_fixture_enters_alternate_screen_and_restores_primary_screen( }, ) .await; - let restored_text = restored.lines.join("\n"); + let restored_text = lines_text(&restored.lines); assert!(!restored_text.contains("fullscreen-live")); server.shutdown().await.expect("server shuts down"); @@ -1127,7 +1136,7 @@ async fn hidden_fullscreen_buffer_reveals_live_alternate_screen_coherently() { |snapshot| { snapshot.alternate_screen && snapshot.title.as_deref() == Some("fullscreen-hidden-live") - && snapshot.lines.join("\n").contains("fullscreen-live") + && lines_text(&snapshot.lines).contains("fullscreen-live") }, ) .await; @@ -1154,7 +1163,7 @@ async fn hidden_fullscreen_buffer_reveals_live_alternate_screen_coherently() { fixture.hidden_buffer.id, Duration::from_secs(6), |snapshot| { - let text = snapshot.lines.join("\n"); + let text = lines_text(&snapshot.lines); !snapshot.alternate_screen && snapshot.title.as_deref() == Some("fullscreen-hidden-restored") && text.contains("main-before") @@ -1162,7 +1171,7 @@ async fn hidden_fullscreen_buffer_reveals_live_alternate_screen_coherently() { }, ) .await; - assert!(!restored.lines.join("\n").contains("fullscreen-live")); + assert!(!lines_text(&restored.lines).contains("fullscreen-live")); let restored_render = render_session(&mut client, "alpha").await; assert!(restored_render.contains("main-before")); @@ -1210,7 +1219,7 @@ async fn rapid_terminal_output_renders_latest_visible_snapshot() { &mut connection, buffer.id, Duration::from_secs(3), - |snapshot| snapshot.total_lines >= 80 && snapshot.lines.join("\n").contains("burst-80"), + |snapshot| snapshot.total_lines >= 80 && lines_text(&snapshot.lines).contains("burst-80"), ) .await; @@ -1222,10 +1231,83 @@ async fn rapid_terminal_output_renders_latest_visible_snapshot() { .lines .iter() .rev() - .find(|line| line.starts_with("burst-")) + .find(|line| line.text.starts_with("burst-")) .expect("latest rendered burst line"); - assert!(render.contains(latest_rendered_line)); + assert!(render.contains(&latest_rendered_line.text)); assert!(!render.contains("burst-01")); server.shutdown().await.expect("server shuts down"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn styled_pane_output_reaches_client_ansi_lines() { + let server = TestServer::start().await.expect("server starts"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("protocol connection"); + + let session = create_session(&mut connection, "alpha").await; + let buffer = create_buffer_with_command( + &mut connection, + "colored", + vec![ + "/bin/sh".to_owned(), + "-lc".to_owned(), + // Emit red text via SGR, then idle so the pane persists while we render. + "printf '\\033[31mRED-TEXT\\033[0m\\n'; sleep 2".to_owned(), + ], + ) + .await; + let _ = connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id: session.session.id, + title: "colored".to_owned(), + buffer_id: Some(buffer.id), + child_node_id: None, + })) + .await + .expect("add colored tab succeeds"); + + connection + .wait_for_capture_contains(buffer.id, "RED-TEXT", Duration::from_secs(3)) + .await + .expect("colored output renders"); + let _ = wait_for_visible_snapshot( + &mut connection, + buffer.id, + Duration::from_secs(3), + |snapshot| lines_text(&snapshot.lines).contains("RED-TEXT"), + ) + .await; + + let mut client = MuxClient::connect(server.socket_path()) + .await + .expect("client connects"); + client.resync_all_sessions().await.expect("resync succeeds"); + refresh_all_snapshots(&mut client) + .await + .expect("refresh snapshots succeeds"); + let session_id = session_id_by_name(&client, "alpha"); + let model = PresentationModel::project( + client.state(), + session_id, + Size { + width: 80, + height: 24, + }, + ) + .expect("projection succeeds"); + let grid = Renderer.render(client.state(), &model); + let ansi = grid.ansi_lines().join("\n"); + + // ANSI red maps to indexed color 1; the client re-emits `38;5;1m` so the + // outer terminal's palette resolves it (semantic colors are not baked to RGB). + assert!( + ansi.contains("\x1b[38;5;1m"), + "expected indexed red SGR in client output:\n{ansi:?}" + ); + assert!(grid.render().contains("RED-TEXT")); + + server.shutdown().await.expect("server shuts down"); +} diff --git a/crates/embers-client/tests/reducer.rs b/crates/embers-client/tests/reducer.rs index e8b7f520..7b31b764 100644 --- a/crates/embers-client/tests/reducer.rs +++ b/crates/embers-client/tests/reducer.rs @@ -3,7 +3,8 @@ use embers_client::{ SelectionState, }; use embers_core::{ - ActivityState, BufferId, FloatGeometry, NodeId, PtySize, RequestId, SessionId, SplitDirection, + ActivityState, BufferId, FloatGeometry, NodeId, PtySize, RequestId, SessionId, SnapshotLine, + SplitDirection, }; use embers_protocol::{ BufferDetachedEvent, BufferRecord, BufferRecordKind, BufferRecordState, BufferViewRecord, @@ -164,7 +165,7 @@ fn visible_snapshot( buffer_id: BufferId(buffer_id), sequence: 1, size: PtySize::new(80, 24), - lines: vec!["line-a".to_owned(), "line-b".to_owned()], + lines: vec![SnapshotLine::plain("line-a"), SnapshotLine::plain("line-b")], title: None, cwd: None, viewport_top_line, diff --git a/crates/embers-client/tests/renderer.rs b/crates/embers-client/tests/renderer.rs index b3a8c4e7..5438dd98 100644 --- a/crates/embers-client/tests/renderer.rs +++ b/crates/embers-client/tests/renderer.rs @@ -2,10 +2,44 @@ use embers_client::{ PresentationModel, Renderer, SearchMatch, SearchState, SelectionKind, SelectionPoint, SelectionState, }; -use embers_core::{CursorPosition, CursorShape, CursorState, Size}; +use embers_core::{ + CellAttrs, CursorPosition, CursorShape, CursorState, Size, SnapshotLine, StyledRun, TermColor, +}; use crate::support::{FOCUSED_BUFFER_ID, FOCUSED_LEAF_ID, SESSION_ID, demo_state}; +/// Build a single-run styled line covering the whole text. +fn styled_line(text: &str, fg: TermColor, attrs: u16) -> SnapshotLine { + SnapshotLine { + text: text.to_owned(), + runs: vec![StyledRun { + len: text.len() as u32, + fg, + bg: TermColor::Default, + attrs: CellAttrs(attrs), + }], + } +} + +/// Render `demo_state` after installing styled visible lines on the focused leaf. +fn render_focused_with_lines(lines: Vec) -> embers_client::RenderGrid { + let mut state = demo_state(); + let view = state.view_state_mut(FOCUSED_LEAF_ID).unwrap(); + view.follow_output = false; + view.scroll_top_line = 0; + view.visible_lines = lines; + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .expect("projection succeeds"); + Renderer.render(&state, &presentation) +} + #[test] fn renders_nested_tabs_splits_and_floating_overlay() { let state = demo_state(); @@ -107,7 +141,10 @@ fn renderer_shows_scroll_indicator_and_search_highlights() { view.follow_output = false; view.scroll_top_line = 12; view.total_line_count = 60; - view.visible_lines = vec!["needle here".to_owned(), "plain".to_owned()]; + view.visible_lines = vec![ + SnapshotLine::plain("needle here"), + SnapshotLine::plain("plain"), + ]; view.search_state = Some(SearchState { query: "needle".to_owned(), matches: vec![SearchMatch { @@ -208,3 +245,110 @@ fn renderer_draws_selection_overlay_and_hides_program_cursor_when_selecting() { .any(|line| line.contains("\x1b[7mlo")) ); } + +#[test] +fn renders_indexed_foreground_run_in_pane() { + let grid = render_focused_with_lines(vec![styled_line("red", TermColor::Indexed(1), 0)]); + let ansi = grid.ansi_lines(); + assert!( + ansi.iter().any(|line| line.contains("\x1b[38;5;1m")), + "{ansi:?}" + ); + // Plain projection still shows the text. + assert!(grid.lines().iter().any(|line| line.contains("red"))); +} + +#[test] +fn selection_composes_with_content_color() { + let mut state = demo_state(); + let view = state.view_state_mut(FOCUSED_LEAF_ID).unwrap(); + view.follow_output = false; + view.scroll_top_line = 0; + view.visible_lines = vec![styled_line("red", TermColor::Indexed(1), 0)]; + view.selection_state = Some(SelectionState { + kind: SelectionKind::Character, + anchor: SelectionPoint { line: 0, column: 0 }, + cursor: SelectionPoint { line: 0, column: 2 }, + }); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .unwrap(); + let grid = Renderer.render(&state, &presentation); + let ansi = grid.ansi_lines(); + + // The selected span keeps its red foreground and gains reverse — the two + // compose rather than the selection erasing the color. + let styled = ansi + .iter() + .find(|line| line.contains("\x1b[38;5;1m")) + .expect("red foreground present"); + assert!( + styled.contains("\x1b[7m"), + "expected reverse too: {styled:?}" + ); +} + +#[test] +fn hidden_run_blanks_the_glyph() { + let grid = render_focused_with_lines(vec![styled_line( + "secret", + TermColor::Default, + CellAttrs::HIDDEN, + )]); + // The hidden text must not appear as glyphs in the pane. + assert!( + grid.lines().iter().all(|line| !line.contains("secret")), + "{:?}", + grid.lines() + ); +} + +#[test] +fn renders_styled_wide_char() { + let grid = render_focused_with_lines(vec![styled_line("界x", TermColor::Indexed(2), 0)]); + let ansi = grid.ansi_lines(); + assert!( + ansi.iter().any(|line| line.contains("\x1b[38;5;2m")), + "{ansi:?}" + ); + assert!(grid.lines().iter().any(|line| line.contains("界"))); +} + +#[test] +fn scrolled_styled_view_keeps_color() { + let mut state = demo_state(); + let view = state.view_state_mut(FOCUSED_LEAF_ID).unwrap(); + view.follow_output = false; + view.scroll_top_line = 5; + view.total_line_count = 60; + view.visible_lines = vec![styled_line( + "scrolled", + TermColor::Rgb { + r: 10, + g: 20, + b: 30, + }, + 0, + )]; + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .unwrap(); + let grid = Renderer.render(&state, &presentation); + assert!( + grid.ansi_lines() + .iter() + .any(|line| line.contains("\x1b[38;2;10;20;30m")) + ); +} diff --git a/crates/embers-client/tests/support/mod.rs b/crates/embers-client/tests/support/mod.rs index b7d89d52..fcc44b2d 100644 --- a/crates/embers-client/tests/support/mod.rs +++ b/crates/embers-client/tests/support/mod.rs @@ -341,7 +341,10 @@ fn snapshot(buffer_id: u64, lines: [&str; N]) -> VisibleSnapshot buffer_id: BufferId(buffer_id), sequence: 1, size: PtySize::new(80, 24), - lines: lines.into_iter().map(str::to_owned).collect(), + lines: lines + .into_iter() + .map(embers_core::SnapshotLine::plain) + .collect(), title: None, cwd: None, viewport_top_line: 0, diff --git a/crates/embers-core/Cargo.toml b/crates/embers-core/Cargo.toml index 8d3e6cd7..f52b6254 100644 --- a/crates/embers-core/Cargo.toml +++ b/crates/embers-core/Cargo.toml @@ -16,3 +16,6 @@ thiserror.workspace = true tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/embers-core/src/lib.rs b/crates/embers-core/src/lib.rs index 7e1d7c11..2579e1f4 100644 --- a/crates/embers-core/src/lib.rs +++ b/crates/embers-core/src/lib.rs @@ -14,5 +14,6 @@ pub use geometry::{FloatGeometry, Point, PtySize, Rect, Size, SplitDirection}; pub use ids::{BufferId, ClientId, FloatingId, IdAllocator, NodeId, RequestId, SessionId}; pub use metadata::{ActivityState, EntityMetadata, Timestamp}; pub use snapshot::{ - CursorPosition, CursorShape, CursorState, SnapshotLine, TerminalModes, TerminalSnapshot, + CellAttrs, CursorPosition, CursorShape, CursorState, SnapshotLine, StyledRun, TermColor, + TerminalModes, TerminalSnapshot, }; diff --git a/crates/embers-core/src/snapshot.rs b/crates/embers-core/src/snapshot.rs index 1e409945..ee04a1f9 100644 --- a/crates/embers-core/src/snapshot.rs +++ b/crates/embers-core/src/snapshot.rs @@ -32,19 +32,98 @@ pub struct TerminalModes { pub bracketed_paste: bool, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// Semantic terminal color. +/// +/// Colors stay semantic through the pipeline: named/indexed ANSI colors ship as +/// [`TermColor::Indexed`] so the outer terminal's palette resolves them, rather +/// than baking the emulator's palette RGB. Only true-color (`38;2;…`) sequences +/// become [`TermColor::Rgb`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum TermColor { + #[default] + Default, + Indexed(u8), + Rgb { + r: u8, + g: u8, + b: u8, + }, +} + +/// Per-cell attribute bitflags. +/// +/// A hand-rolled newtype over `u16` (no `bitflags` dependency). Serializes +/// transparently as its inner integer. `WIDE_CHAR`/`WRAPLINE`/spacer flags are +/// consumed by the emulator walk and never represented here. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CellAttrs(pub u16); + +impl CellAttrs { + pub const BOLD: u16 = 1; + pub const DIM: u16 = 2; + pub const ITALIC: u16 = 4; + pub const UNDERLINE: u16 = 8; + pub const DOUBLE_UNDERLINE: u16 = 16; + pub const INVERSE: u16 = 32; + pub const HIDDEN: u16 = 64; + pub const STRIKEOUT: u16 = 128; + + pub const fn empty() -> Self { + Self(0) + } + + pub const fn bits(self) -> u16 { + self.0 + } + + pub const fn contains(self, flag: u16) -> bool { + self.0 & flag != 0 + } + + pub const fn insert(&mut self, flag: u16) { + self.0 |= flag; + } +} + +/// Run-length style annotation over a [`SnapshotLine`]'s text. +/// +/// `len` is measured in bytes of `text`. Invariant on a line: either `runs` is +/// empty (the whole line is default-styled) or `sum(run.len) == text.len()`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct StyledRun { + /// Number of bytes of the line's text this run covers. + pub len: u32, + pub fg: TermColor, + pub bg: TermColor, + pub attrs: CellAttrs, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SnapshotLine { pub text: String, + /// Run-length style annotations. Empty means the whole line is default-styled. + /// `#[serde(default)]` keeps this compatible with older keeper processes that + /// serialize `SnapshotLine` without a `runs` field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runs: Vec, } -impl From<&str> for SnapshotLine { - fn from(value: &str) -> Self { +impl SnapshotLine { + /// A line with no per-cell styling. + pub fn plain(text: impl Into) -> Self { Self { - text: value.to_owned(), + text: text.into(), + runs: Vec::new(), } } } +impl From<&str> for SnapshotLine { + fn from(value: &str) -> Self { + Self::plain(value) + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TerminalSnapshot { pub sequence: u64, @@ -68,10 +147,7 @@ impl TerminalSnapshot { sequence, size, cursor: None, - lines: lines - .into_iter() - .map(|line| SnapshotLine { text: line.into() }) - .collect(), + lines: lines.into_iter().map(SnapshotLine::plain).collect(), title: None, cwd: None, viewport_top_line: 0, @@ -93,7 +169,7 @@ impl TerminalSnapshot { mod tests { use crate::geometry::PtySize; - use super::TerminalSnapshot; + use super::{CellAttrs, SnapshotLine, StyledRun, TermColor, TerminalSnapshot}; #[test] fn plain_text_joins_lines() { @@ -101,4 +177,61 @@ mod tests { assert_eq!(snapshot.plain_text(), "hello\nworld"); } + + #[test] + fn styled_run_round_trips_through_json() { + let mut attrs = CellAttrs::empty(); + attrs.insert(CellAttrs::BOLD); + attrs.insert(CellAttrs::UNDERLINE); + let line = SnapshotLine { + text: "hi".to_owned(), + runs: vec![ + StyledRun { + len: 1, + fg: TermColor::Indexed(1), + bg: TermColor::Default, + attrs, + }, + StyledRun { + len: 1, + fg: TermColor::Rgb { + r: 10, + g: 20, + b: 30, + }, + bg: TermColor::Indexed(4), + attrs: CellAttrs::empty(), + }, + ], + }; + + let json = serde_json::to_string(&line).unwrap(); + let decoded: SnapshotLine = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, line); + } + + #[test] + fn plain_line_serializes_without_runs_key() { + let line = SnapshotLine::plain("hello"); + let json = serde_json::to_string(&line).unwrap(); + assert_eq!(json, r#"{"text":"hello"}"#); + } + + #[test] + fn line_without_runs_key_deserializes_as_plain() { + // Old keeper processes serialize `SnapshotLine` without a `runs` field; + // that JSON must still decode against the new type. + let decoded: SnapshotLine = serde_json::from_str(r#"{"text":"x"}"#).unwrap(); + assert_eq!(decoded, SnapshotLine::plain("x")); + assert!(decoded.runs.is_empty()); + } + + #[test] + fn cell_attrs_serializes_as_integer() { + let mut attrs = CellAttrs::empty(); + attrs.insert(CellAttrs::BOLD); + assert_eq!(serde_json::to_string(&attrs).unwrap(), "1"); + let decoded: CellAttrs = serde_json::from_str("1").unwrap(); + assert_eq!(decoded, attrs); + } } diff --git a/crates/embers-protocol/schema/embers.fbs b/crates/embers-protocol/schema/embers.fbs index 835ed792..5b45b792 100644 --- a/crates/embers-protocol/schema/embers.fbs +++ b/crates/embers-protocol/schema/embers.fbs @@ -475,6 +475,26 @@ table CursorState { shape:CursorShapeWire = Block; } +// One run-length style annotation over a styled line's text. +// A fixed-size struct (16 bytes inline) rather than a table to halve the size +// vs a table per run. Structs cannot evolve; if `attrs` outgrows u16, append a +// new field to StyledLine instead. +// Color kind: 0 = default, 1 = indexed (index in *_r), 2 = rgb (*_r/g/b). +struct StyledRunWire { + len:uint; + fg_kind:ubyte; fg_r:ubyte; fg_g:ubyte; fg_b:ubyte; + bg_kind:ubyte; bg_r:ubyte; bg_g:ubyte; bg_b:ubyte; + attrs:ushort; +} + +// One styled line in a sparse `styles` array: `line_index` is the position in +// the parallel `lines` array this styling applies to. Only lines that actually +// carry styling are emitted, so mostly-plain buffers stay compact. +table StyledLine { + line_index:uint; + runs:[StyledRunWire]; +} + table SnapshotResponse { buffer_id:ulong; sequence:ulong = 0; @@ -500,6 +520,9 @@ table VisibleSnapshotResponse { focus_reporting:bool = false; bracketed_paste:bool = false; cursor:CursorState; + // Appended (append-only evolution): parallel per-line styling. Absent for + // plain buffers, so plain frames stay byte-identical to pre-styling frames. + styles:[StyledLine]; } table ScrollbackSliceResponse { @@ -507,6 +530,8 @@ table ScrollbackSliceResponse { start_line:ulong = 0; total_lines:ulong = 0; lines:[string]; + // Appended: parallel per-line styling. Absent ⇒ plain lines. + styles:[StyledLine]; } table SessionCreatedEvent { diff --git a/crates/embers-protocol/src/codec.rs b/crates/embers-protocol/src/codec.rs index 181f4d33..b58a3538 100644 --- a/crates/embers-protocol/src/codec.rs +++ b/crates/embers-protocol/src/codec.rs @@ -1,8 +1,9 @@ use std::num::NonZeroU64; use embers_core::{ - ActivityState, BufferId, CursorShape, CursorState, ErrorCode, FloatGeometry, FloatingId, - NodeId, PtySize, RequestId, SessionId, SplitDirection, WireError, + ActivityState, BufferId, CellAttrs, CursorShape, CursorState, ErrorCode, FloatGeometry, + FloatingId, NodeId, PtySize, RequestId, SessionId, SnapshotLine, SplitDirection, StyledRun, + TermColor, WireError, }; use flatbuffers::FlatBufferBuilder; use thiserror::Error; @@ -463,6 +464,126 @@ fn encode_cursor_state<'a>( ) } +type StyledLinesOffset<'a> = flatbuffers::WIPOffset< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, +>; + +/// Encode sparse per-line styling. +/// +/// Only lines that actually carry styling are emitted, each tagged with its +/// index into the parallel `lines` array, so a mostly-plain buffer costs one +/// entry per styled line rather than one per line. Returns `None` when every +/// line is plain, so plain buffers produce a frame with no `styles` field — +/// byte-identical to pre-styling frames. +fn encode_styled_lines<'a>( + builder: &mut FlatBufferBuilder<'a>, + lines: &[SnapshotLine], +) -> Option> { + if lines.iter().all(|line| line.runs.is_empty()) { + return None; + } + + let line_offsets: Vec<_> = lines + .iter() + .enumerate() + .filter(|(_, line)| !line.runs.is_empty()) + .map(|(index, line)| { + let runs: Vec = line.runs.iter().map(encode_styled_run).collect(); + let runs = builder.create_vector(&runs); + fb::StyledLine::create( + builder, + &fb::StyledLineArgs { + line_index: u32::try_from(index).unwrap_or(u32::MAX), + runs: Some(runs), + }, + ) + }) + .collect(); + + Some(builder.create_vector(&line_offsets)) +} + +fn encode_styled_run(run: &StyledRun) -> fb::StyledRunWire { + let (fg_kind, fg_r, fg_g, fg_b) = encode_term_color(run.fg); + let (bg_kind, bg_r, bg_g, bg_b) = encode_term_color(run.bg); + fb::StyledRunWire::new( + run.len, + fg_kind, + fg_r, + fg_g, + fg_b, + bg_kind, + bg_r, + bg_g, + bg_b, + run.attrs.bits(), + ) +} + +/// `(kind, r, g, b)` where kind is 0=default, 1=indexed (index in `r`), 2=rgb. +fn encode_term_color(color: TermColor) -> (u8, u8, u8, u8) { + match color { + TermColor::Default => (0, 0, 0, 0), + TermColor::Indexed(index) => (1, index, 0, 0), + TermColor::Rgb { r, g, b } => (2, r, g, b), + } +} + +/// Scatter an optional sparse styles vector onto decoded text lines. +/// +/// Each styles entry carries its `line_index`; entries are placed onto the +/// matching text line. Degrades leniently: absent `styles` or an out-of-range +/// index yields plain lines, an unknown color kind decodes as +/// [`TermColor::Default`], and a line whose runs do not cover exactly its text +/// bytes drops that line's runs. Text never fails. +fn decode_styled_lines( + texts: Vec, + styles: Option>>>, +) -> Vec { + let mut per_line: Vec> = vec![Vec::new(); texts.len()]; + if let Some(styles) = styles { + for entry in styles.iter() { + let index = entry.line_index() as usize; + if index < per_line.len() + && let Some(runs) = entry.runs() + { + per_line[index] = runs.iter().map(decode_styled_run).collect(); + } + } + } + + texts + .into_iter() + .zip(per_line) + .map(|(text, runs)| { + let covered: u64 = runs.iter().map(|run| u64::from(run.len)).sum(); + if runs.is_empty() || covered == text.len() as u64 { + SnapshotLine { text, runs } + } else { + SnapshotLine::plain(text) + } + }) + .collect() +} + +fn decode_styled_run(run: &fb::StyledRunWire) -> StyledRun { + StyledRun { + len: run.len(), + fg: decode_term_color(run.fg_kind(), run.fg_r(), run.fg_g(), run.fg_b()), + bg: decode_term_color(run.bg_kind(), run.bg_r(), run.bg_g(), run.bg_b()), + attrs: CellAttrs(run.attrs()), + } +} + +fn decode_term_color(kind: u8, r: u8, g: u8, b: u8) -> TermColor { + match kind { + 1 => TermColor::Indexed(r), + 2 => TermColor::Rgb { r, g, b }, + // 0 = default, and any unknown kind degrades to default. + _ => TermColor::Default, + } +} + fn encode_buffer_history_scope(scope: BufferHistoryScope) -> fb::BufferHistoryScopeWire { match scope { BufferHistoryScope::Full => fb::BufferHistoryScopeWire::Full, @@ -2726,8 +2847,13 @@ fn encode_server_response<'a>( ServerResponse::VisibleSnapshot(r) => { let title = r.title.as_ref().map(|t| builder.create_string(t)); let cwd = r.cwd.as_ref().map(|c| builder.create_string(c)); - let lines_vec: Vec<_> = r.lines.iter().map(|l| builder.create_string(l)).collect(); + let lines_vec: Vec<_> = r + .lines + .iter() + .map(|l| builder.create_string(&l.text)) + .collect(); let lines = builder.create_vector(&lines_vec); + let styles = encode_styled_lines(builder, &r.lines); let cursor = r .cursor .as_ref() @@ -2749,6 +2875,7 @@ fn encode_server_response<'a>( focus_reporting: r.focus_reporting, bracketed_paste: r.bracketed_paste, cursor, + styles, }, ); fb::Envelope::create( @@ -2762,8 +2889,13 @@ fn encode_server_response<'a>( ) } ServerResponse::ScrollbackSlice(r) => { - let lines_vec: Vec<_> = r.lines.iter().map(|l| builder.create_string(l)).collect(); + let lines_vec: Vec<_> = r + .lines + .iter() + .map(|l| builder.create_string(&l.text)) + .collect(); let lines = builder.create_vector(&lines_vec); + let styles = encode_styled_lines(builder, &r.lines); let snapshot = fb::ScrollbackSliceResponse::create( builder, &fb::ScrollbackSliceResponseArgs { @@ -2771,6 +2903,7 @@ fn encode_server_response<'a>( start_line: r.start_line, total_lines: r.total_lines, lines: Some(lines), + styles, }, ); fb::Envelope::create( @@ -4090,6 +4223,7 @@ pub fn decode_server_envelope(bytes: &[u8]) -> Result = lines.iter().map(|l| l.to_owned()).collect(); + let lines_vec = decode_styled_lines(lines_vec, resp.styles()); let cursor = resp.cursor().map(decode_cursor_state).transpose()?; Ok(ServerEnvelope::Response(ServerResponse::VisibleSnapshot( VisibleSnapshotResponse { @@ -4122,6 +4256,7 @@ pub fn decode_server_envelope(bytes: &[u8]) -> Result = lines.iter().map(|l| l.to_owned()).collect(); + let lines_vec = decode_styled_lines(lines_vec, resp.styles()); Ok(ServerEnvelope::Response(ServerResponse::ScrollbackSlice( ScrollbackSliceResponse { request_id: RequestId(envelope.request_id()), @@ -6082,4 +6217,141 @@ mod tests { if message == "input_request.buffer_id must be non-zero" )); } + + fn round_trip(envelope: &ServerEnvelope) -> ServerEnvelope { + let bytes = encode_server_envelope(envelope).expect("encode succeeds"); + decode_server_envelope(&bytes).expect("decode succeeds") + } + + fn styled_visible(lines: Vec) -> ServerEnvelope { + ServerEnvelope::Response(ServerResponse::VisibleSnapshot(VisibleSnapshotResponse { + request_id: RequestId(1), + buffer_id: BufferId(7), + sequence: 3, + size: PtySize::new(80, 24), + lines, + title: None, + cwd: None, + viewport_top_line: 0, + total_lines: 24, + alternate_screen: false, + mouse_reporting: false, + focus_reporting: false, + bracketed_paste: false, + cursor: None, + })) + } + + #[test] + fn plain_visible_snapshot_round_trips_without_styles() { + let envelope = styled_visible(vec![SnapshotLine::plain("alpha"), SnapshotLine::plain("")]); + let decoded = round_trip(&envelope); + assert_eq!(decoded, envelope); + if let ServerEnvelope::Response(ServerResponse::VisibleSnapshot(resp)) = decoded { + assert!(resp.lines.iter().all(|line| line.runs.is_empty())); + } else { + panic!("expected visible snapshot"); + } + } + + #[test] + fn plain_frames_omit_the_styles_field() { + // A plain buffer must produce a frame with no styles vector so it stays + // byte-compatible with pre-styling peers. + let bytes = encode_server_envelope(&styled_visible(vec![SnapshotLine::plain("x")])) + .expect("encode succeeds"); + let envelope = flatbuffers::root::(&bytes).expect("valid envelope"); + let resp = envelope + .visible_snapshot_response() + .expect("response present"); + assert!(resp.styles().is_none()); + } + + #[test] + fn styled_visible_snapshot_round_trips() { + let envelope = styled_visible(vec![ + SnapshotLine { + text: "hi".to_owned(), + runs: vec![ + StyledRun { + len: 1, + fg: TermColor::Indexed(1), + bg: TermColor::Default, + attrs: CellAttrs(CellAttrs::BOLD), + }, + StyledRun { + len: 1, + fg: TermColor::Rgb { r: 9, g: 8, b: 7 }, + bg: TermColor::Indexed(4), + attrs: CellAttrs::empty(), + }, + ], + }, + SnapshotLine::plain("plain"), + ]); + assert_eq!(round_trip(&envelope), envelope); + } + + #[test] + fn styling_on_a_non_first_line_round_trips_sparsely() { + // Only the middle line is styled. It must round-trip onto the correct + // index, and the sparse encoding must emit exactly one styles entry. + let styled = SnapshotLine { + text: "middle".to_owned(), + runs: vec![StyledRun { + len: 6, + fg: TermColor::Indexed(3), + bg: TermColor::Default, + attrs: CellAttrs::empty(), + }], + }; + let envelope = styled_visible(vec![ + SnapshotLine::plain("first"), + styled.clone(), + SnapshotLine::plain("last"), + ]); + assert_eq!(round_trip(&envelope), envelope); + + let bytes = encode_server_envelope(&envelope).expect("encode succeeds"); + let decoded = flatbuffers::root::(&bytes).expect("valid envelope"); + let styles = decoded + .visible_snapshot_response() + .and_then(|resp| resp.styles()) + .expect("styles present"); + assert_eq!(styles.len(), 1, "only the styled line should be encoded"); + assert_eq!(styles.get(0).line_index(), 1); + } + + #[test] + fn decode_drops_runs_when_lengths_do_not_cover_text() { + // A run claiming more bytes than the text holds is dropped on decode; the + // text always survives. + let envelope = styled_visible(vec![SnapshotLine { + text: "ab".to_owned(), + runs: vec![StyledRun { + len: 99, + fg: TermColor::Indexed(2), + bg: TermColor::Default, + attrs: CellAttrs::empty(), + }], + }]); + let decoded = round_trip(&envelope); + if let ServerEnvelope::Response(ServerResponse::VisibleSnapshot(resp)) = decoded { + assert_eq!(resp.lines[0].text, "ab"); + assert!(resp.lines[0].runs.is_empty()); + } else { + panic!("expected visible snapshot"); + } + } + + #[test] + fn decode_term_color_treats_unknown_kind_as_default() { + assert_eq!(decode_term_color(0, 5, 5, 5), TermColor::Default); + assert_eq!(decode_term_color(1, 5, 0, 0), TermColor::Indexed(5)); + assert_eq!( + decode_term_color(2, 1, 2, 3), + TermColor::Rgb { r: 1, g: 2, b: 3 } + ); + assert_eq!(decode_term_color(200, 9, 9, 9), TermColor::Default); + } } diff --git a/crates/embers-protocol/src/types.rs b/crates/embers-protocol/src/types.rs index efeb61a0..23d0070d 100644 --- a/crates/embers-protocol/src/types.rs +++ b/crates/embers-protocol/src/types.rs @@ -3,7 +3,7 @@ use std::num::NonZeroU64; use embers_core::{ ActivityState, BufferId, CursorState, FloatGeometry, FloatingId, NodeId, PtySize, RequestId, - SessionId, SplitDirection, WireError, + SessionId, SnapshotLine, SplitDirection, WireError, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -950,7 +950,7 @@ pub struct VisibleSnapshotResponse { pub buffer_id: BufferId, pub sequence: u64, pub size: PtySize, - pub lines: Vec, + pub lines: Vec, pub title: Option, pub cwd: Option, pub viewport_top_line: u64, @@ -968,7 +968,7 @@ pub struct ScrollbackSliceResponse { pub buffer_id: BufferId, pub start_line: u64, pub total_lines: u64, - pub lines: Vec, + pub lines: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/embers-protocol/tests/family_round_trip.rs b/crates/embers-protocol/tests/family_round_trip.rs index c34b43d5..11f0437e 100644 --- a/crates/embers-protocol/tests/family_round_trip.rs +++ b/crates/embers-protocol/tests/family_round_trip.rs @@ -1,8 +1,9 @@ use std::num::NonZeroU64; use embers_core::{ - ActivityState, BufferId, CursorPosition, CursorShape, CursorState, ErrorCode, FloatGeometry, - FloatingId, NodeId, PtySize, RequestId, SessionId, SplitDirection, WireError, + ActivityState, BufferId, CellAttrs, CursorPosition, CursorShape, CursorState, ErrorCode, + FloatGeometry, FloatingId, NodeId, PtySize, RequestId, SessionId, SnapshotLine, SplitDirection, + StyledRun, TermColor, WireError, }; use embers_protocol::*; @@ -459,7 +460,19 @@ fn server_envelope_families_round_trip() { buffer_id: BufferId(11), sequence: 10, size: PtySize::new(120, 40), - lines: vec!["alpha".to_owned(), "beta".to_owned(), "".to_owned()], + lines: vec![ + SnapshotLine { + text: "alpha".to_owned(), + runs: vec![StyledRun { + len: 5, + fg: TermColor::Indexed(1), + bg: TermColor::Default, + attrs: CellAttrs(CellAttrs::BOLD), + }], + }, + SnapshotLine::plain("beta"), + SnapshotLine::plain(""), + ], title: Some("shell".to_owned()), cwd: Some("/tmp".to_owned()), viewport_top_line: 17, @@ -478,7 +491,22 @@ fn server_envelope_families_round_trip() { buffer_id: BufferId(11), start_line: 12, total_lines: 43, - lines: vec!["gamma".to_owned(), "delta".to_owned()], + lines: vec![ + SnapshotLine { + text: "gamma".to_owned(), + runs: vec![StyledRun { + len: 5, + fg: TermColor::Rgb { + r: 200, + g: 100, + b: 50, + }, + bg: TermColor::Indexed(4), + attrs: CellAttrs(CellAttrs::UNDERLINE), + }], + }, + SnapshotLine::plain("delta"), + ], })), ServerEnvelope::Event(ServerEvent::SessionCreated(SessionCreatedEvent { session: session.clone(), diff --git a/crates/embers-server/src/buffer_runtime.rs b/crates/embers-server/src/buffer_runtime.rs index 725ac25c..74851507 100644 --- a/crates/embers-server/src/buffer_runtime.rs +++ b/crates/embers-server/src/buffer_runtime.rs @@ -19,7 +19,9 @@ use std::thread; use std::time::Duration; use base64::Engine as _; -use embers_core::{ActivityState, BufferId, MuxError, PtySize, Result, TerminalSnapshot}; +use embers_core::{ + ActivityState, BufferId, MuxError, PtySize, Result, SnapshotLine, StyledRun, TerminalSnapshot, +}; use portable_pty::{ Child, ChildKiller, CommandBuilder, MasterPty, NativePtySystem, PtySize as PortablePtySize, PtySystem, @@ -169,11 +171,41 @@ pub struct KeeperSnapshot { pub cwd: Option, } +/// Budget for scrollback-slice styling. Above this the keeper drops styles and +/// ships plain text, keeping the JSON (16 MiB) and client frame (8 MiB) caps +/// safe. Well below the caps so text and framing overhead still fit. +const MAX_STYLE_PAYLOAD_BYTES: usize = 4 * 1024 * 1024; + +/// Rough per-run cost when a [`StyledRun`] is serialized to keeper JSON. Used +/// only to estimate whether a slice's styling fits the budget. +const ESTIMATED_STYLED_RUN_JSON_BYTES: usize = 64; + #[derive(Clone, Serialize, Deserialize)] pub struct KeeperScrollbackSlice { pub start_line: u64, pub total_lines: u64, pub lines: Vec, + /// Parallel per-line style runs. A field distinct from `lines` (rather than + /// retyping `lines`) keeps the keeper JSON compatible with older keeper + /// processes: `#[serde(default)]` makes a missing `styles` decode as no + /// styling, and a short/empty inner vec means that line is plain. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub styles: Vec>, +} + +impl KeeperScrollbackSlice { + /// Zip `lines` and `styles` back into styled snapshot lines. A missing or + /// short `styles` entry yields a plain line, so old keepers degrade to text. + pub fn into_snapshot_lines(self) -> Vec { + let mut styles = self.styles.into_iter(); + self.lines + .into_iter() + .map(|text| SnapshotLine { + text, + runs: styles.next().unwrap_or_default(), + }) + .collect() + } } struct KeeperRuntime { @@ -662,10 +694,29 @@ impl KeeperSurface { let slice = self .backend .capture_scrollback_slice(start_line, line_count); + let mut lines = Vec::with_capacity(slice.lines.len()); + let mut styles = Vec::with_capacity(slice.lines.len()); + for line in slice.lines { + lines.push(line.text); + styles.push(line.runs); + } + + // Styles are best-effort: a large `line_count` could produce a styled + // payload big enough to blow the keeper JSON (16 MiB) or client frame + // (8 MiB) caps. If the estimated styling exceeds the budget, ship plain + // text — the text always survives. + let run_count: usize = styles.iter().map(Vec::len).sum(); + if run_count.saturating_mul(ESTIMATED_STYLED_RUN_JSON_BYTES) > MAX_STYLE_PAYLOAD_BYTES + || styles.iter().all(Vec::is_empty) + { + styles.clear(); + } + KeeperScrollbackSlice { start_line: slice.start_line, total_lines: slice.total_lines, - lines: slice.lines, + lines, + styles, } } } diff --git a/crates/embers-server/src/server.rs b/crates/embers-server/src/server.rs index d53cc9ac..04781a52 100644 --- a/crates/embers-server/src/server.rs +++ b/crates/embers-server/src/server.rs @@ -10,7 +10,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use embers_core::{ - BufferId, ErrorCode, MuxError, PtySize, RequestId, Result, WireError, request_span, + BufferId, ErrorCode, MuxError, PtySize, RequestId, Result, SnapshotLine, WireError, + request_span, }; use embers_protocol::{ BufferCreatedEvent, BufferDetachedEvent, BufferHistoryPlacement, BufferHistoryScope, @@ -2419,9 +2420,14 @@ impl Runtime { .lines } BufferHistoryScope::Visible => { + // Helper buffers store plain text; project the styled visible + // snapshot down to text at this boundary. self.capture_visible_snapshot(RequestId(0), source_buffer_id) .await? .lines + .into_iter() + .map(|line| line.text) + .collect() } }, }; @@ -2641,7 +2647,7 @@ impl Runtime { buffer_id, sequence, size, - lines, + lines: lines.into_iter().map(SnapshotLine::plain).collect(), title: Some(title), cwd, viewport_top_line, @@ -2664,7 +2670,7 @@ impl Runtime { buffer_id, sequence: snapshot.sequence, size: snapshot.size, - lines: snapshot.lines.into_iter().map(|line| line.text).collect(), + lines: snapshot.lines, title: snapshot.title, cwd: snapshot.cwd.map(|path| path.display().to_string()), viewport_top_line: snapshot.viewport_top_line, @@ -2710,7 +2716,7 @@ impl Runtime { buffer_id, start_line, total_lines, - lines, + lines: lines.into_iter().map(SnapshotLine::plain).collect(), }); } let runtime = self.buffer_runtime(buffer_id).await?; @@ -2724,7 +2730,7 @@ impl Runtime { buffer_id, start_line: slice.start_line, total_lines: slice.total_lines, - lines: slice.lines, + lines: slice.into_snapshot_lines(), }) } @@ -4251,8 +4257,13 @@ mod tests { assert_eq!(snapshot.size.rows, 3); assert_eq!(snapshot.total_lines, 5); assert_eq!(snapshot.viewport_top_line, 2); + let snapshot_text: Vec<_> = snapshot + .lines + .iter() + .map(|line| line.text.clone()) + .collect(); assert_eq!( - snapshot.lines, + snapshot_text, vec![ "line-3".to_owned(), "line-4".to_owned(), diff --git a/crates/embers-server/src/terminal_backend.rs b/crates/embers-server/src/terminal_backend.rs index 99d72e93..e512f439 100644 --- a/crates/embers-server/src/terminal_backend.rs +++ b/crates/embers-server/src/terminal_backend.rs @@ -2,13 +2,16 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use alacritty_terminal::event::{Event, EventListener}; -use alacritty_terminal::grid::Dimensions; -use alacritty_terminal::index::{Column, Line, Point}; +use alacritty_terminal::grid::{Dimensions, Row}; +use alacritty_terminal::index::{Column, Line}; +use alacritty_terminal::term::cell::{Cell, Flags, LineLength}; use alacritty_terminal::term::{Config, LineDamageBounds, Term, TermDamage, TermMode}; -use alacritty_terminal::vte::ansi::{self, CursorShape as AlacrittyCursorShape}; +use alacritty_terminal::vte::ansi::{ + self, Color as AnsiColor, CursorShape as AlacrittyCursorShape, NamedColor, +}; use embers_core::{ - ActivityState, CursorPosition, CursorShape, CursorState, PtySize, SnapshotLine, TerminalModes, - TerminalSnapshot, + ActivityState, CellAttrs, CursorPosition, CursorShape, CursorState, PtySize, SnapshotLine, + StyledRun, TermColor, TerminalModes, TerminalSnapshot, }; #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -27,7 +30,7 @@ pub struct BackendMetadata { pub struct BackendScrollbackSlice { pub start_line: u64, pub total_lines: u64, - pub lines: Vec, + pub lines: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -172,22 +175,37 @@ impl AlacrittyTerminalBackend { } } - fn visible_lines(&self) -> Vec { + fn visible_lines(&self) -> Vec { let grid = self.term.grid(); let display_offset = grid.display_offset() as i32; let top = Line(-display_offset); let bottom = Line(grid.screen_lines() as i32 - display_offset - 1); - self.collect_lines(top, bottom, false) + self.collect_styled_lines(top, bottom) } + /// Full history + screen as plain text. + /// + /// Uses the same cell-walk text projection as [`Self::styled_line`] (tabs to + /// spaces, spacer cells skipped) so search columns computed over captures + /// agree with the styled lines the client displays. Run building is skipped: + /// full capture stays plain text. fn all_lines(&self) -> Vec { let grid = self.term.grid(); + if grid.columns() == 0 { + return Vec::new(); + } let top = Line(-(grid.history_size() as i32)); let bottom = Line(grid.screen_lines() as i32 - 1); - self.collect_lines(top, bottom, false) + let mut lines = Vec::new(); + let mut line = top; + while line <= bottom { + lines.push(self.line_text(line)); + line += 1; + } + lines } - fn collect_lines(&self, start: Line, end: Line, trim_trailing_empty: bool) -> Vec { + fn collect_styled_lines(&self, start: Line, end: Line) -> Vec { let grid = self.term.grid(); if grid.columns() == 0 || end < start { return Vec::new(); @@ -196,21 +214,86 @@ impl AlacrittyTerminalBackend { let mut lines = Vec::new(); let mut line = start; while line <= end { - let text = self.term.bounds_to_string( - Point::new(line, Column(0)), - Point::new(line, Column(grid.columns() - 1)), - ); - lines.push(text.trim_end_matches('\n').to_owned()); + lines.push(self.styled_line(line)); line += 1; } + lines + } - if trim_trailing_empty { - while matches!(lines.last(), Some(last) if last.is_empty()) { - lines.pop(); + /// Plain-text projection of a single row (no run building). + fn line_text(&self, line: Line) -> String { + let row = &self.term.grid()[line]; + let content_len = content_length(row); + let mut text = String::new(); + for column in 0..content_len { + let cell = &row[Column(column)]; + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; } + emit_cell_text(cell, &mut text); } + text + } - lines + /// Walk one grid row into a styled line. + /// + /// Column count comes from `row.len()` (alacritty reflows history on resize, + /// so a cached `columns()` can disagree with a history row). Trailing painted + /// cells (non-default background or inverse) survive as styled spaces; default + /// trailing blanks are trimmed so the plain-text projection matches the legacy + /// extraction. + fn styled_line(&self, line: Line) -> SnapshotLine { + let row = &self.term.grid()[line]; + let content_len = content_length(row); + + let mut text = String::new(); + let mut runs: Vec = Vec::new(); + let mut styled = false; + + for column in 0..content_len { + let cell = &row[Column(column)]; + let flags = cell.flags; + if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + + let fg = map_color(cell.fg); + let bg = map_color(cell.bg); + let attrs = map_attrs(flags); + + // `emit_cell_text` always writes at least one byte (a char or a + // space), so every cell contributes to exactly one run. + let start = text.len(); + emit_cell_text(cell, &mut text); + let byte_len = u32::try_from(text.len() - start).unwrap_or(u32::MAX); + + if fg != TermColor::Default || bg != TermColor::Default || attrs != CellAttrs::empty() { + styled = true; + } + + match runs.last_mut() { + Some(last) if last.fg == fg && last.bg == bg && last.attrs == attrs => { + last.len += byte_len; + } + _ => runs.push(StyledRun { + len: byte_len, + fg, + bg, + attrs, + }), + } + } + + // A line that is entirely default-styled coalesces to a single default + // run; drop it so the on-wire invariant is "empty runs = plain line". + if !styled { + runs.clear(); + } + + SnapshotLine { text, runs } } fn cursor_state(&self) -> Option { @@ -279,11 +362,7 @@ impl TerminalBackend for AlacrittyTerminalBackend { sequence, size, cursor: metadata.cursor, - lines: self - .visible_lines() - .into_iter() - .map(|text| SnapshotLine { text }) - .collect(), + lines: self.visible_lines(), title: metadata.title, cwd, viewport_top_line: metadata.viewport_top_line, @@ -306,13 +385,26 @@ impl TerminalBackend for AlacrittyTerminalBackend { } fn capture_scrollback_slice(&self, start_line: u64, line_count: u32) -> BackendScrollbackSlice { - let lines = self.all_lines(); - let total_lines = lines.len() as u64; + let grid = self.term.grid(); + if grid.columns() == 0 { + return BackendScrollbackSlice::default(); + } + + // Global line 0 is the top of history; global `history_size` is the first + // screen row. This mirrors the `all_lines` mapping but only walks the + // requested window instead of the whole history. + let history = grid.history_size() as i64; + let total_lines = (grid.history_size() + grid.screen_lines()) as u64; let start_line = start_line.min(total_lines); let end_line = start_line .saturating_add(u64::from(line_count)) .min(total_lines); - let lines = lines[start_line as usize..end_line as usize].to_vec(); + + let mut lines = Vec::with_capacity((end_line - start_line) as usize); + for global in start_line..end_line { + let index = i32::try_from(global as i64 - history).unwrap_or(0); + lines.push(self.styled_line(Line(index))); + } BackendScrollbackSlice { start_line, @@ -370,6 +462,125 @@ impl TerminalBackend for AlacrittyTerminalBackend { } } +/// Occupied width of a row, extended to keep trailing painted cells. +/// +/// `line_length` trims trailing spaces (default blanks), which is right for +/// plain text. But a trailing space with a non-default background or the inverse +/// flag is a painted cell the client must draw (vim/htop status bars), so extend +/// the length to the furthest painted column. +fn content_length(row: &Row) -> usize { + let columns = row.len(); + if columns == 0 { + return 0; + } + let mut content_len = row.line_length().0; + for column in (content_len..columns).rev() { + if cell_paints_background(&row[Column(column)]) { + content_len = column + 1; + break; + } + } + content_len +} + +/// Whether a cell paints a visible background even when it holds no glyph. +fn cell_paints_background(cell: &Cell) -> bool { + !matches!(cell.bg, AnsiColor::Named(NamedColor::Background)) + || cell.flags.contains(Flags::INVERSE) +} + +/// Emit a cell's text into `text`. +/// +/// Tabs become a single space (tab stops are private to the emulator and a raw +/// `\t` would break the client's column math). Wide-char spacer cells are the +/// caller's responsibility to skip; here we push the primary char plus any +/// zero-width combining marks. Hidden cells keep their char (the client blanks +/// them from the attribute). +fn emit_cell_text(cell: &Cell, text: &mut String) { + if cell.c == '\t' { + text.push(' '); + } else { + text.push(cell.c); + if let Some(zerowidth) = cell.zerowidth() { + text.extend(zerowidth.iter().copied()); + } + } +} + +/// Map an emulator color to a semantic [`TermColor`]. +/// +/// Named/indexed colors stay indexed so the outer terminal's palette resolves +/// them; only true-color specs become RGB. Default fg/bg and cursor colors map +/// to [`TermColor::Default`]. +fn map_color(color: AnsiColor) -> TermColor { + match color { + AnsiColor::Spec(rgb) => TermColor::Rgb { + r: rgb.r, + g: rgb.g, + b: rgb.b, + }, + AnsiColor::Indexed(index) => TermColor::Indexed(index), + AnsiColor::Named(named) => map_named_color(named), + } +} + +fn map_named_color(named: NamedColor) -> TermColor { + match named { + NamedColor::Black | NamedColor::DimBlack => TermColor::Indexed(0), + NamedColor::Red | NamedColor::DimRed => TermColor::Indexed(1), + NamedColor::Green | NamedColor::DimGreen => TermColor::Indexed(2), + NamedColor::Yellow | NamedColor::DimYellow => TermColor::Indexed(3), + NamedColor::Blue | NamedColor::DimBlue => TermColor::Indexed(4), + NamedColor::Magenta | NamedColor::DimMagenta => TermColor::Indexed(5), + NamedColor::Cyan | NamedColor::DimCyan => TermColor::Indexed(6), + NamedColor::White | NamedColor::DimWhite => TermColor::Indexed(7), + NamedColor::BrightBlack => TermColor::Indexed(8), + NamedColor::BrightRed => TermColor::Indexed(9), + NamedColor::BrightGreen => TermColor::Indexed(10), + NamedColor::BrightYellow => TermColor::Indexed(11), + NamedColor::BrightBlue => TermColor::Indexed(12), + NamedColor::BrightMagenta => TermColor::Indexed(13), + NamedColor::BrightCyan => TermColor::Indexed(14), + NamedColor::BrightWhite => TermColor::Indexed(15), + NamedColor::Foreground + | NamedColor::Background + | NamedColor::Cursor + | NamedColor::BrightForeground + | NamedColor::DimForeground => TermColor::Default, + } +} + +fn map_attrs(flags: Flags) -> CellAttrs { + let mut attrs = CellAttrs::empty(); + if flags.contains(Flags::BOLD) { + attrs.insert(CellAttrs::BOLD); + } + if flags.contains(Flags::DIM) { + attrs.insert(CellAttrs::DIM); + } + if flags.contains(Flags::ITALIC) { + attrs.insert(CellAttrs::ITALIC); + } + if flags.intersects( + Flags::UNDERLINE | Flags::UNDERCURL | Flags::DOTTED_UNDERLINE | Flags::DASHED_UNDERLINE, + ) { + attrs.insert(CellAttrs::UNDERLINE); + } + if flags.contains(Flags::DOUBLE_UNDERLINE) { + attrs.insert(CellAttrs::DOUBLE_UNDERLINE); + } + if flags.contains(Flags::INVERSE) { + attrs.insert(CellAttrs::INVERSE); + } + if flags.contains(Flags::HIDDEN) { + attrs.insert(CellAttrs::HIDDEN); + } + if flags.contains(Flags::STRIKEOUT) { + attrs.insert(CellAttrs::STRIKEOUT); + } + attrs +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -379,7 +590,9 @@ mod tests { RawByteRouter, TerminalBackend, }; use crate::config::DEFAULT_MAX_SCROLLBACK_LINES; - use embers_core::{ActivityState, CursorShape, PtySize, TerminalSnapshot}; + use embers_core::{ + ActivityState, CellAttrs, CursorShape, PtySize, SnapshotLine, TermColor, TerminalSnapshot, + }; fn backend(size: PtySize) -> AlacrittyTerminalBackend { AlacrittyTerminalBackend::new(size, DEFAULT_MAX_SCROLLBACK_LINES) @@ -424,7 +637,9 @@ mod tests { BackendScrollbackSlice { start_line, total_lines: 1, - lines: vec![String::from_utf8_lossy(&self.ingested).into_owned()], + lines: vec![SnapshotLine::plain( + String::from_utf8_lossy(&self.ingested).into_owned(), + )], } } @@ -445,6 +660,186 @@ mod tests { snapshot.lines.into_iter().map(|line| line.text).collect() } + fn first_line(backend: &AlacrittyTerminalBackend, size: PtySize) -> SnapshotLine { + backend + .visible_snapshot(1, size, None) + .lines + .into_iter() + .next() + .expect("at least one line") + } + + #[test] + fn sgr_bold_red_produces_indexed_run() { + let size = PtySize::new(8, 1); + let mut backend = backend(size); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"\x1b[31;1mAB\x1b[0mC"); + + let line = first_line(&backend, size); + assert_eq!(line.text, "ABC"); + assert_eq!(line.runs.len(), 2); + assert_eq!(line.runs[0].len, 2); + assert_eq!(line.runs[0].fg, TermColor::Indexed(1)); + assert!(line.runs[0].attrs.contains(CellAttrs::BOLD)); + assert_eq!(line.runs[1].len, 1); + assert_eq!(line.runs[1].fg, TermColor::Default); + assert_eq!(line.runs[1].attrs, CellAttrs::empty()); + } + + #[test] + fn indexed_256_color_survives_as_indexed() { + let size = PtySize::new(4, 1); + let mut backend = backend(size); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"\x1b[38;5;208mX"); + + let line = first_line(&backend, size); + assert_eq!(line.text, "X"); + assert_eq!(line.runs[0].fg, TermColor::Indexed(208)); + } + + #[test] + fn truecolor_becomes_rgb() { + let size = PtySize::new(4, 1); + let mut backend = backend(size); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"\x1b[38;2;10;20;30mX"); + + let line = first_line(&backend, size); + assert_eq!( + line.runs[0].fg, + TermColor::Rgb { + r: 10, + g: 20, + b: 30 + } + ); + } + + #[test] + fn bright_named_color_maps_to_high_index() { + let size = PtySize::new(4, 1); + let mut backend = backend(size); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"\x1b[91mX"); + + let line = first_line(&backend, size); + assert_eq!(line.runs[0].fg, TermColor::Indexed(9)); + } + + #[test] + fn wide_char_is_a_single_run_and_spacer_adds_nothing() { + let size = PtySize::new(6, 1); + let mut backend = backend(size); + let _ = backend.take_damage(); + + // Color the wide char so it forms its own run; the spacer column must not + // add bytes or a run of its own. + backend.ingest_bytes("\x1b[31m界\x1b[0ma".as_bytes()); + + let line = first_line(&backend, size); + assert_eq!(line.text, "界a"); + assert_eq!(line.runs.len(), 2); + assert_eq!(line.runs[0].len, "界".len() as u32); + assert_eq!(line.runs[0].fg, TermColor::Indexed(1)); + assert_eq!(line.runs[1].len, 1); + } + + #[test] + fn combining_char_folds_into_the_base_cell_run() { + let size = PtySize::new(4, 1); + let mut backend = backend(size); + let _ = backend.take_damage(); + + // 'e' + combining acute accent lands in one cell as a zero-width mark. + backend.ingest_bytes("\x1b[31me\u{301}".as_bytes()); + + let line = first_line(&backend, size); + assert_eq!(line.text, "e\u{301}"); + assert_eq!(line.runs.len(), 1); + assert_eq!(line.runs[0].len, "e\u{301}".len() as u32); + assert_eq!(line.runs[0].fg, TermColor::Indexed(1)); + } + + #[test] + fn trailing_background_survives_while_default_blanks_trim() { + let size = PtySize::new(6, 1); + let mut backend = backend(size); + let _ = backend.take_damage(); + + // Set a blue background then erase to end of line: cells become painted + // spaces that must survive as styled trailing content. + backend.ingest_bytes(b"\x1b[44m\x1b[K"); + + let line = first_line(&backend, size); + assert_eq!(line.text, " "); + assert_eq!(line.runs.len(), 1); + assert_eq!(line.runs[0].len, 6); + assert_eq!(line.runs[0].bg, TermColor::Indexed(4)); + + // A default line trims to empty (plain projection unchanged). + let mut plain = + AlacrittyTerminalBackend::new(PtySize::new(6, 1), DEFAULT_MAX_SCROLLBACK_LINES); + let _ = plain.take_damage(); + plain.ingest_bytes(b"hi"); + let plain_line = first_line(&plain, PtySize::new(6, 1)); + assert_eq!(plain_line.text, "hi"); + assert!(plain_line.runs.is_empty()); + } + + #[test] + fn tab_cells_project_to_spaces() { + let size = PtySize::new(12, 1); + let mut backend = backend(size); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"a\tb"); + + let line = first_line(&backend, size); + assert!(!line.text.contains('\t'), "text: {:?}", line.text); + assert!(line.text.starts_with('a')); + assert!(line.text.trim_end().ends_with('b')); + assert!(line.runs.is_empty()); + } + + #[test] + fn ranged_scrollback_slice_carries_styles() { + let size = PtySize::new(6, 2); + let mut backend = backend(size); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"\x1b[31mone\x1b[0m\r\ntwo\r\nthree\r\nfour"); + + // The oldest history line "one" is red; request the window containing it. + let slice = backend.capture_scrollback_slice(0, 1); + assert_eq!(slice.start_line, 0); + assert_eq!(slice.lines[0].text, "one"); + assert_eq!(slice.lines[0].runs[0].fg, TermColor::Indexed(1)); + } + + #[test] + fn alternate_screen_snapshot_carries_styles() { + let size = PtySize::new(20, 4); + let mut backend = backend(size); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"\x1b[?1049h\x1b[H\x1b[32malt\x1b[0m"); + + let snapshot = backend.visible_snapshot(2, size, None); + assert!(snapshot.modes.alternate_screen); + let styled = snapshot + .lines + .iter() + .find(|line| line.text.contains("alt")) + .expect("alt line present"); + assert_eq!(styled.runs[0].fg, TermColor::Indexed(2)); + } + #[test] fn visible_snapshot_extracts_plain_text_lines() { let mut backend = backend(PtySize::new(8, 3)); @@ -535,7 +930,8 @@ mod tests { let slice = backend.capture_scrollback_slice(1, 2); assert_eq!(slice.start_line, 1); assert_eq!(slice.total_lines, 4); - assert_eq!(slice.lines, vec!["two", "three"]); + let slice_text: Vec<_> = slice.lines.iter().map(|line| line.text.as_str()).collect(); + assert_eq!(slice_text, vec!["two", "three"]); } #[test] diff --git a/crates/embers-test-support/tests/buffer_runtime.rs b/crates/embers-test-support/tests/buffer_runtime.rs index 621cc71e..10280663 100644 --- a/crates/embers-test-support/tests/buffer_runtime.rs +++ b/crates/embers-test-support/tests/buffer_runtime.rs @@ -1,6 +1,6 @@ use std::time::{Duration, Instant}; -use embers_core::{PtySize, new_request_id}; +use embers_core::{PtySize, SnapshotLine, new_request_id}; use embers_protocol::{ BufferRecord, BufferRecordState, BufferRequest, ClientMessage, InputRequest, OkResponse, ServerResponse, SnapshotResponse, @@ -8,6 +8,15 @@ use embers_protocol::{ use embers_test_support::{TestConnection, TestServer, acquire_test_lock}; use tokio::time::sleep; +/// Join styled snapshot lines into newline-delimited plain text for assertions. +fn lines_text(lines: &[SnapshotLine]) -> String { + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>() + .join("\n") +} + async fn create_buffer(connection: &mut TestConnection, command: &[&str]) -> BufferRecord { let response = connection .request(&ClientMessage::Buffer(BufferRequest::Create { @@ -294,7 +303,7 @@ async fn visible_snapshot_surfaces_terminal_modes_and_cursor_metadata() { .capture_visible_buffer(buffer.id) .await .expect("visible capture succeeds"); - let text = snapshot.lines.join("\n"); + let text = lines_text(&snapshot.lines); assert!(text.contains("hello")); assert_eq!(snapshot.title.as_deref(), Some("embers")); assert!(snapshot.alternate_screen); @@ -341,7 +350,8 @@ async fn scrollback_slice_returns_history_while_full_capture_stays_available() { assert!(captured.lines.join("\n").contains("line-40")); assert!(visible.total_lines >= 40); assert!(visible.viewport_top_line > 0); - assert_eq!(slice.lines, expected_prefix); + let slice_text: Vec = slice.lines.iter().map(|line| line.text.clone()).collect(); + assert_eq!(slice_text, expected_prefix); assert_eq!(slice.start_line, 0); assert_eq!(slice.total_lines, visible.total_lines); @@ -454,7 +464,7 @@ async fn detached_visible_capture_tracks_latest_size_and_output() { .expect("initial visible capture succeeds"); assert_eq!(initial_visible.size, PtySize::new(80, 24)); assert_eq!(initial_visible.title.as_deref(), Some("detached-preview")); - assert!(initial_visible.lines.join("\n").contains("ready")); + assert!(lines_text(&initial_visible.lines).contains("ready")); resize_buffer(&mut connection, buffer.id, 96, 18).await; let resized_visible = connection @@ -473,7 +483,7 @@ async fn detached_visible_capture_tracks_latest_size_and_output() { .expect("final visible capture succeeds"); assert_eq!(visible.size, PtySize::new(96, 18)); assert_eq!(visible.title.as_deref(), Some("detached-preview")); - assert!(visible.lines.join("\n").contains("seen:after-resize")); + assert!(lines_text(&visible.lines).contains("seen:after-resize")); let captured = capture_buffer(&mut connection, buffer.id).await; assert_eq!(captured.size, PtySize::new(96, 18)); @@ -485,7 +495,7 @@ async fn detached_visible_capture_tracks_latest_size_and_output() { .await .expect("detached scrollback slice succeeds"); assert!(slice.total_lines >= 2); - assert!(slice.lines.join("\n").contains("ready")); + assert!(lines_text(&slice.lines).contains("ready")); server.shutdown().await.expect("shutdown server"); } diff --git a/docs/render-source-contract.md b/docs/render-source-contract.md index ff067650..5368aeb8 100644 --- a/docs/render-source-contract.md +++ b/docs/render-source-contract.md @@ -7,11 +7,15 @@ Phase 8 locks down which server surfaces the client uses for terminal rendering. The server remains authoritative for both layout and terminal state. - `SessionSnapshot` provides layout topology plus durable buffer metadata such as title, activity, attachment, and PTY size. -- `VisibleSnapshotResponse` provides the current visible terminal surface for one buffer, including visible lines, cursor state, viewport position, alternate-screen mode, and other terminal-mode flags. +- `VisibleSnapshotResponse` provides the current visible terminal surface for one buffer, including styled visible lines, cursor state, viewport position, alternate-screen mode, and other terminal-mode flags. - Full capture and scrollback slices stay on-demand APIs and are not part of the normal render loop. The client does not consume terminal diffs. It renders from full visible snapshots, with `RenderInvalidated` acting as a hint that a buffer should be refreshed before the next user-visible render. +### Styled visible lines + +Each visible line is a `SnapshotLine { text, runs }`: the plain text plus run-length style annotations (`StyledRun`) carrying per-cell foreground/background color and attributes. Colors are semantic — named/indexed ANSI colors travel as `Indexed(n)` so the outer terminal's palette resolves them, and only true-color sequences carry explicit RGB. An empty `runs` vector means the whole line is default-styled, so plain buffers ship (and render) exactly as before. Scrollback slices used for scrolled-back views carry the same styling; full capture and helper/persistence surfaces stay plain text. The no-diff / full-snapshot model is otherwise unchanged. + ### Freshness expectations `RenderInvalidated` means the visible snapshot for that buffer may be stale. The client refreshes invalidated visible leaves (leaf nodes in the layout tree corresponding to visible buffers) before rendering and then updates the display so updated titles, alternate-screen flags, and visible lines are used together. diff --git a/docs/terminal-capture-model.md b/docs/terminal-capture-model.md index 4eaa09b7..c3f00f1b 100644 --- a/docs/terminal-capture-model.md +++ b/docs/terminal-capture-model.md @@ -19,6 +19,14 @@ Embers exposes three related but distinct capture surfaces for PTY buffers: All three are sourced from the durable buffer runtime (`BufferRuntimeHandle` -> runtime keeper -> `TerminalBackend`), not from layout state. +## Styling + +Visible snapshots and scrollback slices carry per-cell styling; full capture, helper buffers, and persistence stay plain text (like `tmux capture-pane` without `-e`). + +Styled lines are `SnapshotLine { text, runs }`: the text plus run-length `StyledRun` annotations with semantic foreground/background color (`TermColor` = `Default | Indexed(u8) | Rgb`) and attribute bits (bold, dim, italic, underline, double-underline, inverse, hidden, strikeout). Named/indexed ANSI colors ship as `Indexed(n)` so the outer terminal's palette resolves them; only true-color sequences become `Rgb`. Empty `runs` means the whole line is default-styled, so plain content is byte-identical to the pre-styling wire format. + +The text projection is column-faithful to the active screen: tab cells become spaces, wide-character spacer cells contribute nothing, and trailing cells with a non-default background or the inverse flag survive as styled spaces (default trailing blanks still trim). The same projection is used for full capture text, so search columns computed over a capture agree with the displayed styled lines. + ## Full snapshot semantics `capture_snapshot` is the "capture pane/buffer" source of truth for PTY buffers. @@ -27,7 +35,7 @@ It returns: - the current snapshot sequence - the buffer's current PTY size -- the backend's full captured lines +- the backend's full captured lines, as plain text (no styling) - the terminal title if the backend has one - the buffer cwd tracked by the server @@ -39,7 +47,7 @@ For PTY buffers, this is a runtime capture, not a view capture. Moving, detachin It returns: -- the current visible lines from the active screen +- the current visible lines from the active screen, with per-cell styling - viewport position and total line count - terminal mode bits such as alternate screen, mouse reporting, focus reporting, and bracketed paste - cursor metadata @@ -55,7 +63,9 @@ It returns: - `start_line`: the effective start of the returned slice - `total_lines`: the full scrollback length at capture time -- `lines`: the requested window into that history +- `lines`: the requested window into that history, with per-cell styling + +Slice styling is best-effort: the requested window is walked directly (only the requested rows, not the full history), and if the styled payload would threaten the keeper JSON or client frame size caps the keeper drops styles and returns plain text for that slice. The text always survives. Repeated reads without new output should be stable: the same buffer state should yield the same full snapshot, visible snapshot, and scrollback slice.