diff --git a/src/pane/osc.rs b/src/pane/osc.rs index a281af77..472fa7aa 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::path::PathBuf; use tracing::info; @@ -695,68 +694,6 @@ pub(super) fn current_transient_default_color_owner(shell_pid: u32) -> Option bool { - job.processes.iter().any(|process| { - process.name.eq_ignore_ascii_case("droid") - || process - .argv0 - .as_deref() - .is_some_and(|argv0| argv0.eq_ignore_ascii_case("droid")) - || process.cmdline.as_deref().is_some_and(|cmdline| { - cmdline.eq_ignore_ascii_case("droid") - || cmdline.starts_with("droid ") - || cmdline.to_ascii_lowercase().contains("/droid") - }) - }) -} - -pub(super) fn contains_scrollback_clear_sequence(bytes: &[u8]) -> bool { - bytes.windows(4).any(|window| window == b"\x1b[3J") - || bytes.windows(5).any(|window| window == b"\x1b[?3J") -} - -fn strip_scrollback_clear_sequences<'a>(bytes: &'a [u8]) -> Cow<'a, [u8]> { - if !contains_scrollback_clear_sequence(bytes) { - return Cow::Borrowed(bytes); - } - - let mut filtered = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - let remaining = &bytes[index..]; - if remaining.starts_with(b"\x1b[3J") { - index += 4; - continue; - } - if remaining.starts_with(b"\x1b[?3J") { - index += 5; - continue; - } - filtered.push(bytes[index]); - index += 1; - } - - Cow::Owned(filtered) -} - -pub(super) fn maybe_filter_primary_screen_scrollback_clear<'a>( - bytes: &'a [u8], - alternate_screen: bool, - foreground_job: Option<&crate::platform::ForegroundJob>, -) -> Cow<'a, [u8]> { - // Droid redraws its primary-screen TUI with CSI 3 J, which erases pane - // scrollback inside herdr. Keep the hack scoped to Droid on the primary - // screen so normal terminal clear-history behavior still works elsewhere. - if alternate_screen - || !contains_scrollback_clear_sequence(bytes) - || !foreground_job.is_some_and(foreground_job_uses_droid_scrollback_compat) - { - return Cow::Borrowed(bytes); - } - - strip_scrollback_clear_sequences(bytes) -} - #[cfg(target_os = "macos")] pub(super) fn should_restore_host_terminal_theme( owner_pgid: u32, @@ -1350,81 +1287,6 @@ mod tests { ); } - #[test] - fn droid_scrollback_compat_matches_process_name_and_cmdline() { - let name_only = crate::platform::ForegroundJob { - process_group_id: 42, - processes: vec![crate::platform::ForegroundProcess { - pid: 42, - name: "droid".to_string(), - argv0: None, - argv: Some(vec![ - "/opt/factory/droid".to_string(), - "--resume".to_string(), - ]), - cmdline: Some("/opt/factory/droid --resume".to_string()), - }], - }; - assert!(foreground_job_uses_droid_scrollback_compat(&name_only)); - - let cmdline_only = crate::platform::ForegroundJob { - process_group_id: 42, - processes: vec![crate::platform::ForegroundProcess { - pid: 42, - name: "bun".to_string(), - argv0: Some("bun".to_string()), - argv: Some(vec![ - "bun".to_string(), - "/home/can/.local/bin/droid".to_string(), - "--resume".to_string(), - ]), - cmdline: Some("/home/can/.local/bin/droid --resume".to_string()), - }], - }; - assert!(foreground_job_uses_droid_scrollback_compat(&cmdline_only)); - - let shell = shell_job(7); - assert!(!foreground_job_uses_droid_scrollback_compat(&shell)); - } - - #[test] - fn strip_scrollback_clear_sequences_removes_ed3_only() { - let filtered = strip_scrollback_clear_sequences(b"a\x1b[3Jb\x1b[?3Jc\x1b[2Jd"); - assert_eq!(filtered.as_ref(), b"abc\x1b[2Jd"); - } - - #[test] - fn primary_screen_droid_compat_ignores_scrollback_clear_only_for_droid() { - let droid_job = crate::platform::ForegroundJob { - process_group_id: 42, - processes: vec![crate::platform::ForegroundProcess { - pid: 42, - name: "droid".to_string(), - argv0: Some("droid".to_string()), - argv: Some(vec!["droid".to_string()]), - cmdline: Some("droid".to_string()), - }], - }; - - let filtered = maybe_filter_primary_screen_scrollback_clear( - b"\x1b[3J\x1b[2J", - false, - Some(&droid_job), - ); - assert_eq!(filtered.as_ref(), b"\x1b[2J"); - - let shell = maybe_filter_primary_screen_scrollback_clear( - b"\x1b[3J\x1b[2J", - false, - Some(&shell_job(7)), - ); - assert_eq!(shell.as_ref(), b"\x1b[3J\x1b[2J"); - - let alternate = - maybe_filter_primary_screen_scrollback_clear(b"\x1b[3J\x1b[2J", true, Some(&droid_job)); - assert_eq!(alternate.as_ref(), b"\x1b[3J\x1b[2J"); - } - #[test] fn host_theme_restore_waits_for_shell_and_non_alternate_screen() { assert!(!should_restore_host_terminal_theme( diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index c30ef415..c9d0b90a 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; @@ -33,8 +32,7 @@ use super::{ }, kitty_keyboard::KittyKeyboardTracker, osc::{ - contains_scrollback_clear_sequence, current_transient_default_color_owner, - maybe_filter_primary_screen_scrollback_clear, parse_reported_cwd, + current_transient_default_color_owner, parse_reported_cwd, restore_host_terminal_theme_if_needed, write_host_terminal_theme_selective, AgentOscStateTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, OscDebugTracker, @@ -1381,43 +1379,18 @@ impl GhosttyPaneTerminal { } let terminal_title_changed = core.agent_osc_state.observe(bytes); - let alternate_screen = core - .terminal - .active_screen() - .map(|screen| screen == crate::ghostty::ActiveScreen::Alternate) - .unwrap_or(false); - let filtered_bytes = if shell_pid > 0 { - let foreground_job = (!alternate_screen && contains_scrollback_clear_sequence(bytes)) - .then(|| crate::detect::foreground_job(shell_pid)) - .flatten(); - maybe_filter_primary_screen_scrollback_clear( - bytes, - alternate_screen, - foreground_job.as_ref(), - ) - } else { - Cow::Borrowed(bytes) - }; - if filtered_bytes.len() != bytes.len() { - debug!( - pane = pane_id.raw(), - shell_pid, "ignored scrollback clear sequence for droid compatibility" - ); - } - - core.kitty_keyboard.observe(filtered_bytes.as_ref()); + core.kitty_keyboard.observe(bytes); let mut terminal_responses = Vec::new(); - core.default_color_event_tracker - .observe(filtered_bytes.as_ref()); - core.c1_xtgettcap_tracker.observe(filtered_bytes.as_ref()); + core.default_color_event_tracker.observe(bytes); + core.c1_xtgettcap_tracker.observe(bytes); let c1_xtgettcap_responses = core.c1_xtgettcap_tracker.drain_pending(); - core.decscusr_tracker.observe(filtered_bytes.as_ref()); + core.decscusr_tracker.observe(bytes); let in_progress_default_color_event = core.default_color_event_tracker.in_progress_event(); let default_color_events = core.default_color_event_tracker.drain_pending(); let write_started = crate::render_prof::timer(); self.write_pty_bytes_with_ordered_responses( &mut core, - filtered_bytes.as_ref(), + bytes, default_color_events, in_progress_default_color_event, c1_xtgettcap_responses, @@ -1435,8 +1408,8 @@ impl GhosttyPaneTerminal { windows_recent_fallback::update_after_write(&mut core); crate::render_prof::duration_since("pty.ghostty_write", write_started); - let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled() - && contains_kitty_graphics_sequence(filtered_bytes.as_ref()); + let has_kitty_graphics_sequence = + crate::kitty_graphics::is_enabled() && contains_kitty_graphics_sequence(bytes); if has_kitty_graphics_sequence { debug!(pane = pane_id.raw(), "processed kitty graphics sequence"); } diff --git a/src/pane/terminal/migration_tests.rs b/src/pane/terminal/migration_tests.rs index 94d78a77..09e5df01 100644 --- a/src/pane/terminal/migration_tests.rs +++ b/src/pane/terminal/migration_tests.rs @@ -46,9 +46,13 @@ impl Harness { } fn write(&mut self, bytes: &[u8]) { + self.write_from_process(0, bytes); + } + + fn write_from_process(&mut self, shell_pid: u32, bytes: &[u8]) { let result = self .pane - .process_pty_bytes(PaneId::from_raw(1), 0, bytes, &self.tx); + .process_pty_bytes(PaneId::from_raw(1), shell_pid, bytes, &self.tx); for reply in result.terminal_responses { self.effects.replies.extend_from_slice(&reply); } @@ -111,6 +115,128 @@ impl Harness { } } +#[cfg(target_os = "linux")] +#[test] +fn primary_screen_replay_honors_ed3_for_droid_at_chunk_boundaries() { + // A zero PID would bypass the former process-specific filter entirely. + // Use the real foreground-job lookup, with cleanup even on assertion failure. + struct ChildGuard(Box); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let pair = portable_pty::native_pty_system() + .openpty(portable_pty::PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut command = portable_pty::CommandBuilder::new("bash"); + command.args(["-c", "exec -a droid sleep 999"]); + let child = ChildGuard(pair.slave.spawn_command(command).unwrap()); + let pid = child.0.process_id().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let ready = crate::detect::foreground_job(pid).is_some_and(|job| { + job.processes + .iter() + .any(|process| process.cmdline.as_deref() == Some("droid 999")) + }); + if ready { + break; + } + assert!(Instant::now() < deadline, "Droid foreground job not ready"); + std::thread::sleep(Duration::from_millis(10)); + } + + for clear in ["\x1b[3J", "\x1b[?3J"] { + let prefix = format!("\x1b[?2026h\x1b[2J{clear}\x1b[H"); + let frame = |label: &str| { + let mut bytes = format!("{prefix}welcome\r\n"); + for row in 0..55 { + bytes.push_str(&format!("{label}-{row:02}\r\n")); + } + bytes.push_str("\x1b[?2026l"); + bytes + }; + let old = frame("old"); + let new = frame("new"); + // Whole writes catch the old filter; bytewise writes and every split + // within the erase prefix prove that PTY chunking cannot change ED3. + for chunk_size in [usize::MAX, 1, 7] { + for split in 0..=prefix.len() { + let mut harness = Harness::new(80, 24); + for bytes in old.as_bytes().chunks(chunk_size) { + harness.write_from_process(pid, bytes); + } + assert!(harness + .pane + .recent_text_snapshot(256) + .text + .contains("old-00")); + harness.write_from_process(pid, &new.as_bytes()[..split]); + for bytes in new.as_bytes()[split..].chunks(chunk_size) { + harness.write_from_process(pid, bytes); + } + let recent = harness.pane.recent_text_snapshot(256).text; + assert!(recent.contains("new-54"), "redraw must complete"); + assert_eq!( + recent.matches("welcome").count(), + 1, + "{clear:?}, split {split}, chunk {chunk_size}: {recent}" + ); + assert!(!recent.contains("old-"), "{recent}"); + assert!(harness.pane.visible_text().contains("new-54")); + harness.pane.scroll_up(256); + let top = harness.pane.visible_text(); + assert!(top.contains("welcome") && top.contains("new-00"), "{top}"); + assert!(!top.contains("old-"), "{top}"); + } + } + } +} + +#[test] +fn erase_display_preserves_screen_and_history_boundaries() { + for clear in [b"\x1b[3J".as_slice(), b"\x1b[?3J"] { + let mut harness = Harness::new(80, 24); + for row in 0..55 { + harness.write(format!("history-{row:02}\r\n").as_bytes()); + } + assert!(harness + .pane + .recent_text_snapshot(256) + .text + .contains("history-00")); + let primary = harness.pane.recent_text_snapshot(256); + let visible = harness.pane.visible_text(); + + harness.write(b"\x1b[?1049h\x1b[2J\x1b[Halternate"); + harness.write(clear); + assert!(harness.pane.visible_text().contains("alternate")); + harness.write(b"\x1b[?1049l"); + assert_eq!(harness.pane.recent_text_snapshot(256), primary); + assert_eq!(harness.pane.visible_text(), visible); + + // ED2 clears only the display, not prior shell output in scrollback. + harness.write(b"\x1b[2J\x1b[Hprompt"); + assert_eq!(harness.pane.visible_text().trim(), "prompt"); + assert!(harness + .pane + .recent_text_snapshot(256) + .text + .contains("history-00")); + harness.write(clear); + // ED3 clears history without erasing the current display. + assert_eq!(harness.pane.visible_text().trim(), "prompt"); + assert_eq!(harness.pane.recent_text_snapshot(256).text.trim(), "prompt"); + } +} + #[test] fn short_streams_are_invariant_at_every_byte_boundary() { let fixtures: &[&[u8]] = &[