Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions mux/crates/ghostty-vt/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,27 @@ impl Terminal {
)
}

/// Plain-text dump of the currently rendered viewport.
///
/// This uses the terminal formatter's read-only selection path rather
/// than `RenderState::update`, so callers can inspect the viewport
/// without clearing dirty flags needed by a concurrent renderer.
pub fn viewport_text(&mut self) -> Result<String> {
let cols = self.cols();
let rows = self.rows();
if cols == 0 || rows == 0 {
return Ok(String::new());
}
self.selection_text_with_tag_options(
sys::GHOSTTY_POINT_TAG_VIEWPORT,
(0, 0),
(cols.saturating_sub(1), rows.saturating_sub(1) as u64),
false,
true,
)
.ok_or(crate::Error::InvalidValue)
}

/// Plain text of a selection range given in absolute screen
/// coordinates (scrollbar offset + viewport row), inclusive.
/// Clamps the end row when scrollback has trimmed rows after the
Expand All @@ -317,6 +338,17 @@ impl Terminal {
tag: sys::GhosttyPointTag,
start: (u16, u64),
end: (u16, u64),
) -> Option<String> {
self.selection_text_with_tag_options(tag, start, end, true, true)
}

fn selection_text_with_tag_options(
&mut self,
tag: sys::GhosttyPointTag,
start: (u16, u64),
end: (u16, u64),
unwrap_lines: bool,
trim: bool,
) -> Option<String> {
let grid_ref = |x: u16, y: u64| -> Option<sys::GhosttyGridRef> {
let y = u32::try_from(y).ok()?;
Expand All @@ -340,8 +372,8 @@ impl Terminal {
let opts = sys::GhosttyFormatterTerminalOptions {
size: std::mem::size_of::<sys::GhosttyFormatterTerminalOptions>(),
emit: sys::GHOSTTY_FORMATTER_FORMAT_PLAIN,
unwrap: true,
trim: true,
unwrap: unwrap_lines,
trim,
extra: sys::GhosttyFormatterTerminalExtra {
size: std::mem::size_of::<sys::GhosttyFormatterTerminalExtra>(),
..Default::default()
Expand All @@ -352,8 +384,8 @@ impl Terminal {
Some(String::from_utf8_lossy(&bytes).into_owned())
}

/// Plain-text dump of the active screen (viewport plus scrollback is
/// not included; this formats the active screen contents).
/// Plain-text dump of the active screen's full page list, INCLUDING
/// scrollback. For the rendered viewport only, use [`Self::viewport_text`].
pub fn plain_text(&mut self) -> Result<String> {
let opts = sys::GhosttyFormatterTerminalOptions {
size: std::mem::size_of::<sys::GhosttyFormatterTerminalOptions>(),
Expand Down
17 changes: 17 additions & 0 deletions mux/crates/ghostty-vt/tests/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,23 @@ fn plain_text_dump() {
assert!(text.contains("beta"));
}

#[test]
fn viewport_text_does_not_clear_render_dirty_state() {
let mut term = Terminal::new(40, 5, 1000, Callbacks::default()).unwrap();
let mut rs = RenderState::new().unwrap();

rs.update(&mut term).unwrap();
rs.set_clean();

term.vt_write(b"alpha\r\nbeta");
let text = term.viewport_text().unwrap();
assert!(text.contains("alpha"), "viewport was {text:?}");
assert!(text.contains("beta"), "viewport was {text:?}");

rs.update(&mut term).unwrap();
assert_ne!(rs.dirty(), Dirty::Clean);
}

#[test]
fn selection_text_extracts_range() {
let mut term = Terminal::new(40, 5, 0, Callbacks::default()).unwrap();
Expand Down
2 changes: 1 addition & 1 deletion mux/crates/mux-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,7 @@ fn handle_command(mux: &Arc<Mux>, cmd: Command, writer: &LineWriter) -> anyhow::
Command::ReadScreen { surface } => {
let surface = get_surface(mux, surface)?;
require_pty(&surface)?;
let text = surface.try_with_terminal(|t| t.plain_text())??;
let text = surface.try_with_terminal(|t| t.viewport_text())??;
Ok(json!({ "text": text }))
}
Command::VtState { surface } => {
Expand Down
39 changes: 39 additions & 0 deletions mux/crates/mux-core/tests/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,45 @@ fn control_socket_round_trip() {
mux_core::server::cleanup(&sock_path);
}

#[test]
fn control_socket_read_screen_reports_rendered_viewport_after_scrollback_clear() {
let mut output = String::new();
for row in 0..12 {
output.push_str(&format!("row{row:02}\\r\\n"));
}
let script = format!("printf '{output}'; printf '\\033[H\\033[2Jprompt$ '; sleep 30");
let mux = Mux::new(unique_session("test-read-screen-viewport"), shell_opts(&script));
let surface = mux.new_workspace(None, Some((17, 5))).unwrap();

let sock_path = mux_core::server::serve(mux.clone(), None).unwrap();
let stream = connect(&sock_path);
let mut writer = stream.try_clone_box().unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();

let text = wait_for(
|| {
line.clear();
writeln!(writer, r#"{{"id":1,"cmd":"read-screen","surface":{}}}"#, surface.id).unwrap();
reader.read_line(&mut line).unwrap();
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(value["ok"], true, "read-screen failed: {line}");
let text = value["data"]["text"].as_str().unwrap_or_default().to_string();
text.contains("prompt$").then_some(text)
},
Duration::from_secs(10),
)
.expect("prompt never reached rendered screen");
let first_line = text.lines().next().unwrap_or_default();
assert!(
first_line.contains("prompt$"),
"read-screen should report the rendered viewport, got {text:?}"
);

mux.close_surface(surface.id);
mux_core::server::cleanup(&sock_path);
}

#[test]
fn control_socket_set_default_colors_merges_fields() {
let opts = SurfaceOptions { command: Some(vec!["/bin/cat".to_string()]), ..Default::default() };
Expand Down
Loading