diff options
Diffstat (limited to 'src/pane.rs')
| -rw-r--r-- | src/pane.rs | 2706 |
1 files changed, 127 insertions, 2579 deletions
diff --git a/src/pane.rs b/src/pane.rs index fb34b6ab..eb37d0c1 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::cell::Cell; use std::io::{BufWriter, Read, Write}; use std::sync::{ @@ -8,19 +7,29 @@ use std::sync::{ use bytes::Bytes; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use ratatui::style::{Color, Modifier, Style}; use ratatui::{layout::Rect, Frame}; use tokio::sync::{mpsc, Notify}; use tracing::{debug, error, info, warn}; -use unicode_width::UnicodeWidthStr; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; use crate::layout::PaneId; -const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200); +mod input; +mod osc; +mod state; +mod terminal; + +use self::{ + state::stabilize_agent_state, + terminal::{GhosttyPaneTerminal, PaneTerminal}, +}; +pub use self::{ + state::{EffectiveStateChange, PaneState}, + terminal::{InputState, ScrollMetrics}, +}; + const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1); -const DEFAULT_DETECTION_ROWS: usize = 24; #[derive(Debug, Clone, Copy)] struct PendingAgentRelease { @@ -68,196 +77,50 @@ async fn publish_state_changed_event( } } -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, - } -} +const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6; -// --------------------------------------------------------------------------- -// PaneState — pure data, constructable without PTYs, testable -// --------------------------------------------------------------------------- - -#[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, +#[derive(Debug, Clone, Copy)] +struct AgentDetectionPresence { + current_agent: Option<Agent>, + consecutive_misses: u8, } -impl PaneState { - pub fn new() -> Self { +impl AgentDetectionPresence { + fn from_agent(current_agent: Option<Agent>) -> Self { Self { - detected_agent: None, - fallback_state: AgentState::Unknown, - hook_authority: None, - state: AgentState::Unknown, - seen: true, + current_agent, + consecutive_misses: 0, } } - 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) + fn current_agent(&self) -> Option<Agent> { + self.current_agent } - 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); + fn observe_process_probe(&mut self, identified_agent: Option<Agent>) -> bool { + match identified_agent { + Some(agent) => { + self.consecutive_misses = 0; + if Some(agent) == self.current_agent { + return false; + } + self.current_agent = Some(agent); + true + } + None => { + if self.current_agent.is_none() { + self.consecutive_misses = 0; + return false; + } + self.consecutive_misses = self.consecutive_misses.saturating_add(1); + if self.consecutive_misses < AGENT_MISS_CONFIRMATION_ATTEMPTS { + return false; + } + self.current_agent = None; + self.consecutive_misses = 0; + true + } } - 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, - }) } } @@ -281,1461 +144,6 @@ pub struct PaneRuntime { } #[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)] -struct ProcessBytesResult { - request_render: bool, - clipboard_writes: Vec<Vec<u8>>, -} - -struct GhosttyPaneTerminal { - core: Mutex<GhosttyPaneCore>, - key_encoder: Mutex<crate::ghostty::KeyEncoder>, -} - -struct GhosttyPaneCore { - terminal: crate::ghostty::Terminal, - render_state: crate::ghostty::RenderState, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - transient_default_color_owner_pgid: Option<u32>, - default_color_tracker: DefaultColorOscTracker, - osc52_forwarder: Osc52Forwarder, -} - -#[derive(Debug, Default)] -struct DefaultColorOscTracker { - state: DefaultColorOscTrackerState, - body: Vec<u8>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum DefaultColorOscTrackerState { - #[default] - Ground, - Escape, - OscBody, - OscEscape, -} - -impl DefaultColorOscTracker { - 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)] -struct Osc52Forwarder { - state: Osc52ForwarderState, - body: Vec<u8>, - pending: Vec<Vec<u8>>, -} - -impl Osc52Forwarder { - 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(); - } - - 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) -} - -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") - }) - }) -} - -fn contains_scrollback_clear_sequence(bytes: &[u8]) -> bool { - bytes.windows(4).any(|window| window == b"\x1b[3J") - || bytes.windows(5).any(|window| window == b"\x1b[?3J") -} - -fn strip_scrollback_clear_sequences<'a>(bytes: &'a [u8]) -> Cow<'a, [u8]> { - if !contains_scrollback_clear_sequence(bytes) { - return Cow::Borrowed(bytes); - } - - let mut filtered = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - let remaining = &bytes[index..]; - if remaining.starts_with(b"\x1b[3J") { - index += 4; - continue; - } - if remaining.starts_with(b"\x1b[?3J") { - index += 5; - continue; - } - filtered.push(bytes[index]); - index += 1; - } - - Cow::Owned(filtered) -} - -fn maybe_filter_primary_screen_scrollback_clear<'a>( - bytes: &'a [u8], - alternate_screen: bool, - foreground_job: Option<&crate::platform::ForegroundJob>, -) -> Cow<'a, [u8]> { - // Droid redraws its primary-screen TUI with CSI 3 J, which erases pane - // scrollback inside herdr. Keep the hack scoped to Droid on the primary - // screen so normal terminal clear-history behavior still works elsewhere. - if alternate_screen - || !contains_scrollback_clear_sequence(bytes) - || !foreground_job.is_some_and(foreground_job_uses_droid_scrollback_compat) - { - return Cow::Borrowed(bytes); - } - - strip_scrollback_clear_sequences(bytes) -} - -#[cfg(target_os = "macos")] -fn should_restore_host_terminal_theme( - owner_pgid: u32, - 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"))] -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) -} - -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()); - } -} - -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 -} - -struct PaneTerminal { - ghostty: GhosttyPaneTerminal, -} - -impl PaneTerminal { - 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) - } - - fn resize(&self, rows: u16, cols: u16) { - self.ghostty.resize(rows, cols); - } - - fn scroll_up(&self, lines: usize) { - self.ghostty.scroll_up(lines); - } - - fn scroll_down(&self, lines: usize) { - self.ghostty.scroll_down(lines); - } - - fn scroll_reset(&self) { - self.ghostty.scroll_reset(); - } - - fn set_scroll_offset_from_bottom(&self, lines: usize) { - self.ghostty.set_scroll_offset_from_bottom(lines); - } - - fn scroll_metrics(&self) -> Option<ScrollMetrics> { - self.ghostty.scroll_metrics() - } - - fn input_state(&self) -> Option<InputState> { - self.ghostty.input_state() - } - - fn visible_text(&self) -> String { - self.ghostty.visible_text() - } - - fn detection_text(&self) -> String { - self.ghostty.detection_text() - } - - fn recent_text(&self, lines: usize) -> String { - self.ghostty.recent_text(lines) - } - - fn recent_unwrapped_text(&self, lines: usize) -> String { - self.ghostty.recent_unwrapped_text(lines) - } - - fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> { - self.ghostty.extract_selection(selection) - } - - fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { - self.ghostty.render(frame, area, show_cursor); - } - - fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { - self.ghostty.apply_host_terminal_theme(theme); - } - - fn has_transient_default_color_override(&self) -> bool { - self.ghostty.has_transient_default_color_override() - } - - 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) - } - - fn keyboard_protocol( - &self, - fallback: crate::input::KeyboardProtocol, - ) -> crate::input::KeyboardProtocol { - self.ghostty.keyboard_protocol().unwrap_or(fallback) - } - - fn encode_terminal_key( - &self, - key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, - ) -> Vec<u8> { - self.ghostty.encode_terminal_key(key, protocol) - } - - 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) - } - - 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 { - 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), - }) - } - - 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); - } - } - - fn has_transient_default_color_override(&self) -> bool { - self.core - .lock() - .map(|core| core.transient_default_color_owner_pgid.is_some()) - .unwrap_or(false) - } - - 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(), - ) - } - - 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, - } - } - - fn resize(&self, rows: u16, cols: u16) { - if let Ok(mut core) = self.core.lock() { - let _ = core.terminal.resize(cols, rows); - } - } - - fn scroll_up(&self, lines: usize) { - if let Ok(mut core) = self.core.lock() { - core.terminal.scroll_viewport_delta(-(lines as isize)); - } - } - - fn scroll_down(&self, lines: usize) { - if let Ok(mut core) = self.core.lock() { - core.terminal.scroll_viewport_delta(lines as isize); - } - } - - fn scroll_reset(&self) { - if let Ok(mut core) = self.core.lock() { - core.terminal.scroll_viewport_bottom(); - } - } - - 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)); - } - } - } - - 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, - }) - } - - 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, - )) - } - - 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, - }) - } - - 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), - } - } - - 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() - } - - 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() - } - - fn visible_text(&self) -> String { - self.core - .lock() - .ok() - .and_then(|mut core| ghostty_visible_text(&mut core).ok()) - .unwrap_or_default() - } - - fn detection_text(&self) -> String { - self.core - .lock() - .ok() - .and_then(|core| ghostty_detection_text(&core).ok()) - .unwrap_or_default() - } - - fn recent_text(&self, lines: usize) -> String { - self.core - .lock() - .ok() - .and_then(|core| ghostty_recent_text(&core, lines).ok()) - .unwrap_or_default() - } - - 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() - } - - 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()) - } - - 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_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) -} - -fn ghostty_prefers_herdr_text_encoding(key: crate::input::TerminalKey) -> bool { - matches!(key.code, crossterm::event::KeyCode::Char(_)) -} - -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 -} - -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) -} - -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) -} - -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, - }) -} - -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) -} - -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 => " ", - } -} - -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(); - } - 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") - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WheelRouting { HostScroll, MouseReport, @@ -1751,22 +159,6 @@ impl Drop for PaneRuntime { } } -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") - } -} - fn wait_for_processes_to_exit(pids: &[u32], timeout: std::time::Duration) -> bool { let deadline = std::time::Instant::now() + timeout; loop { @@ -1939,9 +331,7 @@ impl PaneRuntime { .map_err(|e| std::io::Error::other(e.to_string()))?; let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?; pane_terminal.apply_host_terminal_theme(host_terminal_theme); - let terminal = Arc::new(PaneTerminal { - ghostty: pane_terminal, - }); + let terminal = Arc::new(PaneTerminal::new(pane_terminal)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(0)); let reader = pair @@ -2050,7 +440,7 @@ impl PaneRuntime { let pending_release_for_task = pending_release.clone(); let handle = tokio::spawn(async move { - let mut agent: Option<Agent> = None; + let mut agent_presence = AgentDetectionPresence::from_agent(None); let mut state = AgentState::Unknown; let mut last_process_check = Instant::now(); let mut last_claude_working_at = None; @@ -2063,7 +453,7 @@ impl PaneRuntime { || terminal.has_transient_default_color_override() { TICK_PENDING_RELEASE - } else if agent.is_none() { + } else if agent_presence.current_agent().is_none() { TICK_UNIDENTIFIED } else { TICK_IDENTIFIED @@ -2071,7 +461,7 @@ impl PaneRuntime { tokio::select! { _ = tokio::time::sleep(tick) => {} _ = detect_reset.notified() => { - agent = None; + agent_presence = AgentDetectionPresence::from_agent(None); state = AgentState::Unknown; last_claude_working_at = None; } @@ -2080,48 +470,60 @@ impl PaneRuntime { let now = Instant::now(); let suppressed_agent = active_pending_release(&pending_release_for_task, now); let should_check_process = suppressed_agent.is_some() - || agent.is_none() + || agent_presence.current_agent().is_none() || now.duration_since(last_process_check) >= PROCESS_RECHECK; let mut agent_changed = false; + let mut agent = agent_presence.current_agent(); if should_check_process { last_process_check = now; let pid = child_pid.load(Ordering::Acquire); if pid > 0 { + let mut process_name = None; + let mut process_group_id = None; + let mut new_agent = None; + if let Some(job) = detect::foreground_job(pid) { + process_group_id = Some(job.process_group_id); let identified = detect::identify_agent_in_job(&job); - let mut new_agent = identified.as_ref().map(|(agent, _)| *agent); - - if let Some(suppressed_agent) = suppressed_agent { - if new_agent == Some(suppressed_agent) { - new_agent = None; - } else if let Ok(mut pending_release) = - pending_release_for_task.lock() - { - *pending_release = None; - } + process_name = identified + .as_ref() + .map(|(_, process_name)| process_name.clone()); + new_agent = identified.as_ref().map(|(agent, _)| *agent); + } + + if let Some(suppressed_agent) = suppressed_agent { + if new_agent == Some(suppressed_agent) { + new_agent = None; + } else if let Ok(mut pending_release) = + pending_release_for_task.lock() + { + *pending_release = None; } + } - if new_agent != agent { - if let Some((_, process_name)) = identified { - info!( - pane = pane_id.raw(), - ?new_agent, - process = %process_name, - pgid = job.process_group_id, - "agent changed" - ); - } else { - info!( - pane = pane_id.raw(), - ?new_agent, - pgid = job.process_group_id, - "agent changed" - ); - } - agent = new_agent; - agent_changed = true; + let previous_agent = agent_presence.current_agent(); + if agent_presence.observe_process_probe(new_agent) { + agent = agent_presence.current_agent(); + if let Some(process_name) = process_name { + info!( + pane = pane_id.raw(), + previous_agent = ?previous_agent, + ?agent, + process = %process_name, + pgid = ?process_group_id, + "agent changed" + ); + } else { + info!( + pane = pane_id.raw(), + previous_agent = ?previous_agent, + ?agent, + pgid = ?process_group_id, + "agent changed" + ); } + agent_changed = true; } } } @@ -2405,16 +807,12 @@ impl PaneRuntime { #[cfg(test)] impl PaneRuntime { - pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self { - Self::test_with_scrollback_bytes(cols, rows, 0, bytes) + pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<Bytes>) { + Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[]) } - pub(crate) fn test_with_screen_bytes_and_receiver( - cols: u16, - rows: u16, - bytes: &[u8], - ) -> (Self, mpsc::Receiver<Bytes>) { - Self::test_with_scrollback_bytes_and_receiver(cols, rows, 0, bytes) + pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self { + Self::test_with_scrollback_bytes(cols, rows, 0, bytes) } pub(crate) fn test_with_scrollback_bytes( @@ -2423,10 +821,10 @@ impl PaneRuntime { scrollback_limit_bytes: usize, bytes: &[u8], ) -> Self { - Self::test_with_scrollback_bytes_and_receiver(cols, rows, scrollback_limit_bytes, bytes).0 + Self::test_with_channel_and_scrollback_bytes(cols, rows, scrollback_limit_bytes, bytes).0 } - fn test_with_scrollback_bytes_and_receiver( + fn test_with_channel_and_scrollback_bytes( cols: u16, rows: u16, scrollback_limit_bytes: usize, @@ -2440,9 +838,9 @@ impl PaneRuntime { ( Self { - terminal: Arc::new(PaneTerminal { - ghostty: GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), - }), + terminal: Arc::new(PaneTerminal::new( + GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), + )), sender: tx, resize_tx, current_size: Cell::new((rows, cols)), @@ -2461,761 +859,6 @@ impl PaneRuntime { mod tests { use super::*; - 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()); - } - } - - fn pane_default_theme(pane: &GhosttyPaneTerminal) -> crate::terminal_theme::TerminalTheme { - let mut core = pane.core.lock().unwrap(); - let 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 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_uses_ratatui_width_contract() { - assert_eq!( - ghostty_normalize_buffer_symbol("🙂", crate::ghostty::CellWide::Wide), - "🙂" - ); - 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(" ", 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 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 = 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); - } - - #[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); - } - #[tokio::test] async fn focus_events_are_forwarded_when_enabled() { let (tx, mut rx) = mpsc::channel(4); @@ -3225,9 +868,9 @@ mod tests { .mode_set(crate::ghostty::MODE_FOCUS_EVENT, true) .unwrap(); let runtime = PaneRuntime { - terminal: Arc::new(PaneTerminal { - ghostty: GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), - }), + terminal: Arc::new(PaneTerminal::new( + GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), + )), sender: tx, resize_tx, current_size: Cell::new((80, 24)), @@ -3248,9 +891,9 @@ mod tests { let (resize_tx, _resize_rx) = mpsc::channel(1); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let runtime = PaneRuntime { - terminal: Arc::new(PaneTerminal { - ghostty: GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), - }), + terminal: Arc::new(PaneTerminal::new( + GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), + )), sender: tx, resize_tx, current_size: Cell::new((80, 24)), @@ -3270,126 +913,31 @@ mod tests { } #[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()]); - } - - #[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, - ); + fn transient_process_miss_keeps_current_agent_detected() { + let mut presence = AgentDetectionPresence::from_agent(Some(Agent::Pi)); - pane.set_detected_state(None, AgentState::Unknown); + let changed = presence.observe_process_probe(None); - 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); + assert!(!changed, "one miss should not clear the detected agent"); + assert_eq!(presence.current_agent(), Some(Agent::Pi)); } #[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); + fn agent_only_clears_after_confirmation_misses() { + let mut presence = AgentDetectionPresence::from_agent(Some(Agent::Pi)); - pane.release_agent("herdr:pi", "pi"); + for attempt in 1..AGENT_MISS_CONFIRMATION_ATTEMPTS { + let changed = presence.observe_process_probe(None); + assert!( + !changed, + "miss {attempt} should stay in the confirmation window" + ); + assert_eq!(presence.current_agent(), Some(Agent::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); + let changed = presence.observe_process_probe(None); + assert!(changed, "last confirmation miss should clear the agent"); + assert_eq!(presence.current_agent(), None); } #[tokio::test] |
