use rmux_core::input::mode;
use rmux_core::{render_dec_modes_for_snapshot, PaneGeometry, PaneId};
#[cfg(test)]
use rmux_core::{GridRenderOptions, Screen, ScreenCaptureRange};
use rmux_proto::{RmuxError, TerminalSize};
const SNAPSHOT_RESET_PREFIX: &[u8] =
b"\x1b[?2026l\x1b[?1049l\x1b[?6l\x1b[r\x1b[0m\x1b[?25l\x1b[3J\x1b[2J\x1b[H";
const SNAPSHOT_ALT_SCREEN_PREFIX: &[u8] =
b"\x1b[?1049h\x1b[?6l\x1b[r\x1b[0m\x1b[?25l\x1b[3J\x1b[2J\x1b[H";
pub(crate) const WEB_SESSION_FRAME_BYTES_MAX: usize = 3 * 512 * 1024;
use crate::web::WEB_RECOVERY_CONTENT_BYTES_MAX;
const WEB_SESSION_VIEW_ENTRY_MAX: usize = 1024;
const WEB_SESSION_WINDOW_NAME_MAX: usize = 4 * 1024;
const WEB_SESSION_WINDOW_NAMES_TOTAL_MAX: usize = 128 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct WebPaneSnapshot {
pub(crate) cols: u16,
pub(crate) rows: u16,
pub(crate) output_sequence: u64,
pub(crate) ansi_lines: Vec<Vec<u8>>,
pub(crate) cursor_row: u16,
pub(crate) cursor_col: u16,
pub(crate) cursor_visible: bool,
pub(crate) mode_bits: u32,
pub(crate) cursor_style: u32,
pub(crate) alternate: bool,
pub(crate) scroll_top: u32,
pub(crate) scroll_bottom: u32,
pub(crate) history_rows_total: u64,
pub(crate) history_rows_included: u64,
pub(crate) metadata_complete: bool,
#[serde(skip)]
pub(crate) recovery_keyframe: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct WebSessionSnapshot {
pub(crate) size: TerminalSize,
pub(crate) view: WebSessionView,
frame: Vec<u8>,
active_mode_bits: u32,
active_cursor_style: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WebSessionPaneFrame {
pub(crate) size: TerminalSize,
pub(crate) pane: WebSessionPaneView,
pub(crate) frame: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct WebSessionView {
pub(crate) size: TerminalSize,
pub(crate) panes: Vec<WebSessionPaneView>,
pub(crate) windows: Vec<WebSessionWindowView>,
pub(crate) metadata_complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct WebSessionPaneView {
pub(crate) id: u32,
pub(crate) x: u16,
pub(crate) y: u16,
pub(crate) cols: u16,
pub(crate) rows: u16,
pub(crate) active: bool,
pub(crate) history_size: usize,
pub(crate) scroll_offset: usize,
pub(crate) alternate_on: bool,
pub(crate) mouse_on: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct WebSessionWindowView {
pub(crate) index: u32,
pub(crate) name: String,
pub(crate) active: bool,
}
impl WebPaneSnapshot {
#[cfg(test)]
pub(crate) fn ansi_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
self.append_ansi_bytes(&mut out);
out
}
pub(crate) fn append_ansi_bytes(&self, out: &mut Vec<u8>) {
if let Some(keyframe) = &self.recovery_keyframe {
out.extend_from_slice(keyframe);
return;
}
out.extend_from_slice(SNAPSHOT_RESET_PREFIX);
if self.alternate {
out.extend_from_slice(SNAPSHOT_ALT_SCREEN_PREFIX);
}
render_dec_modes_for_snapshot(self.mode_bits, self.cursor_style, out);
for (index, line) in self.ansi_lines.iter().enumerate() {
if index > 0 {
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b"\x1b[0m");
out.extend_from_slice(line);
}
let default_bottom = u32::from(self.rows.max(1)).saturating_sub(1);
if self.scroll_top != 0 || self.scroll_bottom != default_bottom {
out.extend_from_slice(
format!(
"\x1b[{};{}r",
self.scroll_top.saturating_add(1),
self.scroll_bottom.saturating_add(1),
)
.as_bytes(),
);
}
let cursor_row = self.cursor_row.min(self.rows.saturating_sub(1)) + 1;
let cursor_col = self.cursor_col.min(self.cols.saturating_sub(1)) + 1;
out.extend_from_slice(format!("\x1b[0m\x1b[{cursor_row};{cursor_col}H").as_bytes());
if self.mode_bits & mode::MODE_ORIGIN != 0 {
out.extend_from_slice(b"\x1b[?6h");
}
out.extend_from_slice(if self.cursor_visible {
b"\x1b[?25h"
} else {
b"\x1b[?25l"
});
}
}
impl WebSessionSnapshot {
pub(crate) fn new(
size: TerminalSize,
frame: Vec<u8>,
view: WebSessionView,
active_mode_bits: u32,
active_cursor_style: u32,
) -> Result<Self, RmuxError> {
let mut mode_state = Vec::new();
render_dec_modes_for_snapshot(active_mode_bits, active_cursor_style, &mut mode_state);
let encoded_len = SNAPSHOT_RESET_PREFIX
.len()
.saturating_add(mode_state.len())
.saturating_add(frame.len());
if frame.len() > WEB_RECOVERY_CONTENT_BYTES_MAX || encoded_len > WEB_SESSION_FRAME_BYTES_MAX
{
return Err(RmuxError::FrameTooLarge {
length: encoded_len,
maximum: WEB_SESSION_FRAME_BYTES_MAX,
});
}
Ok(Self {
size,
view,
frame,
active_mode_bits,
active_cursor_style,
})
}
#[cfg(test)]
pub(crate) fn ansi_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.frame.len() + SNAPSHOT_RESET_PREFIX.len());
self.append_ansi_bytes(&mut out);
out
}
pub(crate) fn append_ansi_bytes(&self, out: &mut Vec<u8>) {
out.reserve(self.frame.len() + SNAPSHOT_RESET_PREFIX.len());
out.extend_from_slice(SNAPSHOT_RESET_PREFIX);
render_dec_modes_for_snapshot(self.active_mode_bits, self.active_cursor_style, out);
out.extend_from_slice(&self.frame);
}
}
impl WebSessionPaneFrame {
pub(crate) fn new(
size: TerminalSize,
pane: WebSessionPaneView,
frame: Vec<u8>,
) -> Result<Self, RmuxError> {
if frame.len() > WEB_RECOVERY_CONTENT_BYTES_MAX {
return Err(RmuxError::FrameTooLarge {
length: frame.len(),
maximum: WEB_RECOVERY_CONTENT_BYTES_MAX,
});
}
Ok(Self { size, pane, frame })
}
}
impl WebSessionView {
pub(crate) fn new(size: TerminalSize) -> Self {
Self {
size,
panes: Vec::new(),
windows: Vec::new(),
metadata_complete: true,
}
}
pub(crate) fn add_window(&mut self, index: u32, name: Option<&str>, active: bool) {
if self.windows.len() >= WEB_SESSION_VIEW_ENTRY_MAX {
self.metadata_complete = false;
return;
}
let name = name.unwrap_or_default();
let retained_name_bytes = self.windows.iter().fold(0_usize, |total, window| {
total.saturating_add(window.name.len())
});
let remaining = WEB_SESSION_WINDOW_NAMES_TOTAL_MAX.saturating_sub(retained_name_bytes);
let retained_len = utf8_prefix_len(name, remaining.min(WEB_SESSION_WINDOW_NAME_MAX));
if retained_len != name.len() {
self.metadata_complete = false;
}
let name = name[..retained_len].to_owned();
self.windows.push(WebSessionWindowView {
index,
name,
active,
});
}
pub(crate) fn push_pane(&mut self, pane: WebSessionPaneView) {
if self.panes.len() >= WEB_SESSION_VIEW_ENTRY_MAX {
self.metadata_complete = false;
return;
}
self.panes.push(pane);
}
pub(crate) fn mark_metadata_incomplete(&mut self) {
self.metadata_complete = false;
}
}
impl WebSessionPaneView {
pub(crate) fn new(
id: PaneId,
geometry: PaneGeometry,
active: bool,
history_size: usize,
scroll_offset: usize,
alternate_on: bool,
mouse_on: bool,
) -> Self {
Self {
id: id.as_u32(),
x: geometry.x(),
y: geometry.y(),
cols: geometry.cols(),
rows: geometry.rows(),
active,
history_size,
scroll_offset,
alternate_on,
mouse_on,
}
}
}
pub(crate) fn session_content_geometry(
geometry: PaneGeometry,
content_size: TerminalSize,
) -> Option<PaneGeometry> {
if geometry.x() >= content_size.cols || geometry.y() >= content_size.rows {
return None;
}
let cols = geometry.cols().min(content_size.cols - geometry.x());
let rows = geometry.rows().min(content_size.rows - geometry.y());
if rows == 0 || cols == 0 {
return None;
}
Some(PaneGeometry::new(geometry.x(), geometry.y(), cols, rows))
}
pub(crate) fn overlay_pane_lines(
frame: &mut Vec<u8>,
geometry: PaneGeometry,
lines: &[Vec<u8>],
) -> Result<(), RmuxError> {
let additional = (0..usize::from(geometry.rows())).fold(6_usize, |total, row| {
let terminal_row = usize::from(geometry.y()) + row + 1;
let terminal_col = usize::from(geometry.x()) + 1;
total
.saturating_add(format!("\x1b[{terminal_row};{terminal_col}H\x1b[0m").len())
.saturating_add(lines.get(row).map_or(0, Vec::len))
});
let encoded_len = frame.len().saturating_add(additional);
if encoded_len > WEB_RECOVERY_CONTENT_BYTES_MAX {
return Err(RmuxError::FrameTooLarge {
length: encoded_len,
maximum: WEB_RECOVERY_CONTENT_BYTES_MAX,
});
}
for row in 0..usize::from(geometry.rows()) {
let terminal_row = usize::from(geometry.y()) + row + 1;
let terminal_col = usize::from(geometry.x()) + 1;
frame.extend_from_slice(format!("\x1b[{terminal_row};{terminal_col}H\x1b[0m").as_bytes());
if let Some(line) = lines.get(row) {
frame.extend_from_slice(line);
}
}
frame.extend_from_slice(b"\x1b[?25l");
Ok(())
}
fn utf8_prefix_len(value: &str, max_bytes: usize) -> usize {
let mut end = value.len().min(max_bytes);
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
end
}
#[cfg(test)]
pub(crate) fn snapshot_ansi_lines(screen: &Screen) -> Vec<Vec<u8>> {
screen.capture_transcript_lines_independent(
ScreenCaptureRange::default(),
GridRenderOptions {
with_sequences: true,
trim_spaces: false,
..GridRenderOptions::default()
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use rmux_core::input::{mode, InputParser};
use rmux_core::Screen;
use rmux_proto::TerminalSize;
fn default_pane_snapshot(ansi_lines: Vec<Vec<u8>>) -> WebPaneSnapshot {
WebPaneSnapshot {
cols: 80,
rows: 24,
output_sequence: 7,
ansi_lines,
cursor_row: 3,
cursor_col: 7,
cursor_visible: true,
mode_bits: mode::MODE_CURSOR | mode::MODE_WRAP,
cursor_style: 0,
alternate: false,
scroll_top: 0,
scroll_bottom: 23,
history_rows_total: 0,
history_rows_included: 0,
metadata_complete: true,
recovery_keyframe: None,
}
}
#[test]
fn web_snapshot_bytes_preserve_ansi_style_and_cursor() {
let snapshot = default_pane_snapshot(vec![b"\x1b[32muser@host\x1b[0m".to_vec()]);
let bytes = snapshot.ansi_bytes();
let rendered = String::from_utf8(bytes).expect("snapshot bytes are utf8");
assert!(rendered.starts_with("\x1b[?2026l\x1b[?1049l\x1b[?6l\x1b[r"));
assert!(rendered.contains("\x1b[32muser@host"));
assert!(rendered.contains("\x1b[4;8H\x1b[?25h"));
}
#[test]
fn web_snapshot_golden_default_modes_and_cursor_are_byte_stable() {
let snapshot = default_pane_snapshot(vec![b"golden".to_vec()]);
let expected_dec_modes = concat!(
"\x1b[?7h",
"\x1b[4l",
"\x1b[?1l",
"\x1b>",
"\x1b[20l",
"\x1b[?1004l",
"\x1b[?2004l",
"\x1b[?2031l",
"\x1b[?2026l",
"\x1b[?1000l",
"\x1b[?1002l",
"\x1b[?1003l",
"\x1b[?1005l",
"\x1b[?1006l",
"\x1b[<u",
"\x1b[>4;0m",
"\x1b[0 q",
);
assert_eq!(
snapshot.ansi_bytes(),
[
SNAPSHOT_RESET_PREFIX,
expected_dec_modes.as_bytes(),
b"\x1b[0mgolden",
b"\x1b[0m\x1b[4;8H\x1b[?25h",
]
.concat()
);
}
#[test]
fn web_snapshot_reasserts_dec_modes_and_scroll_region() {
let mut snapshot = default_pane_snapshot(vec![b"hi".to_vec()]);
snapshot.mode_bits = mode::MODE_CURSOR
| mode::MODE_WRAP
| mode::MODE_MOUSE_BUTTON
| mode::MODE_MOUSE_SGR
| mode::MODE_BRACKETPASTE;
snapshot.alternate = true;
snapshot.scroll_top = 2;
snapshot.scroll_bottom = 20;
let rendered = String::from_utf8(snapshot.ansi_bytes()).expect("snapshot bytes are utf8");
assert!(rendered.contains("\x1b[?2026l"), "{rendered:?}");
assert!(rendered.contains("\x1b[?1049l"), "{rendered:?}");
assert!(rendered.contains("\x1b[?1049h"), "{rendered:?}");
assert!(rendered.contains("\x1b[?1002h"), "{rendered:?}");
assert!(rendered.contains("\x1b[?1006h"), "{rendered:?}");
assert!(rendered.contains("\x1b[?2004h"), "{rendered:?}");
assert!(rendered.contains("\x1b[3;21r"), "{rendered:?}");
let modes_at = rendered.find("\x1b[?1002h").unwrap();
let content_at = rendered.find("hi").unwrap();
assert!(modes_at < content_at, "modes must precede content");
}
#[test]
fn web_snapshot_resets_stale_modes_when_target_is_normal_screen() {
let snapshot = default_pane_snapshot(vec![b"normal".to_vec()]);
let rendered = String::from_utf8(snapshot.ansi_bytes()).expect("snapshot bytes are utf8");
assert!(rendered.contains("\x1b[?2026l"), "{rendered:?}");
assert!(rendered.contains("\x1b[?1049l"), "{rendered:?}");
assert!(rendered.contains("\x1b[?6l"), "{rendered:?}");
assert!(rendered.contains("\x1b[r"), "{rendered:?}");
assert!(rendered.contains("\x1b[?2004l"), "{rendered:?}");
assert!(!rendered.contains("\x1b[?1049h"), "{rendered:?}");
assert!(!rendered.contains("\x1b[?2026h"), "{rendered:?}");
}
#[test]
fn web_session_snapshot_clears_saved_lines_before_rendering() {
let size = TerminalSize { cols: 80, rows: 24 };
let snapshot = WebSessionSnapshot::new(
size,
b"frame".to_vec(),
WebSessionView::new(size),
mode::MODE_CURSOR | mode::MODE_WRAP,
0,
)
.expect("snapshot fits");
let rendered = String::from_utf8(snapshot.ansi_bytes()).expect("snapshot bytes are utf8");
assert!(rendered.starts_with("\x1b[?2026l\x1b[?1049l\x1b[?6l\x1b[r"));
assert!(rendered.contains("\x1b[?2004l"), "{rendered:?}");
assert!(rendered.ends_with("frame"), "{rendered:?}");
}
#[test]
fn web_session_snapshot_reasserts_active_pane_modes() {
let size = TerminalSize { cols: 80, rows: 24 };
let snapshot = WebSessionSnapshot::new(
size,
b"frame".to_vec(),
WebSessionView::new(size),
mode::MODE_CURSOR | mode::MODE_WRAP | mode::MODE_MOUSE_BUTTON | mode::MODE_MOUSE_SGR,
0,
)
.expect("snapshot fits");
let rendered = String::from_utf8(snapshot.ansi_bytes()).expect("snapshot bytes are utf8");
assert!(rendered.contains("\x1b[?1002h"), "{rendered:?}");
assert!(rendered.contains("\x1b[?1006h"), "{rendered:?}");
assert!(rendered.ends_with("frame"), "{rendered:?}");
}
#[test]
fn web_session_snapshot_rejects_an_oversized_single_frame() {
let size = TerminalSize { cols: 80, rows: 24 };
let frame = vec![b'x'; WEB_RECOVERY_CONTENT_BYTES_MAX + 1];
assert!(matches!(
WebSessionSnapshot::new(size, frame, WebSessionView::new(size), 0, 0),
Err(RmuxError::FrameTooLarge { .. })
));
}
#[test]
fn web_overlay_preflights_complete_rows_before_mutating_the_frame() {
let mut frame = b"unchanged".to_vec();
let original = frame.clone();
let lines = vec![vec![b'x'; WEB_RECOVERY_CONTENT_BYTES_MAX]];
assert!(matches!(
overlay_pane_lines(&mut frame, PaneGeometry::new(0, 0, 1, 1), &lines),
Err(RmuxError::FrameTooLarge { .. })
));
assert_eq!(frame, original);
}
#[test]
fn web_session_view_bounds_terminal_controlled_window_names() {
let size = TerminalSize { cols: 80, rows: 24 };
let mut view = WebSessionView::new(size);
let oversized = "é".repeat(WEB_SESSION_WINDOW_NAME_MAX);
view.add_window(1, Some(&oversized), true);
assert!(!view.metadata_complete);
assert!(view.windows[0].name.len() <= WEB_SESSION_WINDOW_NAME_MAX);
assert!(view.windows[0]
.name
.is_char_boundary(view.windows[0].name.len()));
}
#[test]
fn web_snapshot_capture_preserves_screen_sequences() {
let mut screen = Screen::new(TerminalSize { cols: 12, rows: 3 }, 100);
let mut parser = InputParser::new();
parser.parse(b"\x1b[32muser\x1b[0m@host", &mut screen);
let lines = snapshot_ansi_lines(&screen);
let joined = String::from_utf8(lines.concat()).expect("snapshot lines are utf8");
assert!(joined.contains("\x1b[32m"));
assert!(joined.contains("user"));
}
#[test]
fn web_session_geometry_clips_to_content_bounds_without_status_conversion() {
let size = TerminalSize {
cols: 120,
rows: 30,
};
assert_eq!(
session_content_geometry(PaneGeometry::new(0, 0, 120, 32), size),
Some(PaneGeometry::new(0, 0, 120, 30)),
);
assert_eq!(
session_content_geometry(PaneGeometry::new(60, 16, 70, 16), size),
Some(PaneGeometry::new(60, 16, 60, 14)),
);
assert_eq!(
session_content_geometry(PaneGeometry::new(0, 30, 120, 1), size),
None,
);
assert_eq!(
session_content_geometry(PaneGeometry::new(120, 0, 1, 1), size),
None,
);
}
}