diff --git a/src/pane.rs b/src/pane.rs index c0146e39..7424638e 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::cell::Cell; use std::io::{BufWriter, Read, Write}; use std::sync::{ @@ -485,6 +486,68 @@ fn current_transient_default_color_owner(shell_pid: u32) -> Option { (!foreground_job_is_shell(&job, shell_pid)).then_some(job.process_group_id) } +fn foreground_job_uses_droid_scrollback_compat(job: &crate::platform::ForegroundJob) -> 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") + }) + }) +} + +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) +} + +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")] fn should_restore_host_terminal_theme( owner_pgid: u32, @@ -781,7 +844,31 @@ impl GhosttyPaneTerminal { core.osc52_forwarder.observe(bytes); let clipboard_writes = core.osc52_forwarder.drain_pending(); - core.terminal.write(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.terminal.write(filtered_bytes.as_ref()); if let Ok(mut key_encoder) = self.key_encoder.lock() { key_encoder.set_from_terminal(&core.terminal); } @@ -2791,6 +2878,71 @@ mod tests { assert!(fw.drain_pending().is_empty()); } + #[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, + 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()), + 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()), + 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(