aboutsummaryrefslogtreecommitdiff
path: root/src/pane.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/pane.rs')
-rw-r--r--src/pane.rs2706
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]