diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-21 16:30:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 16:30:04 +0300 |
| commit | 3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch) | |
| tree | c457a7cf0af271dea1a412474fad5a33c1b60be8 /src/pane | |
| parent | 2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff) | |
feat: add persistent server-client sessions
Make herdr persistent by default.
Launching herdr now starts or reattaches to a background session
server. Clients can detach and reattach while panes and agent
processes keep running. Session mode now supports multi-client attach,
auto-detect startup, and a thin-client/headless-server split.
This also refactors the large app, ui, input, pane, config,
workspace, and persistence modules into smaller focused submodules,
while preserving behavior and colocating tests with the code they
exercise.
Upgrade notes:
- persistence mode is now the default
- in-app quit detaches the current client instead of stopping the server
- use `herdr server stop` to stop the background session
- use `--no-session` for the old single-process behavior
- default socket paths now live under the config directory
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()]); + } +} |
