aboutsummaryrefslogtreecommitdiff
path: root/src/pane/terminal
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /src/pane/terminal
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'src/pane/terminal')
-rw-r--r--src/pane/terminal/migration_tests.rs382
-rw-r--r--src/pane/terminal/windows_recent_fallback.rs17
2 files changed, 399 insertions, 0 deletions
diff --git a/src/pane/terminal/migration_tests.rs b/src/pane/terminal/migration_tests.rs
new file mode 100644
index 00000000..94d78a77
--- /dev/null
+++ b/src/pane/terminal/migration_tests.rs
@@ -0,0 +1,382 @@
+//! Bounded semantic migration gates. No opaque IDs, timing, or render scheduling
+//! decisions enter this oracle. Keep the same runner for old/candidate captures.
+use super::*;
+
+struct Harness {
+ pane: PaneTerminal,
+ tx: mpsc::Sender<Bytes>,
+ width: u16,
+ height: u16,
+ effects: Effects,
+}
+
+#[derive(Debug, Default, PartialEq, Eq)]
+struct Effects {
+ replies: Vec<u8>,
+ clipboard: Vec<Vec<u8>>,
+ bells: u32,
+ cwd: Vec<std::path::PathBuf>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct Observation {
+ geometry: (u16, u16),
+ cells: Vec<CellData>,
+ text_rows: Vec<crate::ghostty::ScreenTextRow>,
+ links: Vec<((u16, u16), String, String)>,
+ cursor: TerminalCursorState,
+ input: InputState,
+ visible: String,
+ recent: TerminalReadSnapshot,
+ detection: String,
+ title: Option<String>,
+}
+
+impl Harness {
+ fn new(width: u16, height: u16) -> Self {
+ let (tx, _rx) = mpsc::channel(16);
+ let terminal = crate::ghostty::Terminal::new(width, height, 256).unwrap();
+ Self {
+ pane: PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap()),
+ tx,
+ width,
+ height,
+ effects: Effects::default(),
+ }
+ }
+
+ fn write(&mut self, bytes: &[u8]) {
+ let result = self
+ .pane
+ .process_pty_bytes(PaneId::from_raw(1), 0, bytes, &self.tx);
+ for reply in result.terminal_responses {
+ self.effects.replies.extend_from_slice(&reply);
+ }
+ self.effects.clipboard.extend(result.clipboard_writes);
+ self.effects.bells += u32::from(result.terminal_bells);
+ self.effects.cwd.extend(result.reported_cwd);
+ }
+
+ fn resize(&mut self, width: u16, height: u16) {
+ for reply in self.pane.resize(height, width, 8, 16) {
+ self.effects.replies.extend_from_slice(&reply);
+ }
+ self.width = width;
+ self.height = height;
+ }
+
+ fn full_cells(&self) -> Vec<CellData> {
+ // Read the frame buffer, not TestBackend's diff output (which may skip
+ // wide spacer cells). This is the independent full-render oracle.
+ let backend = ratatui::backend::TestBackend::new(self.width, self.height);
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
+ let mut cells = Vec::new();
+ terminal
+ .draw(|frame| {
+ self.pane
+ .render(frame, Rect::new(0, 0, self.width, self.height), false);
+ cells = frame
+ .buffer_mut()
+ .content
+ .iter()
+ .map(CellData::from_ratatui_cell)
+ .collect();
+ })
+ .unwrap();
+ cells
+ }
+
+ fn cursor(&self) -> Option<TerminalCursorState> {
+ // Compare terminal semantics, not Windows' time-based cursor settling policy.
+ current_cursor_state(&mut self.pane.ghostty.core.lock().unwrap())
+ }
+
+ fn observe(&self) -> Observation {
+ let (_, cols, text_rows) = self.pane.screen_text_snapshot().unwrap();
+ assert_eq!(cols, self.width);
+ Observation {
+ geometry: (self.width, self.height),
+ cells: self.full_cells(),
+ text_rows,
+ links: self
+ .pane
+ .visible_hyperlinks(Rect::new(0, 0, self.width, self.height)),
+ cursor: self.cursor().unwrap(),
+ input: self.pane.input_state().unwrap(),
+ visible: self.pane.visible_text(),
+ recent: self.pane.recent_text_snapshot(32),
+ detection: self.pane.detection_text(),
+ title: self.pane.terminal_title(),
+ }
+ }
+}
+
+#[test]
+fn short_streams_are_invariant_at_every_byte_boundary() {
+ let fixtures: &[&[u8]] = &[
+ "a界e\u{301}🇯🇵!".as_bytes(),
+ b"a\x1b[31;1mB\x1b[0m\x1b[2;3HZ\x1b[6n\x1b[?2004h",
+ b"\x1b]8;;https://example.test/a\x1b\\link\x1b]8;;\x1b\\!",
+ b"\x1b]52;c;aGk=\x07\x1b]2;migration\x1b\\\x07",
+ b"\x1bP+q5463\x1b\\\x1bP+q6E6F7065\x9c\x1b[6n",
+ ];
+ for bytes in fixtures {
+ let mut whole = Harness::new(16, 4);
+ whole.write(bytes);
+ let expected = whole.observe();
+ for split in 0..=bytes.len() {
+ let mut fragmented = Harness::new(16, 4);
+ fragmented.write(&bytes[..split]);
+ fragmented.write(&bytes[split..]);
+ assert_eq!(
+ fragmented.observe(),
+ expected,
+ "split {split}, bytes {bytes:?}"
+ );
+ assert_eq!(
+ fragmented.effects, whole.effects,
+ "effects at split {split}"
+ );
+ }
+ let mut bytewise = Harness::new(16, 4);
+ for byte in bytes.iter() {
+ bytewise.write(std::slice::from_ref(byte));
+ }
+ assert_eq!(bytewise.observe(), expected);
+ assert_eq!(bytewise.effects, whole.effects);
+ }
+}
+
+const MIXED: &str = "ab\x1b[1;38;2;12;34;56m界e\u{301}\x1b[0m\x1b]8;;https://example.test/reflow\x1b\\🇯🇵xyz\x1b]8;;\x1b\\\r\nnext カタカナ end";
+
+#[test]
+fn mixed_reflow_reads_are_stable_and_chunk_independent() {
+ let mut whole = Harness::new(12, 5);
+ let mut fragmented = Harness::new(12, 5);
+ whole.write(MIXED.as_bytes());
+ for chunk in MIXED.as_bytes().chunks(3) {
+ fragmented.write(chunk);
+ }
+ for (width, height) in [(12, 5), (8, 4), (17, 6), (9, 5), (12, 5)] {
+ whole.resize(width, height);
+ fragmented.resize(width, height);
+ let expected = whole.observe();
+ assert_eq!(whole.observe(), expected, "reads must not mutate semantics");
+ assert_eq!(fragmented.observe(), expected);
+ assert!(
+ !expected.links.is_empty(),
+ "fixture must retain a visible link"
+ );
+ }
+}
+
+#[test]
+fn incremental_rows_reconstruct_full_render() {
+ let mut incremental = Harness::new(12, 5);
+ let mut full = Harness::new(12, 5);
+ let mut retained = vec![CellData::from_ratatui_cell(&ratatui::buffer::Cell::default()); 60];
+ // Independent terminals: full render must not consume incremental dirty state.
+ for bytes in [
+ b"".as_slice(),
+ "ab\x1b[1;31m界e\u{301}\x1b[0m\r\nnext".as_bytes(),
+ b"\x1b[2;2HZ",
+ b"\x1b[1;1HA\x1b[5;8HB",
+ b"\x1b]4;1;rgb:12/34/56\x07",
+ b"\x1b[3;4H",
+ b"\x1b[?1049hALT",
+ b"\x1b[?1049l",
+ ] {
+ incremental.write(bytes);
+ full.write(bytes);
+ match incremental.pane.collect_dirty_patch(12, 5) {
+ TerminalDirtyPatchOutcome::Clean => {}
+ TerminalDirtyPatchOutcome::Patch(patch) => {
+ for (row, cells) in patch.rows {
+ assert_eq!(cells.len(), 12);
+ let start = usize::from(row) * 12;
+ retained[start..start + 12].clone_from_slice(&cells);
+ }
+ }
+ TerminalDirtyPatchOutcome::Fallback => {
+ panic!("bounded text fixture unexpectedly fell back")
+ }
+ }
+ assert_eq!(retained, full.full_cells(), "after {bytes:?}");
+ assert_eq!(incremental.cursor(), full.cursor());
+ }
+}
+
+#[test]
+fn sparse_dirty_patches_preserve_coordinates_and_clipped_rows() {
+ for height in [3, 6] {
+ let mut terminal = Harness::new(8, 6);
+ terminal.write(b"\x1b[2;3H");
+ terminal.pane.collect_dirty_patch(8, 6);
+ assert!(matches!(
+ terminal.pane.collect_dirty_patch(8, 6),
+ TerminalDirtyPatchOutcome::Clean
+ ));
+ terminal.write(b"\x1b[2;3HX\x1b[5;4HY");
+ let TerminalDirtyPatchOutcome::Patch(patch) = terminal.pane.collect_dirty_patch(8, height)
+ else {
+ panic!("expected sparse patch");
+ };
+ let expected_rows = if height == 3 { vec![1] } else { vec![1, 4] };
+ assert_eq!(
+ patch.rows.iter().map(|(y, _)| *y).collect::<Vec<_>>(),
+ expected_rows
+ );
+ assert!(patch.rows.iter().all(|(_, cells)| cells.len() == 8));
+
+ let core = terminal.pane.ghostty.core.lock().unwrap();
+ let mut iterator = crate::ghostty::RowIterator::new().unwrap();
+ let mut rows = core
+ .render_state
+ .populate_row_iterator(&mut iterator)
+ .unwrap();
+ let mut y = 0;
+ while rows.next() {
+ assert_eq!(rows.dirty().unwrap(), height == 3 && y == 4);
+ y += 1;
+ }
+ assert_eq!(y, 6);
+ }
+}
+
+#[test]
+fn dirty_patch_fallback_keeps_previously_collected_rows_dirty() {
+ let mut terminal = Harness::new(8, 6);
+ terminal.write(b"\x1b[2;3H");
+ terminal.pane.collect_dirty_patch(8, 6);
+ terminal.write(b"\x1b[2;3HX\x1b[5;4H\x1b]8;;https://example.test\x1b\\Y\x1b]8;;\x1b\\");
+ assert!(matches!(
+ terminal.pane.collect_dirty_patch(8, 6),
+ TerminalDirtyPatchOutcome::Fallback
+ ));
+ let core = terminal.pane.ghostty.core.lock().unwrap();
+ let mut iterator = crate::ghostty::RowIterator::new().unwrap();
+ let mut rows = core
+ .render_state
+ .populate_row_iterator(&mut iterator)
+ .unwrap();
+ let mut dirty = Vec::new();
+ let mut y = 0;
+ while rows.next() {
+ if rows.dirty().unwrap() {
+ dirty.push(y);
+ }
+ y += 1;
+ }
+ assert_eq!(dirty, vec![1, 4]);
+}
+
+#[test]
+fn complete_history_replay_supports_plain_append() {
+ // ANSI history is text restoration, not a parser/cursor snapshot. End at a
+ // non-wrapping printable cell with SGR and OSC8 closed; no pending tab/CSI.
+ let mut source = Harness::new(24, 4);
+ source.write(b"\x1b[31mred\x1b[0m\r\nplain");
+ let ansi = source.pane.recent_unwrapped_ansi(32);
+ let mut restored = Harness::new(24, 4);
+ restored.pane.ghostty.seed_history_ansi(&ansi);
+ assert_eq!(
+ restored.pane.recent_text_snapshot(32),
+ source.pane.recent_text_snapshot(32)
+ );
+ // Establish the documented live-output boundary explicitly instead of
+ // requiring history formatting to restore arbitrary cursor/SGR state.
+ for terminal in [&mut source, &mut restored] {
+ terminal.write(b"\x1b[0m\r\nappended");
+ }
+ assert_eq!(
+ restored.pane.recent_text_snapshot(32),
+ source.pane.recent_text_snapshot(32)
+ );
+ assert!(restored.effects.clipboard.is_empty());
+ assert!(restored.effects.replies.is_empty());
+}
+
+#[test]
+#[ignore = "non-gating release render scaling profile"]
+fn render_scale_profile_sparse_dirty_patches() {
+ for count in [1, 15] {
+ let mut panes = (0..count).map(|_| Harness::new(80, 24)).collect::<Vec<_>>();
+ for pane in &mut panes {
+ for line in 0..40 {
+ pane.write(format!("{line:04} populated 界 terminal row\r\n").as_bytes());
+ }
+ pane.write(b"\x1b[12;40H");
+ pane.pane.collect_dirty_patch(80, 24);
+ }
+ for sparse in [false, true] {
+ let mut samples = Vec::new();
+ for sample in 0..35 {
+ let mut elapsed = std::time::Duration::ZERO;
+ for iteration in 0..20 {
+ if sparse {
+ let bytes: &[u8] = if iteration % 2 == 0 {
+ b"\x1b[12;40HX"
+ } else {
+ b"\x1b[12;40HY"
+ };
+ for pane in &panes {
+ pane.pane.ghostty.core.lock().unwrap().terminal.write(bytes);
+ }
+ }
+ let start = std::time::Instant::now();
+ for pane in &panes {
+ let outcome = pane.pane.collect_dirty_patch(80, 24);
+ if sparse {
+ let TerminalDirtyPatchOutcome::Patch(ref patch) = outcome else {
+ panic!("expected sparse patch");
+ };
+ assert_eq!(patch.rows.len(), 1);
+ assert_eq!(patch.rows[0].0, 11);
+ } else {
+ assert!(matches!(outcome, TerminalDirtyPatchOutcome::Clean));
+ }
+ std::hint::black_box(outcome);
+ }
+ elapsed += start.elapsed();
+ }
+ if sample >= 5 {
+ samples.push(elapsed.as_nanos() / 20);
+ }
+ }
+ samples.sort_unstable();
+ eprintln!(
+ "dirty_patch_scale panes={count} sparse={sparse} median_ns={} p95_ns={}",
+ samples[samples.len() / 2],
+ samples[samples.len() * 95 / 100]
+ );
+ }
+ }
+}
+
+#[test]
+fn capture_bounded_migration_observations() {
+ let mut terminal = Harness::new(12, 5);
+ let mut observations = Vec::new();
+ terminal.write(MIXED.as_bytes());
+ observations.push(terminal.observe());
+ for (width, height) in [(8, 4), (17, 6), (12, 5)] {
+ terminal.resize(width, height);
+ observations.push(terminal.observe());
+ }
+ terminal.write(b"\x1b[6n\x1b[?2004h\x1b]52;c;aGk=\x07\x07");
+ observations.push(terminal.observe());
+ terminal.pane.scroll_up(2);
+ observations.push(terminal.observe());
+ terminal.pane.scroll_reset();
+ observations.push(terminal.observe());
+ // An explicit path prevents fixtures from silently being regenerated.
+ // Compare old/new with diff; explain each difference, never bulk-bless it.
+ if let Some(path) = std::env::var_os("HERDR_MIGRATION_OBSERVATIONS") {
+ std::fs::write(
+ path,
+ format!("{observations:#?}\n{:#?}\n", terminal.effects),
+ )
+ .unwrap();
+ }
+ assert_eq!(observations.last().unwrap(), &terminal.observe());
+}
diff --git a/src/pane/terminal/windows_recent_fallback.rs b/src/pane/terminal/windows_recent_fallback.rs
index efb58b49..7f632c08 100644
--- a/src/pane/terminal/windows_recent_fallback.rs
+++ b/src/pane/terminal/windows_recent_fallback.rs
@@ -346,6 +346,20 @@ mod tests {
#[test]
fn deferred_repaint_survives_real_scrollback_pruning() {
+ assert_deferred_repaint_survives_real_scrollback_pruning(None);
+ }
+
+ #[test]
+ fn deferred_repaint_survives_real_scrollback_pruning_resize_rows() {
+ assert_deferred_repaint_survives_real_scrollback_pruning(Some((2, 40)));
+ }
+
+ #[test]
+ fn deferred_repaint_survives_real_scrollback_pruning_resize_both_axes() {
+ assert_deferred_repaint_survives_real_scrollback_pruning(Some((2, 20)));
+ }
+
+ fn assert_deferred_repaint_survives_real_scrollback_pruning(resize: Option<(u16, u16)>) {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(40, 1, 1).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
@@ -387,6 +401,9 @@ mod tests {
.collect::<String>();
pane.process_pty_bytes(pane_id, 0, batch.as_bytes(), &tx);
assert_eq!(state(), (before, true));
+ if let Some((rows, cols)) = resize {
+ pane.resize(rows, cols, 8, 16);
+ }
let mut core = pane.core.lock().unwrap();
let fallback =
super::super::finish_recent_snapshot(&mut core, String::new(), CACHE_LINES, false);