aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
Diffstat (limited to 'src/pane')
-rw-r--r--src/pane/input.rs293
-rw-r--r--src/pane/osc.rs706
-rw-r--r--src/pane/state.rs313
-rw-r--r--src/pane/terminal.rs1307
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()]);
+ }
+}