diff options
Diffstat (limited to 'src/pane')
| -rw-r--r-- | src/pane/input.rs | 293 | ||||
| -rw-r--r-- | src/pane/osc.rs | 706 | ||||
| -rw-r--r-- | src/pane/state.rs | 313 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 1307 |
4 files changed, 2619 insertions, 0 deletions
diff --git a/src/pane/input.rs b/src/pane/input.rs new file mode 100644 index 00000000..262e036d --- /dev/null +++ b/src/pane/input.rs @@ -0,0 +1,293 @@ +pub(super) fn ghostty_key_event_from_terminal_key( + key: crate::input::TerminalKey, +) -> Option<crate::ghostty::KeyEvent> { + let mut event = crate::ghostty::KeyEvent::new().ok()?; + event.set_action(match key.kind { + crossterm::event::KeyEventKind::Press => { + crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS + } + crossterm::event::KeyEventKind::Release => { + crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_RELEASE + } + crossterm::event::KeyEventKind::Repeat => { + crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_REPEAT + } + }); + event.set_mods(ghostty_mods_from_key_modifiers(key.modifiers)); + event.set_key(ghostty_key_from_crossterm_key_code( + key.code, + key.shifted_codepoint, + )?); + + if let Some(text) = ghostty_key_text(key) { + event.set_utf8(&text); + } else { + event.set_utf8(""); + } + + if let Some(codepoint) = ghostty_unshifted_codepoint(key) { + event.set_unshifted_codepoint(codepoint); + } + + Some(event) +} + +pub(super) fn ghostty_prefers_herdr_text_encoding(key: crate::input::TerminalKey) -> bool { + matches!(key.code, crossterm::event::KeyCode::Char(_)) +} + +pub(super) fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyModifiers) -> u16 { + let mut ghostty_mods = 0u16; + if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) { + ghostty_mods |= crate::ghostty::MOD_SHIFT; + } + if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { + ghostty_mods |= crate::ghostty::MOD_CTRL; + } + if modifiers.contains(crossterm::event::KeyModifiers::ALT) { + ghostty_mods |= crate::ghostty::MOD_ALT; + } + if modifiers.contains(crossterm::event::KeyModifiers::SUPER) { + ghostty_mods |= crate::ghostty::MOD_SUPER; + } + ghostty_mods +} + +pub(super) fn ghostty_mouse_encoder_for_terminal( + terminal: &crate::ghostty::Terminal, +) -> Option<crate::ghostty::MouseEncoder> { + let mut encoder = crate::ghostty::MouseEncoder::new().ok()?; + encoder.set_from_terminal(terminal); + let cols = terminal.cols().ok()? as u32; + let rows = terminal.rows().ok()? as u32; + encoder.set_size(cols, rows, 1, 1); + Some(encoder) +} + +pub(super) fn ghostty_mouse_event_from_button_kind( + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, +) -> Option<crate::ghostty::MouseEvent> { + let mut event = crate::ghostty::MouseEvent::new().ok()?; + let (action, button) = match kind { + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => ( + crate::ghostty::MOUSE_ACTION_PRESS, + Some(crate::ghostty::MOUSE_BUTTON_LEFT), + ), + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Middle) => ( + crate::ghostty::MOUSE_ACTION_PRESS, + Some(crate::ghostty::MOUSE_BUTTON_MIDDLE), + ), + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right) => ( + crate::ghostty::MOUSE_ACTION_PRESS, + Some(crate::ghostty::MOUSE_BUTTON_RIGHT), + ), + crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => ( + crate::ghostty::MOUSE_ACTION_RELEASE, + Some(crate::ghostty::MOUSE_BUTTON_LEFT), + ), + crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Middle) => ( + crate::ghostty::MOUSE_ACTION_RELEASE, + Some(crate::ghostty::MOUSE_BUTTON_MIDDLE), + ), + crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Right) => ( + crate::ghostty::MOUSE_ACTION_RELEASE, + Some(crate::ghostty::MOUSE_BUTTON_RIGHT), + ), + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => ( + crate::ghostty::MOUSE_ACTION_MOTION, + Some(crate::ghostty::MOUSE_BUTTON_LEFT), + ), + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Middle) => ( + crate::ghostty::MOUSE_ACTION_MOTION, + Some(crate::ghostty::MOUSE_BUTTON_MIDDLE), + ), + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Right) => ( + crate::ghostty::MOUSE_ACTION_MOTION, + Some(crate::ghostty::MOUSE_BUTTON_RIGHT), + ), + _ => return None, + }; + event.set_action(action); + if let Some(button) = button { + event.set_button(button); + } else { + event.clear_button(); + } + event.set_mods(ghostty_mods_from_key_modifiers(modifiers)); + event.set_position(column as f32, row as f32); + Some(event) +} + +pub(super) fn ghostty_mouse_event_from_wheel_kind( + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, +) -> Option<crate::ghostty::MouseEvent> { + let mut event = crate::ghostty::MouseEvent::new().ok()?; + event.set_action(crate::ghostty::MOUSE_ACTION_PRESS); + let button = match kind { + crossterm::event::MouseEventKind::ScrollUp => crate::ghostty::MOUSE_BUTTON_WHEEL_UP, + crossterm::event::MouseEventKind::ScrollDown => crate::ghostty::MOUSE_BUTTON_WHEEL_DOWN, + crossterm::event::MouseEventKind::ScrollLeft => crate::ghostty::MOUSE_BUTTON_WHEEL_LEFT, + crossterm::event::MouseEventKind::ScrollRight => crate::ghostty::MOUSE_BUTTON_WHEEL_RIGHT, + _ => return None, + }; + event.set_button(button); + event.set_mods(ghostty_mods_from_key_modifiers(modifiers)); + event.set_position(column as f32, row as f32); + Some(event) +} + +fn ghostty_key_text(key: crate::input::TerminalKey) -> Option<String> { + match key.code { + crossterm::event::KeyCode::Char(c) => Some( + key.shifted_codepoint + .and_then(char::from_u32) + .unwrap_or(c) + .to_string(), + ), + _ => None, + } +} + +fn ghostty_unshifted_codepoint(key: crate::input::TerminalKey) -> Option<u32> { + match key.code { + crossterm::event::KeyCode::Char(c) => Some(c as u32), + _ => None, + } +} + +fn ghostty_key_from_crossterm_key_code( + code: crossterm::event::KeyCode, + shifted_codepoint: Option<u32>, +) -> Option<u32> { + use crate::ghostty::ffi; + use crossterm::event::KeyCode; + + match code { + KeyCode::Backspace => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKSPACE), + KeyCode::Enter => Some(ffi::GhosttyKey_GHOSTTY_KEY_ENTER), + KeyCode::Left => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_LEFT), + KeyCode::Right => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_RIGHT), + KeyCode::Up => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_UP), + KeyCode::Down => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_DOWN), + KeyCode::Home => Some(ffi::GhosttyKey_GHOSTTY_KEY_HOME), + KeyCode::End => Some(ffi::GhosttyKey_GHOSTTY_KEY_END), + KeyCode::PageUp => Some(ffi::GhosttyKey_GHOSTTY_KEY_PAGE_UP), + KeyCode::PageDown => Some(ffi::GhosttyKey_GHOSTTY_KEY_PAGE_DOWN), + KeyCode::Tab | KeyCode::BackTab => Some(ffi::GhosttyKey_GHOSTTY_KEY_TAB), + KeyCode::Delete => Some(ffi::GhosttyKey_GHOSTTY_KEY_DELETE), + KeyCode::Insert => Some(ffi::GhosttyKey_GHOSTTY_KEY_INSERT), + KeyCode::Esc => Some(ffi::GhosttyKey_GHOSTTY_KEY_ESCAPE), + KeyCode::F(n) => Some(match n { + 1 => ffi::GhosttyKey_GHOSTTY_KEY_F1, + 2 => ffi::GhosttyKey_GHOSTTY_KEY_F2, + 3 => ffi::GhosttyKey_GHOSTTY_KEY_F3, + 4 => ffi::GhosttyKey_GHOSTTY_KEY_F4, + 5 => ffi::GhosttyKey_GHOSTTY_KEY_F5, + 6 => ffi::GhosttyKey_GHOSTTY_KEY_F6, + 7 => ffi::GhosttyKey_GHOSTTY_KEY_F7, + 8 => ffi::GhosttyKey_GHOSTTY_KEY_F8, + 9 => ffi::GhosttyKey_GHOSTTY_KEY_F9, + 10 => ffi::GhosttyKey_GHOSTTY_KEY_F10, + 11 => ffi::GhosttyKey_GHOSTTY_KEY_F11, + 12 => ffi::GhosttyKey_GHOSTTY_KEY_F12, + _ => return None, + }), + KeyCode::Char(c) => ghostty_key_from_char(c, shifted_codepoint), + _ => None, + } +} + +fn ghostty_key_from_char(c: char, shifted_codepoint: Option<u32>) -> Option<u32> { + use crate::ghostty::ffi; + + let base = if let Some(shifted) = shifted_codepoint.and_then(char::from_u32) { + ghostty_unshifted_ascii_pair(shifted).unwrap_or(c) + } else { + c + }; + + match base.to_ascii_lowercase() { + 'a' => Some(ffi::GhosttyKey_GHOSTTY_KEY_A), + 'b' => Some(ffi::GhosttyKey_GHOSTTY_KEY_B), + 'c' => Some(ffi::GhosttyKey_GHOSTTY_KEY_C), + 'd' => Some(ffi::GhosttyKey_GHOSTTY_KEY_D), + 'e' => Some(ffi::GhosttyKey_GHOSTTY_KEY_E), + 'f' => Some(ffi::GhosttyKey_GHOSTTY_KEY_F), + 'g' => Some(ffi::GhosttyKey_GHOSTTY_KEY_G), + 'h' => Some(ffi::GhosttyKey_GHOSTTY_KEY_H), + 'i' => Some(ffi::GhosttyKey_GHOSTTY_KEY_I), + 'j' => Some(ffi::GhosttyKey_GHOSTTY_KEY_J), + 'k' => Some(ffi::GhosttyKey_GHOSTTY_KEY_K), + 'l' => Some(ffi::GhosttyKey_GHOSTTY_KEY_L), + 'm' => Some(ffi::GhosttyKey_GHOSTTY_KEY_M), + 'n' => Some(ffi::GhosttyKey_GHOSTTY_KEY_N), + 'o' => Some(ffi::GhosttyKey_GHOSTTY_KEY_O), + 'p' => Some(ffi::GhosttyKey_GHOSTTY_KEY_P), + 'q' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Q), + 'r' => Some(ffi::GhosttyKey_GHOSTTY_KEY_R), + 's' => Some(ffi::GhosttyKey_GHOSTTY_KEY_S), + 't' => Some(ffi::GhosttyKey_GHOSTTY_KEY_T), + 'u' => Some(ffi::GhosttyKey_GHOSTTY_KEY_U), + 'v' => Some(ffi::GhosttyKey_GHOSTTY_KEY_V), + 'w' => Some(ffi::GhosttyKey_GHOSTTY_KEY_W), + 'x' => Some(ffi::GhosttyKey_GHOSTTY_KEY_X), + 'y' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Y), + 'z' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Z), + '0' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_0), + '1' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_1), + '2' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_2), + '3' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_3), + '4' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_4), + '5' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_5), + '6' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_6), + '7' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_7), + '8' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_8), + '9' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_9), + '`' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKQUOTE), + '\\' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKSLASH), + '[' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_LEFT), + ']' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_RIGHT), + ',' => Some(ffi::GhosttyKey_GHOSTTY_KEY_COMMA), + '=' => Some(ffi::GhosttyKey_GHOSTTY_KEY_EQUAL), + '-' => Some(ffi::GhosttyKey_GHOSTTY_KEY_MINUS), + '.' => Some(ffi::GhosttyKey_GHOSTTY_KEY_PERIOD), + '\'' => Some(ffi::GhosttyKey_GHOSTTY_KEY_QUOTE), + ';' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SEMICOLON), + '/' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SLASH), + ' ' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SPACE), + _ => None, + } +} + +fn ghostty_unshifted_ascii_pair(c: char) -> Option<char> { + Some(match c { + '!' => '1', + '@' => '2', + '#' => '3', + '$' => '4', + '%' => '5', + '^' => '6', + '&' => '7', + '*' => '8', + '(' => '9', + ')' => '0', + '_' => '-', + '+' => '=', + '{' => '[', + '}' => ']', + '|' => '\\', + ':' => ';', + '"' => '\'', + '<' => ',', + '>' => '.', + '?' => '/', + '~' => '`', + _ => return None, + }) +} diff --git a/src/pane/osc.rs b/src/pane/osc.rs new file mode 100644 index 00000000..b5278125 --- /dev/null +++ b/src/pane/osc.rs @@ -0,0 +1,706 @@ +use std::borrow::Cow; + +use tracing::info; + +use crate::layout::PaneId; + +use super::terminal::GhosttyPaneCore; + +#[derive(Debug, Default)] +pub(super) struct DefaultColorOscTracker { + state: DefaultColorOscTrackerState, + body: Vec<u8>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum DefaultColorOscTrackerState { + #[default] + Ground, + Escape, + OscBody, + OscEscape, +} + +impl DefaultColorOscTracker { + pub(super) fn observe(&mut self, bytes: &[u8]) -> bool { + let mut saw_default_color_set = false; + + for &byte in bytes { + match self.state { + DefaultColorOscTrackerState::Ground => { + if byte == 0x1b { + self.state = DefaultColorOscTrackerState::Escape; + } + } + DefaultColorOscTrackerState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = DefaultColorOscTrackerState::OscBody; + } else if byte == 0x1b { + self.state = DefaultColorOscTrackerState::Escape; + } else { + self.state = DefaultColorOscTrackerState::Ground; + } + } + DefaultColorOscTrackerState::OscBody => match byte { + 0x07 => { + saw_default_color_set |= is_default_color_set_osc(&self.body); + self.body.clear(); + self.state = DefaultColorOscTrackerState::Ground; + } + 0x1b => self.state = DefaultColorOscTrackerState::OscEscape, + _ => self.body.push(byte), + }, + DefaultColorOscTrackerState::OscEscape => { + if byte == b'\\' { + saw_default_color_set |= is_default_color_set_osc(&self.body); + self.body.clear(); + self.state = DefaultColorOscTrackerState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = DefaultColorOscTrackerState::OscBody; + } + } + } + + if self.body.len() > 1024 { + self.body.clear(); + self.state = DefaultColorOscTrackerState::Ground; + } + } + + saw_default_color_set + } +} + +fn is_default_color_set_osc(body: &[u8]) -> bool { + let Some(separator) = body.iter().position(|byte| *byte == b';') else { + return false; + }; + let command = &body[..separator]; + let value = &body[separator + 1..]; + matches!(command, b"10" | b"11") && !value.is_empty() && value != b"?" +} + +/// 256 KiB of base64 ≈ 192 KiB of text — enough for real source-file copies +/// while still bounding memory against stream garbage. +const OSC52_MAX_PAYLOAD_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum Osc52ForwarderState { + #[default] + Ground, + Escape, + OscBody, + OscEscape, +} + +/// Reconstructs OSC 52 clipboard-write sequences from raw PTY bytes so the +/// main loop can re-emit them. `libghostty-vt` drops `.clipboard_contents`, +/// so child clipboard writes never reach the host terminal unless we forward +/// them ourselves. +#[derive(Debug, Default)] +pub(super) struct Osc52Forwarder { + state: Osc52ForwarderState, + body: Vec<u8>, + pending: Vec<Vec<u8>>, +} + +impl Osc52Forwarder { + pub(super) fn observe(&mut self, bytes: &[u8]) { + for &byte in bytes { + match self.state { + Osc52ForwarderState::Ground => { + if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } + } + Osc52ForwarderState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = Osc52ForwarderState::OscBody; + } else if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } else { + self.state = Osc52ForwarderState::Ground; + } + } + Osc52ForwarderState::OscBody => match byte { + 0x07 => { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } + 0x1b => self.state = Osc52ForwarderState::OscEscape, + _ => self.body.push(byte), + }, + Osc52ForwarderState::OscEscape => { + if byte == b'\\' { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = Osc52ForwarderState::OscBody; + } + } + } + + if self.body.len() > OSC52_MAX_PAYLOAD_BYTES { + self.body.clear(); + self.state = Osc52ForwarderState::Ground; + } + } + } + + fn finalize(&mut self) { + if let Some(content) = parse_osc52_clipboard_write(&self.body) { + self.pending.push(content); + } + self.body.clear(); + } + + pub(super) fn drain_pending(&mut self) -> Vec<Vec<u8>> { + std::mem::take(&mut self.pending) + } +} + +/// Accepts `52;c;<base64>` and `52;;<base64>`. +/// Queries (`?`) are rejected because herdr has no reply path. +/// The payload must decode as base64 before it is forwarded. +fn parse_osc52_clipboard_write(body: &[u8]) -> Option<Vec<u8>> { + use base64::Engine; + + let rest = body.strip_prefix(b"52;")?; + let sep = rest.iter().position(|b| *b == b';')?; + let selector = &rest[..sep]; + let data = &rest[sep + 1..]; + if !(selector.is_empty() || selector == b"c") || data == b"?" { + return None; + } + base64::engine::general_purpose::STANDARD.decode(data).ok() +} + +fn foreground_job_is_shell(job: &crate::platform::ForegroundJob, shell_pid: u32) -> bool { + job.processes.iter().any(|process| process.pid == shell_pid) +} + +pub(super) fn current_transient_default_color_owner(shell_pid: u32) -> Option<u32> { + let job = crate::detect::foreground_job(shell_pid)?; + (!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") + }) + }) +} + +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, + shell_pid: u32, + alternate_screen: bool, + foreground_job: Option<&crate::platform::ForegroundJob>, +) -> bool { + if alternate_screen { + return false; + } + + let Some(foreground_job) = foreground_job else { + return false; + }; + + let _ = owner_pgid; + foreground_job_is_shell(foreground_job, shell_pid) +} + +#[cfg(not(target_os = "macos"))] +pub(super) fn should_restore_host_terminal_theme( + owner_pgid: u32, + shell_pid: u32, + alternate_screen: bool, + foreground_job: Option<&crate::platform::ForegroundJob>, +) -> bool { + if alternate_screen { + return false; + } + + let Some(foreground_job) = foreground_job else { + return false; + }; + + foreground_job.process_group_id != owner_pgid + && foreground_job_is_shell(foreground_job, shell_pid) +} + +pub(super) fn write_host_terminal_theme( + terminal: &mut crate::ghostty::Terminal, + theme: crate::terminal_theme::TerminalTheme, +) { + if let Some(color) = theme.foreground { + let sequence = crate::terminal_theme::osc_set_default_color_sequence( + crate::terminal_theme::DefaultColorKind::Foreground, + color, + ); + terminal.write(sequence.as_bytes()); + } + if let Some(color) = theme.background { + let sequence = crate::terminal_theme::osc_set_default_color_sequence( + crate::terminal_theme::DefaultColorKind::Background, + color, + ); + terminal.write(sequence.as_bytes()); + } +} + +pub(super) fn restore_host_terminal_theme_if_needed( + core: &mut GhosttyPaneCore, + pane_id: PaneId, + shell_pid: u32, + alternate_screen: bool, + foreground_job: Option<&crate::platform::ForegroundJob>, +) -> bool { + let Some(owner_pgid) = core.transient_default_color_owner_pgid else { + return false; + }; + if core.host_terminal_theme.is_empty() { + return false; + } + if !should_restore_host_terminal_theme(owner_pgid, shell_pid, alternate_screen, foreground_job) + { + return false; + } + + core.transient_default_color_owner_pgid = None; + write_host_terminal_theme(&mut core.terminal, core.host_terminal_theme); + info!( + pane = pane_id.raw(), + owner_pgid, "restored host terminal default colors after transient override" + ); + true +} + +#[cfg(test)] +mod tests { + use tokio::sync::mpsc; + + use super::*; + use crate::layout::PaneId; + + fn pane_default_theme( + pane: &super::super::GhosttyPaneTerminal, + ) -> crate::terminal_theme::TerminalTheme { + let mut core = pane.core.lock().unwrap(); + let super::super::terminal::GhosttyPaneCore { + terminal, + render_state, + .. + } = &mut *core; + render_state.update(terminal).unwrap(); + let colors = render_state.colors().unwrap(); + crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: colors.foreground.r, + g: colors.foreground.g, + b: colors.foreground.b, + }), + background: Some(crate::terminal_theme::RgbColor { + r: colors.background.r, + g: colors.background.g, + b: colors.background.b, + }), + } + } + + fn shell_job(shell_pid: u32) -> crate::platform::ForegroundJob { + crate::platform::ForegroundJob { + process_group_id: shell_pid, + processes: vec![crate::platform::ForegroundProcess { + pid: shell_pid, + name: "zsh".to_string(), + argv0: Some("zsh".to_string()), + cmdline: Some("zsh".to_string()), + }], + } + } + + #[test] + fn default_color_tracker_detects_split_osc_11_sequences() { + let mut tracker = DefaultColorOscTracker::default(); + + assert!(!tracker.observe(b"\x1b]11;rgb:11/22")); + assert!(tracker.observe(b"/33\x1b\\")); + } + + #[test] + fn default_color_tracker_ignores_osc_queries() { + let mut tracker = DefaultColorOscTracker::default(); + + assert!(!tracker.observe(b"\x1b]10;?\x1b\\")); + assert!(!tracker.observe(b"\x1b]11;?\x07")); + } + + #[test] + fn osc52_forwarder_detects_write_with_bel() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;aGVsbG8=\x07"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hello".to_vec()]); + } + + #[test] + fn osc52_forwarder_detects_write_with_st() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;aGVsbG8=\x1b\\"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hello".to_vec()]); + } + + #[test] + fn osc52_forwarder_detects_empty_selector_form() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;;aGVsbG8=\x07"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hello".to_vec()]); + } + + #[test] + fn osc52_forwarder_accepts_clear_clipboard() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;\x07"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![Vec::<u8>::new()]); + } + + #[test] + fn osc52_forwarder_ignores_query() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;?\x07"); + assert!(fw.drain_pending().is_empty()); + } + + #[test] + fn osc52_forwarder_ignores_empty_selector_query() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;;?\x07"); + assert!(fw.drain_pending().is_empty()); + } + + #[test] + fn osc52_forwarder_ignores_other_kinds() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;p;aGk=\x07"); + fw.observe(b"\x1b]52;s;aGk=\x07"); + fw.observe(b"\x1b]52;q;aGk=\x07"); + fw.observe(b"\x1b]52;0;aGk=\x07"); + fw.observe(b"\x1b]52;7;aGk=\x07"); + assert!(fw.drain_pending().is_empty()); + } + + #[test] + fn osc52_forwarder_ignores_invalid_base64() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;%%%\x07"); + fw.observe(b"\x1b]52;c;aGVs\x1b[bG8=\x07"); + assert!(fw.drain_pending().is_empty()); + } + + #[test] + fn osc52_forwarder_ignores_non_osc52() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]11;?\x07"); + fw.observe(b"\x1b]0;title\x07"); + fw.observe(b"\x1b]8;;https://example.com\x1b\\"); + assert!(fw.drain_pending().is_empty()); + } + + #[test] + fn osc52_forwarder_handles_split_sequence_mid_payload() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;aGVs"); + assert!(fw.drain_pending().is_empty()); + fw.observe(b"bG8gd29y"); + assert!(fw.drain_pending().is_empty()); + fw.observe(b"bGQ=\x07"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hello world".to_vec()]); + } + + #[test] + fn osc52_forwarder_handles_split_before_bel() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;aGk="); + assert!(fw.drain_pending().is_empty()); + fw.observe(b"\x07"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hi".to_vec()]); + } + + #[test] + fn osc52_forwarder_handles_split_between_esc_and_backslash() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;aGk=\x1b"); + assert!(fw.drain_pending().is_empty()); + fw.observe(b"\\"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hi".to_vec()]); + } + + #[test] + fn osc52_forwarder_payload_size_limit() { + let mut fw = Osc52Forwarder::default(); + let mut huge = Vec::with_capacity(OSC52_MAX_PAYLOAD_BYTES + 32); + huge.extend_from_slice(b"\x1b]52;c;"); + huge.extend(std::iter::repeat_n(b'A', OSC52_MAX_PAYLOAD_BYTES + 16)); + huge.push(0x07); + fw.observe(&huge); + assert!(fw.drain_pending().is_empty()); + + fw.observe(b"\x1b]52;c;aGk=\x07"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hi".to_vec()]); + } + + #[test] + fn osc52_forwarder_recovers_after_garbage() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x01\x02random\x7fbytes\x1b]52;c;aGk=\x07tail"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hi".to_vec()]); + } + + #[test] + fn osc52_forwarder_multiple_in_one_chunk() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;aGk=\x07\x1b]52;c;Ynll\x07"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![b"hi".to_vec(), b"bye".to_vec()]); + } + + #[test] + fn osc52_forwarder_drain_clears_pending() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;aGk=\x07"); + assert_eq!(fw.drain_pending(), vec![b"hi".to_vec()]); + 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( + 42, + 7, + true, + Some(&shell_job(7)), + )); + assert!(!should_restore_host_terminal_theme(42, 7, false, None)); + assert!(!should_restore_host_terminal_theme( + 42, + 7, + false, + Some(&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()), + }], + }), + )); + assert!(should_restore_host_terminal_theme( + 42, + 7, + false, + Some(&shell_job(7)), + )); + + #[cfg(target_os = "macos")] + assert!(should_restore_host_terminal_theme( + 7, + 7, + false, + Some(&shell_job(7)), + )); + + #[cfg(not(target_os = "macos"))] + assert!(!should_restore_host_terminal_theme( + 7, + 7, + false, + Some(&shell_job(7)), + )); + } + + #[test] + fn restore_host_terminal_theme_reapplies_cached_colors() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = super::super::GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let pane_id = PaneId::from_raw(1); + let shell_pid = 7; + let host_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 0xaa, + g: 0xbb, + b: 0xcc, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 0x11, + g: 0x22, + b: 0x33, + }), + }; + + pane.apply_host_terminal_theme(host_theme); + { + let mut core = pane.core.lock().unwrap(); + core.transient_default_color_owner_pgid = Some(42); + core.terminal.write(b"\x1b]11;rgb:dd/ee/ff\x1b\\"); + } + assert_eq!( + pane_default_theme(&pane).background, + Some(crate::terminal_theme::RgbColor { + r: 0xdd, + g: 0xee, + b: 0xff, + }) + ); + + { + let mut core = pane.core.lock().unwrap(); + assert!(restore_host_terminal_theme_if_needed( + &mut core, + pane_id, + shell_pid, + false, + Some(&shell_job(shell_pid)), + )); + } + + assert_eq!(pane_default_theme(&pane).background, host_theme.background); + assert_eq!(pane_default_theme(&pane).foreground, host_theme.foreground); + } +} diff --git a/src/pane/state.rs b/src/pane/state.rs new file mode 100644 index 00000000..973f660a --- /dev/null +++ b/src/pane/state.rs @@ -0,0 +1,313 @@ +use crate::detect::{Agent, AgentState}; + +const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookAuthority { + pub source: String, + pub agent_label: String, + pub state: AgentState, + pub message: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveStateChange { + pub previous_agent_label: Option<String>, + pub previous_known_agent: Option<Agent>, + pub previous_state: AgentState, + pub agent_label: Option<String>, + pub known_agent: Option<Agent>, + pub state: AgentState, +} + +/// Observable state for a single pane. +/// This is the only part of a pane that workspace logic and tests need. +pub struct PaneState { + pub detected_agent: Option<Agent>, + pub fallback_state: AgentState, + pub hook_authority: Option<HookAuthority>, + pub state: AgentState, + /// Whether the user has seen this pane since its last state change to Idle. + /// False = "Done" (agent finished while user was in another workspace). + pub seen: bool, +} + +impl PaneState { + pub fn new() -> Self { + Self { + detected_agent: None, + fallback_state: AgentState::Unknown, + hook_authority: None, + state: AgentState::Unknown, + seen: true, + } + } + + pub fn set_detected_state( + &mut self, + agent: Option<Agent>, + fallback_state: AgentState, + ) -> Option<EffectiveStateChange> { + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + self.detected_agent = agent; + self.fallback_state = fallback_state; + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + pub fn set_hook_authority( + &mut self, + source: String, + agent_label: String, + state: AgentState, + message: Option<String>, + ) -> Option<EffectiveStateChange> { + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + self.hook_authority = Some(HookAuthority { + source, + agent_label, + state, + message, + }); + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + pub fn clear_hook_authority(&mut self, source: Option<&str>) -> Option<EffectiveStateChange> { + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + let should_clear = self + .hook_authority + .as_ref() + .is_some_and(|authority| source.is_none_or(|source| authority.source == source)); + if !should_clear { + return None; + } + self.hook_authority = None; + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + pub fn release_agent( + &mut self, + source: &str, + agent_label: &str, + ) -> Option<EffectiveStateChange> { + let Some(current_agent_label) = self.effective_agent_label() else { + return None; + }; + if current_agent_label != agent_label { + return None; + } + + if self.hook_authority.as_ref().is_some_and(|authority| { + authority.agent_label != agent_label || authority.source != source + }) { + return None; + } + + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + self.detected_agent = None; + self.fallback_state = AgentState::Unknown; + self.hook_authority = None; + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + pub fn effective_agent_label(&self) -> Option<&str> { + self.hook_authority + .as_ref() + .map(|authority| authority.agent_label.as_str()) + .or_else(|| self.detected_agent.map(crate::detect::agent_label)) + } + + pub fn effective_known_agent(&self) -> Option<Agent> { + if let Some(authority) = &self.hook_authority { + return crate::detect::parse_agent_label(&authority.agent_label); + } + self.detected_agent + } + + fn recompute_effective_state( + &mut self, + previous_agent_label: Option<String>, + previous_known_agent: Option<Agent>, + previous_state: AgentState, + ) -> Option<EffectiveStateChange> { + let state = self + .hook_authority + .as_ref() + .map(|authority| authority.state) + .unwrap_or(self.fallback_state); + let agent_label = self.effective_agent_label().map(str::to_string); + let known_agent = self.effective_known_agent(); + + if previous_agent_label == agent_label && previous_state == state { + return None; + } + + self.state = state; + Some(EffectiveStateChange { + previous_agent_label, + previous_known_agent, + previous_state, + agent_label, + known_agent, + state, + }) + } +} + +pub(crate) fn stabilize_agent_state( + agent: Option<Agent>, + previous: AgentState, + raw: AgentState, + now: std::time::Instant, + last_claude_working_at: &mut Option<std::time::Instant>, +) -> AgentState { + if agent != Some(Agent::Claude) { + return raw; + } + + match raw { + AgentState::Working => { + *last_claude_working_at = Some(now); + AgentState::Working + } + AgentState::Blocked => AgentState::Blocked, + AgentState::Idle if previous == AgentState::Working => { + if last_claude_working_at + .is_some_and(|last_working| now.duration_since(last_working) < CLAUDE_WORKING_HOLD) + { + AgentState::Working + } else { + AgentState::Idle + } + } + _ => raw, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_working_is_sticky_for_short_gap() { + let now = std::time::Instant::now(); + let mut last_working = None; + + let working = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Idle, + AgentState::Working, + now, + &mut last_working, + ); + assert_eq!(working, AgentState::Working); + + let still_working = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Working, + AgentState::Idle, + now + std::time::Duration::from_millis(400), + &mut last_working, + ); + assert_eq!(still_working, AgentState::Working); + } + + #[test] + fn claude_transitions_to_idle_after_hold_expires() { + let now = std::time::Instant::now(); + let mut last_working = Some(now); + + let state = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Working, + AgentState::Idle, + now + CLAUDE_WORKING_HOLD + std::time::Duration::from_millis(1), + &mut last_working, + ); + assert_eq!(state, AgentState::Idle); + } + + #[test] + fn non_claude_states_are_unchanged() { + let now = std::time::Instant::now(); + let mut last_working = None; + + let state = stabilize_agent_state( + Some(Agent::Codex), + AgentState::Working, + AgentState::Idle, + now, + &mut last_working, + ); + assert_eq!(state, AgentState::Idle); + } + + #[test] + fn hook_authority_overrides_fallback_for_same_agent() { + let mut pane = PaneState::new(); + pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); + pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None); + + assert_eq!(pane.detected_agent, Some(Agent::Pi)); + assert_eq!(pane.fallback_state, AgentState::Idle); + assert_eq!(pane.effective_agent_label(), Some("pi")); + assert_eq!(pane.state, AgentState::Working); + } + + #[test] + fn hook_authority_can_override_with_unknown_agent_label() { + let mut pane = PaneState::new(); + pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); + pane.set_hook_authority( + "herdr:custom".into(), + "hermes".into(), + AgentState::Working, + None, + ); + + assert_eq!(pane.detected_agent, Some(Agent::Pi)); + assert_eq!(pane.effective_agent_label(), Some("hermes")); + assert_eq!(pane.effective_known_agent(), None); + assert_eq!(pane.state, AgentState::Working); + } + + #[test] + fn hook_authority_survives_detected_agent_changes() { + let mut pane = PaneState::new(); + pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); + pane.set_hook_authority( + "herdr:custom".into(), + "hermes".into(), + AgentState::Working, + None, + ); + + pane.set_detected_state(None, AgentState::Unknown); + + assert!(pane.hook_authority.is_some()); + assert_eq!(pane.detected_agent, None); + assert_eq!(pane.effective_agent_label(), Some("hermes")); + assert_eq!(pane.state, AgentState::Working); + } + + #[test] + fn release_agent_clears_identity_immediately() { + let mut pane = PaneState::new(); + pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); + pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None); + + pane.release_agent("herdr:pi", "pi"); + + assert!(pane.hook_authority.is_none()); + assert_eq!(pane.detected_agent, None); + assert_eq!(pane.fallback_state, AgentState::Unknown); + assert_eq!(pane.state, AgentState::Unknown); + } +} diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs new file mode 100644 index 00000000..796220b9 --- /dev/null +++ b/src/pane/terminal.rs @@ -0,0 +1,1307 @@ +use std::borrow::Cow; +use std::sync::Mutex; + +use bytes::Bytes; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::{layout::Rect, Frame}; +use tokio::sync::mpsc; +use tracing::{debug, error}; +use unicode_width::UnicodeWidthStr; + +use crate::layout::PaneId; + +use super::{ + input::{ + ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal, + ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_wheel_kind, + ghostty_prefers_herdr_text_encoding, + }, + osc::{ + contains_scrollback_clear_sequence, current_transient_default_color_owner, + maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, + write_host_terminal_theme, DefaultColorOscTracker, Osc52Forwarder, + }, +}; + +const DEFAULT_DETECTION_ROWS: usize = 24; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollMetrics { + pub offset_from_bottom: usize, + pub max_offset_from_bottom: usize, + pub viewport_rows: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InputState { + pub alternate_screen: bool, + pub application_cursor: bool, + pub bracketed_paste: bool, + pub focus_reporting: bool, + pub mouse_protocol_mode: crate::input::MouseProtocolMode, + pub mouse_protocol_encoding: crate::input::MouseProtocolEncoding, + pub mouse_alternate_scroll: bool, +} + +impl InputState { + pub fn mouse_reporting_enabled(self) -> bool { + self.mouse_protocol_mode.reporting_enabled() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProcessBytesResult { + pub request_render: bool, + pub clipboard_writes: Vec<Vec<u8>>, +} + +pub(crate) struct GhosttyPaneTerminal { + pub core: Mutex<GhosttyPaneCore>, + key_encoder: Mutex<crate::ghostty::KeyEncoder>, +} + +pub(crate) struct GhosttyPaneCore { + pub terminal: crate::ghostty::Terminal, + pub render_state: crate::ghostty::RenderState, + pub host_terminal_theme: crate::terminal_theme::TerminalTheme, + pub transient_default_color_owner_pgid: Option<u32>, + pub default_color_tracker: DefaultColorOscTracker, + pub osc52_forwarder: Osc52Forwarder, +} + +pub(crate) struct PaneTerminal { + pub(crate) ghostty: GhosttyPaneTerminal, +} + +impl PaneTerminal { + pub(crate) fn new(ghostty: GhosttyPaneTerminal) -> Self { + Self { ghostty } + } + + pub fn process_pty_bytes( + &self, + pane_id: PaneId, + shell_pid: u32, + bytes: &[u8], + response_writer: &mpsc::Sender<Bytes>, + ) -> ProcessBytesResult { + self.ghostty + .process_pty_bytes(pane_id, shell_pid, bytes, response_writer) + } + + pub fn resize(&self, rows: u16, cols: u16) { + self.ghostty.resize(rows, cols); + } + + pub fn scroll_up(&self, lines: usize) { + self.ghostty.scroll_up(lines); + } + + pub fn scroll_down(&self, lines: usize) { + self.ghostty.scroll_down(lines); + } + + pub fn scroll_reset(&self) { + self.ghostty.scroll_reset(); + } + + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { + self.ghostty.set_scroll_offset_from_bottom(lines); + } + + pub fn scroll_metrics(&self) -> Option<ScrollMetrics> { + self.ghostty.scroll_metrics() + } + + pub fn input_state(&self) -> Option<InputState> { + self.ghostty.input_state() + } + + pub fn visible_text(&self) -> String { + self.ghostty.visible_text() + } + + pub fn detection_text(&self) -> String { + self.ghostty.detection_text() + } + + pub fn recent_text(&self, lines: usize) -> String { + self.ghostty.recent_text(lines) + } + + pub fn recent_unwrapped_text(&self, lines: usize) -> String { + self.ghostty.recent_unwrapped_text(lines) + } + + pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> { + self.ghostty.extract_selection(selection) + } + + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { + self.ghostty.render(frame, area, show_cursor); + } + + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { + self.ghostty.apply_host_terminal_theme(theme); + } + + pub fn has_transient_default_color_override(&self) -> bool { + self.ghostty.has_transient_default_color_override() + } + + pub fn maybe_restore_host_terminal_theme(&self, pane_id: PaneId, shell_pid: u32) -> bool { + self.ghostty + .maybe_restore_host_terminal_theme(pane_id, shell_pid) + } + + pub fn keyboard_protocol( + &self, + fallback: crate::input::KeyboardProtocol, + ) -> crate::input::KeyboardProtocol { + self.ghostty.keyboard_protocol().unwrap_or(fallback) + } + + pub fn encode_terminal_key( + &self, + key: crate::input::TerminalKey, + protocol: crate::input::KeyboardProtocol, + ) -> Vec<u8> { + self.ghostty.encode_terminal_key(key, protocol) + } + + pub fn encode_mouse_button( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + self.ghostty + .encode_mouse_button(kind, column, row, modifiers) + } + + pub fn encode_mouse_wheel( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + self.ghostty + .encode_mouse_wheel(kind, column, row, modifiers) + } +} + +impl GhosttyPaneTerminal { + pub fn new( + mut terminal: crate::ghostty::Terminal, + response_writer: mpsc::Sender<Bytes>, + ) -> std::io::Result<Self> { + terminal + .set_write_pty_callback(move |bytes| { + let _ = response_writer.try_send(Bytes::copy_from_slice(bytes)); + }) + .map_err(|e| std::io::Error::other(e.to_string()))?; + + let render_state = + crate::ghostty::RenderState::new().map_err(|e| std::io::Error::other(e.to_string()))?; + let mut key_encoder = + crate::ghostty::KeyEncoder::new().map_err(|e| std::io::Error::other(e.to_string()))?; + key_encoder.set_from_terminal(&terminal); + Ok(Self { + core: Mutex::new(GhosttyPaneCore { + terminal, + render_state, + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + transient_default_color_owner_pgid: None, + default_color_tracker: DefaultColorOscTracker::default(), + osc52_forwarder: Osc52Forwarder::default(), + }), + key_encoder: Mutex::new(key_encoder), + }) + } + + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { + if let Ok(mut core) = self.core.lock() { + core.host_terminal_theme = theme; + core.transient_default_color_owner_pgid = None; + write_host_terminal_theme(&mut core.terminal, theme); + } + } + + pub fn has_transient_default_color_override(&self) -> bool { + self.core + .lock() + .map(|core| core.transient_default_color_owner_pgid.is_some()) + .unwrap_or(false) + } + + pub fn maybe_restore_host_terminal_theme(&self, pane_id: PaneId, shell_pid: u32) -> bool { + let foreground_job = crate::detect::foreground_job(shell_pid); + let Ok(mut core) = self.core.lock() else { + return false; + }; + + let alternate_screen = core + .terminal + .active_screen() + .map(|screen| screen == crate::ghostty::ActiveScreen::Alternate) + .unwrap_or(false); + restore_host_terminal_theme_if_needed( + &mut core, + pane_id, + shell_pid, + alternate_screen, + foreground_job.as_ref(), + ) + } + + pub fn process_pty_bytes( + &self, + pane_id: PaneId, + shell_pid: u32, + bytes: &[u8], + response_writer: &mpsc::Sender<Bytes>, + ) -> ProcessBytesResult { + let Ok(mut core) = self.core.lock() else { + error!(pane = pane_id.raw(), "ghostty core lock poisoned in reader"); + return ProcessBytesResult { + request_render: false, + clipboard_writes: Vec::new(), + }; + }; + + if shell_pid > 0 && core.default_color_tracker.observe(bytes) { + if let Some(owner_pgid) = current_transient_default_color_owner(shell_pid) { + core.transient_default_color_owner_pgid = Some(owner_pgid); + debug!( + pane = pane_id.raw(), + owner_pgid, "tracked transient default color override" + ); + } + } + + core.osc52_forwarder.observe(bytes); + let clipboard_writes = core.osc52_forwarder.drain_pending(); + + 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); + } + let synchronized_output = core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false); + let _ = response_writer; + ProcessBytesResult { + request_render: !synchronized_output, + clipboard_writes, + } + } + + pub fn resize(&self, rows: u16, cols: u16) { + if let Ok(mut core) = self.core.lock() { + let _ = core.terminal.resize(cols, rows); + } + } + + pub fn scroll_up(&self, lines: usize) { + if let Ok(mut core) = self.core.lock() { + core.terminal.scroll_viewport_delta(-(lines as isize)); + } + } + + pub fn scroll_down(&self, lines: usize) { + if let Ok(mut core) = self.core.lock() { + core.terminal.scroll_viewport_delta(lines as isize); + } + } + + pub fn scroll_reset(&self) { + if let Ok(mut core) = self.core.lock() { + core.terminal.scroll_viewport_bottom(); + } + } + + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { + if let Ok(mut core) = self.core.lock() { + core.terminal.scroll_viewport_bottom(); + if lines > 0 { + core.terminal.scroll_viewport_delta(-(lines as isize)); + } + } + } + + pub fn scroll_metrics(&self) -> Option<ScrollMetrics> { + let Ok(core) = self.core.lock() else { + return None; + }; + let scrollbar = core.terminal.scrollbar().ok()?; + Some(ScrollMetrics { + offset_from_bottom: scrollbar + .total + .saturating_sub(scrollbar.offset + scrollbar.len), + max_offset_from_bottom: scrollbar.total.saturating_sub(scrollbar.len), + viewport_rows: scrollbar.len, + }) + } + + pub fn keyboard_protocol(&self) -> Option<crate::input::KeyboardProtocol> { + let Ok(core) = self.core.lock() else { + return None; + }; + Some(crate::input::KeyboardProtocol::from_kitty_flags( + core.terminal.kitty_keyboard_flags().ok()? as u16, + )) + } + + pub fn input_state(&self) -> Option<InputState> { + let Ok(core) = self.core.lock() else { + return None; + }; + let alternate_screen = + core.terminal.active_screen().ok()? == crate::ghostty::ActiveScreen::Alternate; + let application_cursor = core + .terminal + .mode_get(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS) + .ok()?; + let bracketed_paste = core + .terminal + .mode_get(crate::ghostty::MODE_BRACKETED_PASTE) + .ok()?; + let focus_reporting = core + .terminal + .mode_get(crate::ghostty::MODE_FOCUS_EVENT) + .ok()?; + let mouse_sgr = core + .terminal + .mode_get(crate::ghostty::MODE_MOUSE_SGR) + .ok()?; + let mouse_utf8 = core + .terminal + .mode_get(crate::ghostty::MODE_MOUSE_UTF8) + .ok()?; + let mouse_alternate_scroll = core + .terminal + .mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL) + .ok()?; + let mouse_protocol_mode = if core.terminal.mode_get(1003).ok()? { + crate::input::MouseProtocolMode::AnyMotion + } else if core.terminal.mode_get(1002).ok()? { + crate::input::MouseProtocolMode::ButtonMotion + } else if core.terminal.mode_get(1000).ok()? { + crate::input::MouseProtocolMode::PressRelease + } else if core.terminal.mode_get(9).ok()? { + crate::input::MouseProtocolMode::Press + } else { + crate::input::MouseProtocolMode::None + }; + let mouse_protocol_encoding = if mouse_sgr { + crate::input::MouseProtocolEncoding::Sgr + } else if mouse_utf8 { + crate::input::MouseProtocolEncoding::Utf8 + } else { + crate::input::MouseProtocolEncoding::Default + }; + Some(InputState { + alternate_screen, + application_cursor, + bracketed_paste, + focus_reporting, + mouse_protocol_mode, + mouse_protocol_encoding, + mouse_alternate_scroll, + }) + } + + pub fn encode_terminal_key( + &self, + key: crate::input::TerminalKey, + protocol: crate::input::KeyboardProtocol, + ) -> Vec<u8> { + if ghostty_prefers_herdr_text_encoding(key) { + return crate::input::encode_terminal_key(key, protocol); + } + + let Some(event) = ghostty_key_event_from_terminal_key(key) else { + return crate::input::encode_terminal_key(key, protocol); + }; + + let Ok(mut encoder) = self.key_encoder.lock() else { + return crate::input::encode_terminal_key(key, protocol); + }; + match encoder.encode(&event) { + Ok(bytes) if !bytes.is_empty() => bytes, + Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol), + } + } + + pub fn encode_mouse_button( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + let Ok(core) = self.core.lock() else { + return None; + }; + let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?; + let event = ghostty_mouse_event_from_button_kind(kind, column, row, modifiers)?; + encoder.encode(&event).ok() + } + + pub fn encode_mouse_wheel( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + let Ok(core) = self.core.lock() else { + return None; + }; + let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?; + let event = ghostty_mouse_event_from_wheel_kind(kind, column, row, modifiers)?; + encoder.encode(&event).ok() + } + + pub fn visible_text(&self) -> String { + self.core + .lock() + .ok() + .and_then(|mut core| ghostty_visible_text(&mut core).ok()) + .unwrap_or_default() + } + + pub fn detection_text(&self) -> String { + self.core + .lock() + .ok() + .and_then(|core| ghostty_detection_text(&core).ok()) + .unwrap_or_default() + } + + pub fn recent_text(&self, lines: usize) -> String { + self.core + .lock() + .ok() + .and_then(|core| ghostty_recent_text(&core, lines).ok()) + .unwrap_or_default() + } + + pub fn recent_unwrapped_text(&self, lines: usize) -> String { + self.core + .lock() + .ok() + .and_then(|core| ghostty_recent_text_unwrapped(&core, lines).ok()) + .unwrap_or_default() + } + + pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> { + self.core + .lock() + .ok() + .and_then(|mut core| ghostty_extract_selection(&mut core, selection).ok()) + } + + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { + let Ok(mut core) = self.core.lock() else { + return; + }; + let host_theme = core.host_terminal_theme; + let GhosttyPaneCore { + terminal, + render_state, + .. + } = &mut *core; + if render_state.update(terminal).is_err() { + return; + } + let colors = render_state.colors().ok(); + let default_bg = colors.and_then(|c| ghostty_default_bg(c.background, host_theme)); + let default_fg = colors.map(|c| ghostty_color(c.foreground)); + + let mut row_iterator = match crate::ghostty::RowIterator::new() { + Ok(iterator) => iterator, + Err(_) => return, + }; + let mut row_cells = match crate::ghostty::RowCells::new() { + Ok(cells) => cells, + Err(_) => return, + }; + { + let buf = frame.buffer_mut(); + let mut rows = match render_state.populate_row_iterator(&mut row_iterator) { + Ok(rows) => rows, + Err(_) => return, + }; + let mut y = 0u16; + while y < area.height && rows.next() { + let mut cells = match rows.populate_cells(&mut row_cells) { + Ok(cells) => cells, + Err(_) => break, + }; + let mut x = 0u16; + while x < area.width && cells.next() { + let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow); + let style = ghostty_cell_style(&cells, default_fg, default_bg); + let symbol = ghostty_buffer_symbol(&cells, wide) + .unwrap_or_else(|_| ghostty_blank_symbol_for_width(wide).to_string()); + let cell = &mut buf[(area.x + x, area.y + y)]; + cell.reset(); + cell.set_symbol(&symbol); + cell.set_style(style); + x += 1; + } + while x < area.width { + let cell = &mut buf[(area.x + x, area.y + y)]; + ghostty_reset_cell(cell, default_fg, default_bg); + x += 1; + } + y += 1; + } + while y < area.height { + for x in 0..area.width { + let cell = &mut buf[(area.x + x, area.y + y)]; + ghostty_reset_cell(cell, default_fg, default_bg); + } + y += 1; + } + } + + if show_cursor && render_state.cursor_visible().ok() == Some(true) { + if let Ok(Some(cursor)) = render_state.cursor_viewport() { + if cursor.x < area.width && cursor.y < area.height { + frame.set_cursor_position((area.x + cursor.x, area.y + cursor.y)); + } + } + } + } +} + +fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result<String, crate::ghostty::Error> { + let GhosttyPaneCore { + terminal, + render_state, + .. + } = core; + render_state.update(terminal)?; + let mut row_iterator = crate::ghostty::RowIterator::new()?; + let mut row_cells = crate::ghostty::RowCells::new()?; + let mut rows = render_state.populate_row_iterator(&mut row_iterator)?; + let mut lines = Vec::new(); + while rows.next() { + let mut cells = rows.populate_cells(&mut row_cells)?; + lines.push(ghostty_line_from_cells(&mut cells)?); + } + trim_trailing_blank_rows(&mut lines); + Ok(lines_to_text(lines)) +} + +fn ghostty_detection_text(core: &GhosttyPaneCore) -> Result<String, crate::ghostty::Error> { + let lines = core + .terminal + .rows() + .ok() + .map(|rows| usize::from(rows).max(1)) + .unwrap_or(DEFAULT_DETECTION_ROWS); + ghostty_recent_text(core, lines) +} + +fn ghostty_recent_text( + core: &GhosttyPaneCore, + lines: usize, +) -> Result<String, crate::ghostty::Error> { + let total_rows = core.terminal.total_rows()?; + let cols = core.terminal.cols()?; + let start = total_rows.saturating_sub(lines); + let mut rows = Vec::with_capacity(total_rows.saturating_sub(start)); + for y in start..total_rows { + rows.push(ghostty_screen_row(core, cols, y as u32)?); + } + trim_trailing_blank_rows(&mut rows); + Ok(recent_text_from_rows(&rows, lines)) +} + +fn ghostty_recent_text_unwrapped( + core: &GhosttyPaneCore, + lines: usize, +) -> Result<String, crate::ghostty::Error> { + let total_rows = core.terminal.total_rows()?; + let cols = core.terminal.cols()?; + if total_rows == 0 || cols == 0 { + return Ok(String::new()); + } + let start = total_rows.saturating_sub(lines) as u32; + let end = (total_rows.saturating_sub(1)) as u32; + core.terminal + .read_text_screen((0, start), (cols.saturating_sub(1), end), false) +} + +fn ghostty_extract_selection( + core: &mut GhosttyPaneCore, + selection: &crate::selection::Selection, +) -> Result<String, crate::ghostty::Error> { + let ((start_row, start_col), (end_row, end_col)) = selection.ordered_cells(); + core.terminal + .read_text_screen((start_col, start_row), (end_col, end_row), false) +} + +fn ghostty_screen_row( + core: &GhosttyPaneCore, + cols: u16, + y: u32, +) -> Result<String, crate::ghostty::Error> { + let mut line = String::new(); + for x in 0..cols { + let graphemes = core.terminal.screen_graphemes(x, y)?; + if graphemes.is_empty() { + line.push(' '); + } else { + for codepoint in graphemes { + if let Some(ch) = char::from_u32(codepoint) { + line.push(ch); + } + } + } + } + Ok(line.trim_end().to_string()) +} + +fn ghostty_line_from_cells( + cells: &mut crate::ghostty::RowCellIter<'_>, +) -> Result<String, crate::ghostty::Error> { + let mut line = String::new(); + while cells.next() { + line.push_str(&ghostty_cell_symbol(cells)?); + } + Ok(line.trim_end().to_string()) +} + +fn ghostty_cell_symbol( + cells: &crate::ghostty::RowCellIter<'_>, +) -> Result<String, crate::ghostty::Error> { + let graphemes = cells.graphemes()?; + if graphemes.is_empty() { + return Ok(" ".to_string()); + } + let mut text = String::new(); + for codepoint in graphemes { + if let Some(ch) = char::from_u32(codepoint) { + text.push(ch); + } + } + if text.is_empty() { + text.push(' '); + } + Ok(text) +} + +pub(super) fn ghostty_blank_symbol_for_width(wide: crate::ghostty::CellWide) -> &'static str { + match wide { + crate::ghostty::CellWide::Wide => " ", + crate::ghostty::CellWide::SpacerTail => "", + crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => " ", + } +} + +pub(super) fn ghostty_normalize_buffer_symbol( + symbol: &str, + wide: crate::ghostty::CellWide, +) -> String { + let expected_width = match wide { + crate::ghostty::CellWide::Wide => 2, + crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => 1, + crate::ghostty::CellWide::SpacerTail => 0, + }; + let actual_width = symbol.width(); + if actual_width == expected_width { + return symbol.to_string(); + } + + if wide == crate::ghostty::CellWide::Narrow && actual_width == 2 { + return symbol.to_string(); + } + + ghostty_blank_symbol_for_width(wide).to_string() +} + +fn ghostty_buffer_symbol( + cells: &crate::ghostty::RowCellIter<'_>, + wide: crate::ghostty::CellWide, +) -> Result<String, crate::ghostty::Error> { + let symbol = match wide { + crate::ghostty::CellWide::SpacerTail => String::new(), + crate::ghostty::CellWide::SpacerHead => " ".to_string(), + crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => { + ghostty_cell_symbol(cells)? + } + }; + Ok(ghostty_normalize_buffer_symbol(&symbol, wide)) +} + +fn ghostty_reset_cell( + cell: &mut ratatui::buffer::Cell, + default_fg: Option<Color>, + default_bg: Option<Color>, +) { + cell.reset(); + cell.set_symbol(" "); + if let Some(bg) = default_bg { + cell.set_bg(bg); + } + if let Some(fg) = default_fg { + cell.set_fg(fg); + } +} + +fn ghostty_cell_style( + cells: &crate::ghostty::RowCellIter<'_>, + default_fg: Option<Color>, + default_bg: Option<Color>, +) -> Style { + let style_data = cells.style().unwrap_or_default(); + let mut fg = cells + .fg_color() + .ok() + .flatten() + .map(ghostty_color) + .or(default_fg); + let mut bg = cells + .bg_color() + .ok() + .flatten() + .map(ghostty_color) + .or(default_bg); + if style_data.invisible { + fg = bg.or(default_bg); + } + if style_data.inverse { + std::mem::swap(&mut fg, &mut bg); + } + + let mut style = Style::default(); + if let Some(fg) = fg { + style = style.fg(fg); + } + if let Some(bg) = bg { + style = style.bg(bg); + } + + let mut modifiers = Modifier::empty(); + if style_data.bold { + modifiers |= Modifier::BOLD; + } + if style_data.italic { + modifiers |= Modifier::ITALIC; + } + if style_data.faint { + modifiers |= Modifier::DIM; + } + if style_data.blink { + modifiers |= Modifier::SLOW_BLINK; + } + if style_data.underlined { + modifiers |= Modifier::UNDERLINED; + } + if style_data.strikethrough { + modifiers |= Modifier::CROSSED_OUT; + } + style.add_modifier(modifiers) +} + +fn ghostty_default_bg( + color: crate::ghostty::RgbColor, + host_theme: crate::terminal_theme::TerminalTheme, +) -> Option<Color> { + if host_theme.background == Some(terminal_theme_color(color)) { + None + } else { + Some(ghostty_color(color)) + } +} + +fn terminal_theme_color(color: crate::ghostty::RgbColor) -> crate::terminal_theme::RgbColor { + crate::terminal_theme::RgbColor { + r: color.r, + g: color.g, + b: color.b, + } +} + +fn ghostty_color(color: crate::ghostty::RgbColor) -> Color { + Color::Rgb(color.r, color.g, color.b) +} + +fn lines_to_text(lines: Vec<String>) -> String { + let text = lines.join("\n"); + if text.is_empty() { + text + } else { + format!("{text}\n") + } +} + +pub(super) fn trim_trailing_blank_rows(rows: &mut Vec<String>) { + while rows.last().is_some_and(|row| row.trim().is_empty()) { + rows.pop(); + } +} + +fn recent_text_from_rows(rows: &[String], lines: usize) -> String { + let start = rows.len().saturating_sub(lines); + let text = rows[start..].join("\n"); + if text.is_empty() { + text + } else { + format!("{text}\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{layout::Rect, style::Color}; + use tokio::sync::mpsc; + + fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) { + for i in 0..count { + terminal.write(format!("{i:06}\r\n").as_bytes()); + } + } + + #[test] + fn ghostty_render_can_suppress_cursor_position() { + let (tx, _rx) = mpsc::channel(4); + let mut first_terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + first_terminal.write(b"left"); + let first = GhosttyPaneTerminal::new(first_terminal, tx.clone()).unwrap(); + + let mut second_terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + second_terminal.write(b"r\r\nb"); + let second = GhosttyPaneTerminal::new(second_terminal, tx).unwrap(); + + let backend = ratatui::backend::TestBackend::new(40, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + first.render(frame, Rect::new(0, 0, 20, 5), true); + second.render(frame, Rect::new(20, 0, 20, 5), false); + }) + .unwrap(); + + terminal.backend_mut().assert_cursor_position((4, 0)); + } + + #[test] + fn ghostty_keyboard_protocol_tracks_live_terminal_flags() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[>3u"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + assert_eq!( + pane.keyboard_protocol(), + Some(crate::input::KeyboardProtocol::Kitty { flags: 3 }) + ); + } + + #[test] + fn ghostty_plain_text_chars_still_encode_as_text() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let encoded = pane.encode_terminal_key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::empty(), + ), + crate::input::KeyboardProtocol::Legacy, + ); + + assert_eq!(encoded, b"a"); + } + + #[test] + fn ghostty_char_keys_still_use_herdr_encoding() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[>1u"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let encoded = pane.encode_terminal_key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, + ), + crate::input::KeyboardProtocol::Legacy, + ); + + assert_eq!(encoded, vec![1]); + } + + #[test] + fn ghostty_key_encoding_honors_application_cursor_mode() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal + .mode_set(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS, true) + .unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let encoded = pane.encode_terminal_key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + ), + crate::input::KeyboardProtocol::Legacy, + ); + + assert_eq!(encoded, b"\x1bOA"); + } + + #[test] + fn ghostty_key_encoder_updates_after_terminal_mode_changes() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + let before = pane.encode_terminal_key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + ), + crate::input::KeyboardProtocol::Legacy, + ); + assert_eq!(before, b"\x1b[A"); + + pane.process_pty_bytes(pane_id, 0, b"\x1b[?1h", &tx); + + let after = pane.encode_terminal_key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + ), + crate::input::KeyboardProtocol::Legacy, + ); + assert_eq!(after, b"\x1bOA"); + } + + #[test] + fn ghostty_key_encoder_updates_after_kitty_flag_changes() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, + ); + + let before = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); + let after = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + + assert_ne!(before, after); + assert_eq!(after, b"\x1b[13;6u"); + } + + #[test] + fn ghostty_key_encoders_are_isolated_per_pane() { + let (tx, _rx) = mpsc::channel(4); + let first = GhosttyPaneTerminal::new( + crate::ghostty::Terminal::new(80, 24, 0).unwrap(), + tx.clone(), + ) + .unwrap(); + let second = GhosttyPaneTerminal::new( + crate::ghostty::Terminal::new(80, 24, 0).unwrap(), + tx.clone(), + ) + .unwrap(); + + first.process_pty_bytes(PaneId::from_raw(1), 0, b"\x1b[?1h", &tx); + + let first_encoded = first.encode_terminal_key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + ), + crate::input::KeyboardProtocol::Legacy, + ); + let second_encoded = second.encode_terminal_key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + ), + crate::input::KeyboardProtocol::Legacy, + ); + + assert_eq!(first_encoded, b"\x1bOA"); + assert_eq!(second_encoded, b"\x1b[A"); + } + + #[test] + fn ghostty_mouse_button_encoding_uses_live_terminal_state() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[?1000h\x1b[?1006h"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let encoded = pane.encode_mouse_button( + crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left), + 11, + 9, + crossterm::event::KeyModifiers::empty(), + ); + + assert_eq!(encoded.as_deref(), Some(&b"\x1b[<0;12;10m"[..])); + } + + #[test] + fn ghostty_mouse_drag_encoding_uses_motion_reporting_state() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[?1002h\x1b[?1006h"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let encoded = pane.encode_mouse_button( + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left), + 4, + 6, + crossterm::event::KeyModifiers::SHIFT, + ); + + assert_eq!(encoded.as_deref(), Some(&b"\x1b[<36;5;7M"[..])); + } + + #[test] + fn ghostty_normalize_buffer_symbol_prefers_grapheme_width_when_metadata_disagrees() { + const WIDE_GRAPHEME: &str = "🙂"; + const VS16_GRAPHEME: &str = "⚠️"; + const EMOJI_GRAPHEME: &str = "💳"; + + assert_eq!( + ghostty_normalize_buffer_symbol(WIDE_GRAPHEME, crate::ghostty::CellWide::Wide), + WIDE_GRAPHEME + ); + assert_eq!( + ghostty_normalize_buffer_symbol("a", crate::ghostty::CellWide::Wide), + " " + ); + assert_eq!( + ghostty_normalize_buffer_symbol("⌨️", crate::ghostty::CellWide::Narrow), + "⌨️" + ); + assert_eq!( + ghostty_normalize_buffer_symbol(VS16_GRAPHEME, crate::ghostty::CellWide::Narrow), + VS16_GRAPHEME + ); + assert_eq!( + ghostty_normalize_buffer_symbol(EMOJI_GRAPHEME, crate::ghostty::CellWide::Narrow), + EMOJI_GRAPHEME + ); + assert_eq!( + ghostty_normalize_buffer_symbol(" ", crate::ghostty::CellWide::SpacerTail), + "" + ); + assert_eq!( + ghostty_normalize_buffer_symbol("xx", crate::ghostty::CellWide::SpacerHead), + " " + ); + } + + #[test] + fn pane_scrollback_controls_reach_top_without_ui_interference() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap(); + write_numbered_lines(&mut terminal, 1000); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let before = pane.scroll_metrics().expect("scroll metrics before scroll"); + assert!(before.max_offset_from_bottom > 0); + assert_eq!(before.offset_from_bottom, 0); + + pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom); + + let after = pane.scroll_metrics().expect("scroll metrics after scroll"); + assert_eq!(after.offset_from_bottom, after.max_offset_from_bottom); + assert!(pane.visible_text().contains("000000")); + } + + #[test] + fn detection_text_stays_at_bottom_when_viewport_is_scrolled() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap(); + write_numbered_lines(&mut terminal, 10); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let bottom_snapshot = pane.detection_text(); + assert_eq!(bottom_snapshot, pane.recent_text(3)); + assert!(bottom_snapshot.contains("000009")); + + let before = pane.scroll_metrics().expect("scroll metrics before scroll"); + pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom); + + assert!(pane.visible_text().contains("000000")); + assert_eq!(pane.detection_text(), bottom_snapshot); + } + + #[test] + fn extract_selection_reads_screen_rows_not_current_viewport() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(8, 3, 1024).unwrap(); + write_numbered_lines(&mut terminal, 8); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + pane.set_scroll_offset_from_bottom(3); + let metrics = pane + .scroll_metrics() + .expect("scroll metrics after initial scroll"); + let mut selection = + crate::selection::Selection::anchor(PaneId::from_raw(1), 0, 0, Some(metrics)); + selection.drag(5, 2, Rect::new(0, 0, 8, 3), Some(metrics)); + + pane.scroll_reset(); + + let text = pane + .extract_selection(&selection) + .expect("selection should extract text"); + assert_eq!(text, "000003\n000004\n000005"); + } + + #[test] + fn recent_unwrapped_text_ignores_soft_wraps() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap(); + terminal.write(b"ABCDEFGHIJ"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + assert_eq!(pane.recent_text(3), "ABCDE\nFGHIJ\n"); + assert_eq!(pane.recent_unwrapped_text(3), "ABCDEFGHIJ"); + } + + #[test] + fn synchronized_output_suppresses_intermediate_render_requests_until_batch_ends() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane_terminal = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + let begin = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026h", &tx); + assert!(!begin.request_render); + + let body = pane_terminal.process_pty_bytes(pane_id, 0, b"hello", &tx); + assert!(!body.request_render); + + let end = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026l", &tx); + assert!(end.request_render); + } + + #[test] + fn render_leaves_host_default_background_transparent() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let host_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 0xaa, + g: 0xbb, + b: 0xcc, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 0x11, + g: 0x22, + b: 0x33, + }), + }; + pane.apply_host_terminal_theme(host_theme); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write(b"hi"); + } + + let backend = ratatui::backend::TestBackend::new(20, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "h"); + assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset)); + assert_eq!(buffer[(2, 0)].symbol(), " "); + assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset)); + } + + #[test] + fn render_keeps_explicit_default_background_when_it_differs_from_host() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let host_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 0xaa, + g: 0xbb, + b: 0xcc, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 0x11, + g: 0x22, + b: 0x33, + }), + }; + pane.apply_host_terminal_theme(host_theme); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write(b"\x1b]11;rgb:44/55/66\x1b\\hi"); + } + + let backend = ratatui::backend::TestBackend::new(20, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let expected_bg = Some(Color::Rgb(0x44, 0x55, 0x66)); + assert_eq!(buffer[(0, 0)].symbol(), "h"); + assert_eq!(buffer[(0, 0)].style().bg, expected_bg); + assert_eq!(buffer[(2, 0)].symbol(), " "); + assert_eq!(buffer[(2, 0)].style().bg, expected_bg); + } + + #[test] + fn trim_trailing_blank_rows_drops_empty_viewport_tail() { + let mut rows = vec!["hello".to_string(), "".to_string(), " ".to_string()]; + trim_trailing_blank_rows(&mut rows); + assert_eq!(rows, vec!["hello".to_string()]); + } +} |
