Files
herdrdev__herdr/src/pane/terminal.rs
2026-07-17 03:26:33 +03:00

5564 lines
195 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::borrow::Cow;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use bytes::Bytes;
use ratatui::style::{Color, Modifier, Style};
use ratatui::{layout::Rect, Frame};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tracing::{debug, error};
use unicode_width::UnicodeWidthStr;
use crate::layout::PaneId;
use crate::protocol::CellData;
#[cfg(windows)]
mod windows_recent_fallback;
use super::cursor::{CursorPositionSettleState, DecscusrTracker, CURSOR_POSITION_SETTLE};
use super::{
input::{
ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal,
ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_motion_kind,
ghostty_mouse_event_from_wheel_kind, ghostty_prefers_herdr_text_encoding,
},
kitty_keyboard::KittyKeyboardTracker,
osc::{
contains_scrollback_clear_sequence, current_transient_default_color_owner,
maybe_filter_primary_screen_scrollback_clear, parse_reported_cwd,
restore_host_terminal_theme_if_needed, write_host_terminal_theme_selective,
AgentOscStateTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker,
DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker,
},
xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
};
const DEFAULT_DETECTION_ROWS: usize = 24;
const KITTY_GRAPHICS_REDRAW_SETTLE: Duration = Duration::from_millis(20);
const CURSOR_POSITION_SETTLE_ENABLED: bool = cfg!(windows);
const MODE_MOUSE_X10: u16 = 9;
const MODE_MOUSE_PRESS_RELEASE: u16 = 1000;
const MODE_MOUSE_BUTTON_MOTION: u16 = 1002;
const MODE_MOUSE_ANY_MOTION: u16 = 1003;
#[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, PartialOrd, Ord)]
pub(crate) struct TerminalTextPoint {
pub row: u32,
pub col: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TerminalTextMatch {
pub start: TerminalTextPoint,
pub end: TerminalTextPoint,
pub source_fingerprint: u64,
pub scan_cols: u16,
pub scan_screen: crate::ghostty::ActiveScreen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TerminalWordMotion {
NextStart,
PreviousStart,
NextEnd,
}
const COPY_MODE_WORD_SEPARATORS: &str = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalCursorState {
pub x: u16,
pub y: u16,
pub visible: bool,
/// DECSCUSR parameter (0–6). 0 means terminal default.
pub shape: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TerminalDirtyPatch {
pub rows: Vec<(u16, Vec<CellData>)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TerminalDirtyPatchOutcome {
Clean,
Patch(TerminalDirtyPatch),
Fallback,
}
fn decscusr_cursor_shape(style: crate::ghostty::CursorVisualStyle, blinking: bool) -> u8 {
match (style, blinking) {
(crate::ghostty::CursorVisualStyle::Block, true)
| (crate::ghostty::CursorVisualStyle::BlockHollow, true) => 1,
(crate::ghostty::CursorVisualStyle::Block, false)
| (crate::ghostty::CursorVisualStyle::BlockHollow, false) => 2,
(crate::ghostty::CursorVisualStyle::Underline, true) => 3,
(crate::ghostty::CursorVisualStyle::Underline, false) => 4,
(crate::ghostty::CursorVisualStyle::Bar, true) => 5,
(crate::ghostty::CursorVisualStyle::Bar, false) => 6,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
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,
#[serde(default)]
pub modify_other_keys: bool,
}
impl InputState {
pub fn mouse_reporting_enabled(self) -> bool {
self.mouse_protocol_mode.reporting_enabled()
}
pub fn plain_page_keys_use_host_scrollback(self) -> bool {
!self.alternate_screen && !self.mouse_reporting_enabled() && !self.application_cursor
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProcessBytesResult {
pub request_render: bool,
pub render_delay: Option<Duration>,
pub clipboard_writes: Vec<Vec<u8>>,
pub reported_cwd: Option<std::path::PathBuf>,
pub terminal_responses: Vec<Bytes>,
}
pub(crate) struct GhosttyPaneTerminal {
pub core: Mutex<GhosttyPaneCore>,
key_encoder: Mutex<crate::ghostty::KeyEncoder>,
pending_pty_responses: Arc<Mutex<Vec<Bytes>>>,
}
pub(crate) struct GhosttyPaneCore {
pub terminal: crate::ghostty::Terminal,
#[cfg(windows)]
recent_fallback: windows_recent_fallback::Cache,
pub render_state: crate::ghostty::RenderState,
pub kitty_keyboard: KittyKeyboardTracker,
pub initial_default_foreground: Option<crate::ghostty::RgbColor>,
pub initial_default_background: Option<crate::ghostty::RgbColor>,
pub host_terminal_theme: crate::terminal_theme::TerminalTheme,
pub transient_default_color_owner_pgid: Option<u32>,
pub default_color_tracker: DefaultColorOscTracker,
pub default_color_event_tracker: DefaultColorEventTracker,
pub child_default_foreground_changed: bool,
pub child_default_background_changed: bool,
pub osc52_forwarder: Osc52Forwarder,
pub osc_debug_tracker: OscDebugTracker,
pub agent_osc_state: AgentOscStateTracker,
pub xtgettcap_query_tracker: XtgettcapQueryTracker,
decscusr_tracker: DecscusrTracker,
cursor_settle_state: CursorPositionSettleState,
windows_powershell_prompt_cwd_reporting: bool,
}
pub(crate) struct PaneTerminal {
pub(crate) ghostty: GhosttyPaneTerminal,
}
impl PaneTerminal {
pub(crate) fn new(ghostty: GhosttyPaneTerminal) -> Self {
Self { ghostty }
}
pub fn process_pty_bytes(
&self,
pane_id: PaneId,
shell_pid: u32,
bytes: &[u8],
response_writer: &mpsc::Sender<Bytes>,
) -> ProcessBytesResult {
self.ghostty
.process_pty_bytes(pane_id, shell_pid, bytes, response_writer)
}
pub fn resize(
&self,
rows: u16,
cols: u16,
cell_width_px: u32,
cell_height_px: u32,
) -> Vec<Bytes> {
self.ghostty
.resize(rows, cols, cell_width_px, cell_height_px)
}
pub fn scroll_up(&self, lines: usize) {
self.ghostty.scroll_up(lines);
}
pub fn scroll_down(&self, lines: usize) {
self.ghostty.scroll_down(lines);
}
pub fn scroll_reset(&self) {
self.ghostty.scroll_reset();
}
pub fn set_scroll_offset_from_bottom(&self, lines: usize) {
self.ghostty.set_scroll_offset_from_bottom(lines);
}
pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
self.ghostty.scroll_metrics()
}
pub(crate) fn search_text_matches(
&self,
query: &str,
case_sensitive: bool,
) -> Vec<TerminalTextMatch> {
let Some((buffer, active_screen)) = self.retained_text_buffer() else {
return Vec::new();
};
buffer.search(query, case_sensitive, active_screen)
}
pub(crate) fn text_match_is_current(&self, text_match: TerminalTextMatch) -> bool {
self.text_matches_are_current(&[text_match])
.first()
.copied()
.unwrap_or(false)
}
pub(crate) fn text_matches_are_current(&self, text_matches: &[TerminalTextMatch]) -> Vec<bool> {
if text_matches.is_empty() {
return Vec::new();
}
let Ok(core) = self.ghostty.core.lock() else {
return vec![false; text_matches.len()];
};
let Some(cols) = core.terminal.cols().ok() else {
return vec![false; text_matches.len()];
};
let Some(active_screen) = core.terminal.active_screen().ok() else {
return vec![false; text_matches.len()];
};
let row_range = text_matches
.iter()
.filter(|text_match| {
text_match.scan_cols == cols && text_match.scan_screen == active_screen
})
.fold(None::<(u32, u32)>, |range, text_match| {
Some(match range {
Some((start_row, end_row)) => (
start_row.min(text_match.start.row),
end_row.max(text_match.end.row),
),
None => (text_match.start.row, text_match.end.row),
})
});
let Some((start_row, end_row)) = row_range else {
return vec![false; text_matches.len()];
};
let Ok(rows) = core
.terminal
.screen_text_rows_range(start_row as usize, end_row.saturating_add(1) as usize)
else {
return vec![false; text_matches.len()];
};
let buffer = RetainedTextBuffer::new_search(cols, rows, start_row);
text_matches
.iter()
.map(|text_match| {
text_match.scan_cols == cols
&& text_match.scan_screen == active_screen
&& buffer.contains_match(*text_match)
})
.collect()
}
pub(crate) fn word_motion_target(
&self,
row: u32,
col: u16,
motion: TerminalWordMotion,
) -> Option<TerminalTextPoint> {
let core = self.ghostty.core.lock().ok()?;
let cols = core.terminal.cols().ok()?;
let total_rows = core.terminal.total_rows().ok()?;
let row = usize::try_from(row).ok()?;
if row >= total_rows {
return None;
}
let mut window_rows = 64usize;
loop {
let (start_row, end_row) = match motion {
TerminalWordMotion::PreviousStart => {
(row.saturating_sub(window_rows.saturating_sub(1)), row + 1)
}
TerminalWordMotion::NextStart | TerminalWordMotion::NextEnd => {
(row, row.saturating_add(window_rows).min(total_rows))
}
};
let rows = core
.terminal
.screen_text_rows_range(start_row, end_row)
.ok()?;
let starts_in_continuation = rows
.first()
.is_some_and(|row| row.wrap_continuation && start_row > 0);
let ends_in_continuation = rows
.last()
.is_some_and(|row| row.soft_wrapped && end_row < total_rows);
let buffer = RetainedTextBuffer::new_words(cols, rows, u32::try_from(start_row).ok()?);
let target = buffer.word_motion(u32::try_from(row).ok()?, col, motion);
let needs_more_history = motion == TerminalWordMotion::PreviousStart
&& target
.is_some_and(|target| starts_in_continuation && target.row == start_row as u32);
let needs_more_future = motion == TerminalWordMotion::NextEnd
&& ends_in_continuation
&& target.is_some_and(|target| buffer.point_is_final_atom(target));
if target.is_some() && !needs_more_history && !needs_more_future {
return target;
}
let reached_edge = match motion {
TerminalWordMotion::PreviousStart => start_row == 0,
TerminalWordMotion::NextStart | TerminalWordMotion::NextEnd => {
end_row == total_rows
}
};
if reached_edge {
return target;
}
window_rows = window_rows.saturating_mul(2).min(total_rows);
}
}
fn retained_text_buffer(&self) -> Option<(RetainedTextBuffer, crate::ghostty::ActiveScreen)> {
let (cols, rows, active_screen) = {
let core = self.ghostty.core.lock().ok()?;
let cols = core.terminal.cols().ok()?;
let rows = core.terminal.screen_text_rows().ok()?;
let active_screen = core.terminal.active_screen().ok()?;
(cols, rows, active_screen)
};
Some((RetainedTextBuffer::new_search(cols, rows, 0), active_screen))
}
pub fn input_state(&self) -> Option<InputState> {
self.ghostty.input_state()
}
pub fn wheel_routing(&self) -> Option<crate::pane::WheelRouting> {
self.ghostty.wheel_routing()
}
pub fn cursor_state(&self) -> Option<TerminalCursorState> {
self.ghostty.cursor_state()
}
pub fn synchronized_output_active(&self) -> bool {
self.ghostty.synchronized_output_active()
}
pub fn visible_text(&self) -> String {
self.ghostty.visible_text()
}
pub fn visible_ansi(&self) -> String {
self.ghostty.visible_ansi()
}
pub fn detection_text(&self) -> String {
self.ghostty.detection_text()
}
pub fn recent_text(&self, lines: usize) -> String {
self.ghostty.recent_text(lines)
}
pub fn recent_ansi(&self, lines: usize) -> String {
self.ghostty.recent_ansi(lines)
}
pub fn recent_unwrapped_text(&self, lines: usize) -> String {
self.ghostty.recent_unwrapped_text(lines)
}
pub fn recent_unwrapped_ansi(&self, lines: usize) -> String {
self.ghostty.recent_unwrapped_ansi(lines)
}
pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
self.ghostty.extract_selection(selection)
}
pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) {
self.ghostty.render(frame, area, show_cursor);
}
pub fn collect_dirty_patch(
&self,
area_width: u16,
area_height: u16,
) -> TerminalDirtyPatchOutcome {
self.ghostty.collect_dirty_patch(area_width, area_height)
}
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
self.ghostty.visible_hyperlinks(area)
}
pub fn kitty_image_placements_with_data_filter<F>(
&self,
needs_data: F,
) -> Vec<crate::ghostty::KittyImagePlacement>
where
F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool,
{
self.ghostty
.kitty_image_placements_with_data_filter(needs_data)
}
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
self.ghostty.apply_host_terminal_theme(theme);
}
pub fn has_transient_default_color_override(&self) -> bool {
self.ghostty.has_transient_default_color_override()
}
pub fn maybe_restore_host_terminal_theme(&self, pane_id: PaneId, shell_pid: u32) -> bool {
self.ghostty
.maybe_restore_host_terminal_theme(pane_id, shell_pid)
}
pub fn terminal_title(&self) -> Option<String> {
self.ghostty.terminal_title()
}
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
pub fn agent_osc_title(&self) -> String {
self.ghostty.agent_osc_title()
}
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
pub fn agent_osc_progress(&self) -> String {
self.ghostty.agent_osc_progress()
}
/// Clears retained OSC title/progress evidence on foreground agent change.
pub fn clear_agent_osc_state(&self) {
self.ghostty.clear_agent_osc_state()
}
pub fn keyboard_protocol(
&self,
fallback: crate::input::KeyboardProtocol,
) -> crate::input::KeyboardProtocol {
self.ghostty.keyboard_protocol().unwrap_or(fallback)
}
#[cfg(unix)]
pub fn kitty_keyboard_state_ansi(&self) -> Option<String> {
self.ghostty
.kitty_keyboard_state_ansi()
.filter(|ansi| !ansi.is_empty())
}
pub fn encode_terminal_key(
&self,
key: crate::input::TerminalKey,
protocol: crate::input::KeyboardProtocol,
) -> Vec<u8> {
self.ghostty.encode_terminal_key(key, protocol)
}
pub fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.ghostty
.encode_mouse_button(kind, column, row, modifiers)
}
pub fn encode_mouse_motion(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.ghostty
.encode_mouse_motion(kind, column, row, modifiers)
}
pub fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.ghostty
.encode_mouse_wheel(kind, column, row, modifiers)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TextClass {
Whitespace,
Separator,
Word,
}
#[derive(Debug)]
struct TextAtom {
point: Option<TerminalTextPoint>,
end_col: u16,
class: TextClass,
}
#[derive(Debug)]
struct TextSpan {
byte_start: usize,
byte_end: usize,
start: TerminalTextPoint,
end: TerminalTextPoint,
}
#[derive(Debug, Default)]
struct LogicalTextLine {
text: String,
spans: Vec<TextSpan>,
}
#[derive(Debug)]
struct RetainedTextBuffer {
cols: u16,
lines: Vec<LogicalTextLine>,
atoms: Vec<TextAtom>,
}
impl RetainedTextBuffer {
#[cfg(test)]
fn new(cols: u16, rows: Vec<crate::ghostty::ScreenTextRow>) -> Self {
Self::build(cols, rows, 0, true, true)
}
fn new_search(cols: u16, rows: Vec<crate::ghostty::ScreenTextRow>, row_offset: u32) -> Self {
Self::build(cols, rows, row_offset, true, false)
}
fn new_words(cols: u16, rows: Vec<crate::ghostty::ScreenTextRow>, row_offset: u32) -> Self {
Self::build(cols, rows, row_offset, false, true)
}
fn build(
cols: u16,
rows: Vec<crate::ghostty::ScreenTextRow>,
row_offset: u32,
build_lines: bool,
build_atoms: bool,
) -> Self {
let mut lines = Vec::new();
let mut line = LogicalTextLine::default();
let mut atoms: Vec<TextAtom> = Vec::new();
for (row_idx, row) in rows.into_iter().enumerate() {
let Some(row_idx) = u32::try_from(row_idx).ok() else {
break;
};
let row_idx = row_offset.saturating_add(row_idx);
for (col, cell) in row.cells.into_iter().enumerate() {
let Ok(col) = u16::try_from(col) else {
break;
};
if cell.wide == crate::ghostty::CellWide::SpacerTail {
continue;
}
if cell.wide == crate::ghostty::CellWide::SpacerHead {
if build_atoms {
atoms.push(TextAtom {
point: Some(TerminalTextPoint { row: row_idx, col }),
end_col: col,
class: atoms
.last()
.map_or(TextClass::Whitespace, |atom| atom.class),
});
}
continue;
}
let width = if cell.wide == crate::ghostty::CellWide::Wide {
2
} else {
1
};
let text = terminal_cell_text(&cell.graphemes);
let start = TerminalTextPoint { row: row_idx, col };
let end = TerminalTextPoint {
row: row_idx,
col: col.saturating_add(width - 1),
};
if build_lines {
let byte_start = line.text.len();
line.text.push_str(&text);
let byte_end = line.text.len();
line.spans.push(TextSpan {
byte_start,
byte_end,
start,
end,
});
}
if build_atoms {
atoms.push(TextAtom {
point: Some(start),
end_col: end.col,
class: text_class(&text),
});
}
}
if row.soft_wrapped {
continue;
}
if build_lines {
let trimmed_len = line.text.trim_end().len();
while line
.spans
.last()
.is_some_and(|span| span.byte_start >= trimmed_len)
{
line.spans.pop();
}
line.text.truncate(trimmed_len);
lines.push(std::mem::take(&mut line));
}
if build_atoms {
atoms.push(TextAtom {
point: None,
end_col: 0,
class: TextClass::Whitespace,
});
}
}
if build_lines && (!line.text.is_empty() || !line.spans.is_empty()) {
lines.push(line);
}
Self { cols, lines, atoms }
}
fn search(
&self,
query: &str,
case_sensitive: bool,
active_screen: crate::ghostty::ActiveScreen,
) -> Vec<TerminalTextMatch> {
if query.is_empty() {
return Vec::new();
}
let Ok(regex) = regex::RegexBuilder::new(&regex::escape(query))
.case_insensitive(!case_sensitive)
.build()
else {
return Vec::new();
};
let mut matches = Vec::new();
for line in &self.lines {
for found in regex.find_iter(&line.text) {
let Ok(start_index) = line
.spans
.binary_search_by_key(&found.start(), |span| span.byte_start)
else {
continue;
};
let Ok(end_index) = line
.spans
.binary_search_by_key(&found.end(), |span| span.byte_end)
else {
continue;
};
let start_span = &line.spans[start_index];
let end_span = &line.spans[end_index];
matches.push(TerminalTextMatch {
start: start_span.start,
end: end_span.end,
source_fingerprint: text_fingerprint(found.as_str()),
scan_cols: self.cols,
scan_screen: active_screen,
});
}
}
matches
}
fn contains_match(&self, text_match: TerminalTextMatch) -> bool {
self.lines.iter().any(|line| {
let Ok(start_index) = line
.spans
.binary_search_by_key(&text_match.start, |span| span.start)
else {
return false;
};
let Ok(end_index) = line
.spans
.binary_search_by_key(&text_match.end, |span| span.end)
else {
return false;
};
let start_span = &line.spans[start_index];
let end_span = &line.spans[end_index];
start_span.byte_start <= end_span.byte_end
&& text_fingerprint(&line.text[start_span.byte_start..end_span.byte_end])
== text_match.source_fingerprint
})
}
fn word_motion(
&self,
row: u32,
col: u16,
motion: TerminalWordMotion,
) -> Option<TerminalTextPoint> {
let current = self.atoms.iter().position(|atom| {
atom.point
.is_some_and(|point| point.row == row && col >= point.col && col <= atom.end_col)
})?;
match motion {
TerminalWordMotion::NextStart => self.next_word_start(current),
TerminalWordMotion::PreviousStart => self.previous_word_start(current),
TerminalWordMotion::NextEnd => self.next_word_end(current),
}
}
fn next_word_start(&self, current: usize) -> Option<TerminalTextPoint> {
let current_class = self.atoms.get(current)?.class;
let mut next = current.saturating_add(1);
if current_class != TextClass::Whitespace {
while self
.atoms
.get(next)
.is_some_and(|atom| atom.class == current_class)
{
next += 1;
}
}
while self
.atoms
.get(next)
.is_some_and(|atom| atom.class == TextClass::Whitespace)
{
next += 1;
}
self.next_point(next)
}
fn previous_word_start(&self, current: usize) -> Option<TerminalTextPoint> {
let mut previous = current.checked_sub(1)?;
while self
.atoms
.get(previous)
.is_some_and(|atom| atom.class == TextClass::Whitespace)
{
previous = previous.checked_sub(1)?;
}
let class = self.atoms.get(previous)?.class;
while previous > 0
&& self
.atoms
.get(previous - 1)
.is_some_and(|atom| atom.class == class)
{
previous -= 1;
}
self.previous_point(previous)
}
fn next_word_end(&self, current: usize) -> Option<TerminalTextPoint> {
let mut next = current.saturating_add(1);
while self
.atoms
.get(next)
.is_some_and(|atom| atom.class == TextClass::Whitespace)
{
next += 1;
}
let class = self.atoms.get(next)?.class;
while self
.atoms
.get(next + 1)
.is_some_and(|atom| atom.class == class)
{
next += 1;
}
self.previous_point(next)
}
fn next_point(&self, mut index: usize) -> Option<TerminalTextPoint> {
while let Some(atom) = self.atoms.get(index) {
if let Some(point) = atom.point {
return Some(point);
}
index += 1;
}
None
}
fn previous_point(&self, mut index: usize) -> Option<TerminalTextPoint> {
loop {
if let Some(point) = self.atoms.get(index)?.point {
return Some(point);
}
index = index.checked_sub(1)?;
}
}
fn point_is_final_atom(&self, point: TerminalTextPoint) -> bool {
self.atoms
.iter()
.rev()
.find(|atom| atom.point.is_some())
.is_some_and(|atom| {
atom.point
.is_some_and(|start| start.row == point.row && atom.end_col == point.col)
})
}
}
fn terminal_cell_text(graphemes: &[u32]) -> String {
if graphemes.is_empty()
|| graphemes.first().copied() == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER)
{
return " ".to_string();
}
graphemes
.iter()
.map(|codepoint| char::from_u32(*codepoint).unwrap_or(char::REPLACEMENT_CHARACTER))
.collect()
}
fn text_class(text: &str) -> TextClass {
let Some(ch) = text.chars().next() else {
return TextClass::Whitespace;
};
if ch.is_whitespace() {
TextClass::Whitespace
} else if ch.is_ascii() && COPY_MODE_WORD_SEPARATORS.contains(ch) {
TextClass::Separator
} else {
TextClass::Word
}
}
fn text_fingerprint(text: &str) -> u64 {
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
impl GhosttyPaneTerminal {
pub fn new(
mut terminal: crate::ghostty::Terminal,
_response_writer: mpsc::Sender<Bytes>,
) -> std::io::Result<Self> {
let pending_pty_responses = Arc::new(Mutex::new(Vec::new()));
let callback_responses = pending_pty_responses.clone();
terminal
.set_write_pty_callback(move |bytes| {
if let Ok(mut responses) = callback_responses.lock() {
responses.push(Bytes::copy_from_slice(bytes));
}
})
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut render_state =
crate::ghostty::RenderState::new().map_err(|e| std::io::Error::other(e.to_string()))?;
let initial_colors = render_state
.update(&terminal)
.ok()
.and_then(|_| render_state.colors().ok());
let initial_default_foreground = initial_colors.map(|colors| colors.foreground);
let initial_default_background = initial_colors.map(|colors| colors.background);
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,
#[cfg(windows)]
recent_fallback: windows_recent_fallback::Cache::default(),
render_state,
kitty_keyboard: KittyKeyboardTracker::default(),
initial_default_foreground,
initial_default_background,
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
transient_default_color_owner_pgid: None,
default_color_tracker: DefaultColorOscTracker::default(),
default_color_event_tracker: DefaultColorEventTracker::default(),
child_default_foreground_changed: false,
child_default_background_changed: false,
osc52_forwarder: Osc52Forwarder::default(),
osc_debug_tracker: OscDebugTracker::default(),
agent_osc_state: AgentOscStateTracker::default(),
xtgettcap_query_tracker: XtgettcapQueryTracker::default(),
decscusr_tracker: DecscusrTracker::default(),
cursor_settle_state: CursorPositionSettleState::default(),
windows_powershell_prompt_cwd_reporting: false,
}),
key_encoder: Mutex::new(key_encoder),
pending_pty_responses,
})
}
pub(super) fn set_windows_powershell_prompt_cwd_reporting(&self, enabled: bool) {
if let Ok(mut core) = self.core.lock() {
core.windows_powershell_prompt_cwd_reporting = enabled;
}
}
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
if let Ok(mut core) = self.core.lock() {
let foreground_unowned = !core.child_default_foreground_changed;
let background_unowned = !core.child_default_background_changed;
core.host_terminal_theme = theme;
if foreground_unowned && background_unowned {
core.transient_default_color_owner_pgid = None;
}
write_host_terminal_theme_selective(
&mut core.terminal,
theme,
foreground_unowned,
background_unowned,
);
}
}
pub fn has_transient_default_color_override(&self) -> bool {
self.core
.lock()
.map(|core| core.transient_default_color_owner_pgid.is_some())
.unwrap_or(false)
}
pub fn maybe_restore_host_terminal_theme(&self, pane_id: PaneId, shell_pid: u32) -> bool {
{
let Ok(core) = self.core.lock() else {
return false;
};
if !should_probe_host_terminal_theme_restore(&core) {
return false;
}
}
let foreground_job = crate::detect::foreground_job(shell_pid);
let Ok(mut core) = self.core.lock() else {
return false;
};
let alternate_screen = core
.terminal
.active_screen()
.map(|screen| screen == crate::ghostty::ActiveScreen::Alternate)
.unwrap_or(false);
restore_host_terminal_theme_if_needed(
&mut core,
pane_id,
shell_pid,
alternate_screen,
foreground_job.as_ref(),
)
}
pub fn terminal_title(&self) -> Option<String> {
self.core
.lock()
.ok()
.and_then(|core| core.agent_osc_state.terminal_title().map(str::to_string))
}
#[cfg(unix)]
pub fn seed_terminal_title(&self, title: Option<String>) {
if let Ok(mut core) = self.core.lock() {
core.agent_osc_state.seed_terminal_title(title);
}
}
/// Returns the latest OSC 0/2 title retained for agent detection, or `""`
/// if no title has been seen or the last update was an empty clear.
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
pub fn agent_osc_title(&self) -> String {
self.core
.lock()
.map(|core| core.agent_osc_state.latest_title().to_owned())
.unwrap_or_default()
}
/// Returns the latest OSC 9 progress payload retained for agent detection,
/// or `""` if none has been seen.
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
pub fn agent_osc_progress(&self) -> String {
self.core
.lock()
.map(|core| core.agent_osc_state.latest_progress().to_owned())
.unwrap_or_default()
}
/// Clears retained OSC title/progress evidence when the pane's foreground
/// agent changes, so a new agent process starts from a blank OSC slate.
pub fn clear_agent_osc_state(&self) {
if let Ok(mut core) = self.core.lock() {
core.agent_osc_state.clear_retained();
}
}
pub fn process_pty_bytes(
&self,
pane_id: PaneId,
shell_pid: u32,
bytes: &[u8],
_response_writer: &mpsc::Sender<Bytes>,
) -> ProcessBytesResult {
crate::render_prof::counter("pty.bytes", bytes.len() as u64);
let Ok(mut core) = self.core.lock() else {
error!(pane = pane_id.raw(), "ghostty core lock poisoned in reader");
return ProcessBytesResult {
request_render: false,
render_delay: None,
clipboard_writes: Vec::new(),
reported_cwd: None,
terminal_responses: Vec::new(),
};
};
let _ = core.terminal.take_pwd_changes();
let default_color_observation = core.default_color_tracker.observe(bytes);
if shell_pid > 0 && default_color_observation {
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();
core.osc_debug_tracker.observe(bytes);
for event in core.osc_debug_tracker.drain_pending() {
debug!(
pane = pane_id.raw(),
osc_command = %event.command,
osc_payload = ?event.payload,
"agent OSC evidence observed"
);
}
core.agent_osc_state.observe(bytes);
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.kitty_keyboard.observe(filtered_bytes.as_ref());
let mut terminal_responses = Vec::new();
core.default_color_event_tracker
.observe(filtered_bytes.as_ref());
core.xtgettcap_query_tracker
.observe(filtered_bytes.as_ref());
core.decscusr_tracker.observe(filtered_bytes.as_ref());
let in_progress_default_color_event = core.default_color_event_tracker.in_progress_event();
let default_color_events = core.default_color_event_tracker.drain_pending();
let xtgettcap_responses = core.xtgettcap_query_tracker.drain_pending();
let write_started = crate::render_prof::timer();
self.write_pty_bytes_with_ordered_responses(
&mut core,
filtered_bytes.as_ref(),
default_color_events,
in_progress_default_color_event,
xtgettcap_responses,
&mut terminal_responses,
);
let reported_cwd = core
.terminal
.take_pwd_changes()
.into_iter()
.filter_map(|value| parse_reported_cwd(&value))
.next_back();
#[cfg(windows)]
windows_recent_fallback::update(&mut core);
crate::render_prof::duration_since("pty.ghostty_write", write_started);
let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled()
&& contains_kitty_graphics_sequence(filtered_bytes.as_ref());
if has_kitty_graphics_sequence {
debug!(pane = pane_id.raw(), "processed kitty graphics sequence");
}
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);
if CURSOR_POSITION_SETTLE_ENABLED {
let cursor_started = crate::render_prof::timer();
let cursor_after_write = current_cursor_state(&mut core);
crate::render_prof::duration_since("pty.cursor_state_update", cursor_started);
core.cursor_settle_state
.observe(cursor_after_write, Instant::now());
}
#[cfg(windows)]
let reported_cwd = if core.windows_powershell_prompt_cwd_reporting {
reported_cwd.or_else(|| windows_powershell_current_prompt_cwd(&mut core))
} else {
reported_cwd
};
let request_render = !synchronized_output;
let render_delay = render_delay_after_pty_write(
synchronized_output,
has_kitty_graphics_sequence,
cursor_position_settle_pending(&core),
CURSOR_POSITION_SETTLE_ENABLED,
);
if request_render {
crate::render_prof::event("pty.request_render");
}
if render_delay.is_some() {
crate::render_prof::event("pty.request_render_delayed");
}
if synchronized_output {
crate::render_prof::event("pty.synchronized_output_suppressed");
}
ProcessBytesResult {
request_render,
render_delay,
clipboard_writes,
reported_cwd,
terminal_responses,
}
}
fn write_pty_bytes_with_ordered_responses(
&self,
core: &mut GhosttyPaneCore,
bytes: &[u8],
default_color_events: Vec<DefaultColorTrackedEvent>,
in_progress_default_color_event: Option<DefaultColorEvent>,
xtgettcap_responses: Vec<XtgettcapResponse>,
terminal_responses: &mut Vec<Bytes>,
) {
let mut events = Vec::with_capacity(default_color_events.len() + xtgettcap_responses.len());
events.extend(
default_color_events
.into_iter()
.map(OrderedPtyResponseEvent::DefaultColor),
);
events.extend(
xtgettcap_responses
.into_iter()
.map(OrderedPtyResponseEvent::Xtgettcap),
);
events.sort_by_key(OrderedPtyResponseEvent::end_offset);
let mut written = 0;
for event in events {
let end_offset = event.end_offset().min(bytes.len());
let mut libghostty_responses = Vec::new();
if end_offset > written {
core.terminal.write(&bytes[written..end_offset]);
libghostty_responses = self.drain_pending_pty_responses();
written = end_offset;
}
match event {
OrderedPtyResponseEvent::DefaultColor(event) => {
let replacement = respond_to_default_color_event(core, event.event);
if replacement.is_some() {
remove_last_matching_libghostty_color_reply(
&mut libghostty_responses,
event.event,
);
}
terminal_responses.extend(libghostty_responses);
terminal_responses.extend(replacement);
}
OrderedPtyResponseEvent::Xtgettcap(response) => {
terminal_responses.extend(libghostty_responses);
terminal_responses.push(response.bytes);
}
}
}
if written < bytes.len() {
core.terminal.write(&bytes[written..]);
let mut libghostty_responses = self.drain_pending_pty_responses();
if let Some(event) = in_progress_default_color_event {
if default_color_event_response(core, event).is_some() {
remove_last_matching_libghostty_color_reply(&mut libghostty_responses, event);
}
}
terminal_responses.extend(libghostty_responses);
}
if !core.child_default_foreground_changed && !core.child_default_background_changed {
core.transient_default_color_owner_pgid = None;
}
}
fn drain_pending_pty_responses(&self) -> Vec<Bytes> {
self.pending_pty_responses
.lock()
.map(|mut responses| std::mem::take(&mut *responses))
.unwrap_or_default()
}
pub fn seed_history_ansi(&self, ansi: &str) {
if ansi.is_empty() {
return;
}
let Ok(mut core) = self.core.lock() else {
return;
};
core.terminal.write(ansi.as_bytes());
#[cfg(windows)]
windows_recent_fallback::update(&mut core);
if let Ok(mut key_encoder) = self.key_encoder.lock() {
key_encoder.set_from_terminal(&core.terminal);
}
}
#[cfg(unix)]
pub fn seed_handoff_input_state(&self, input_state: InputState) {
let Ok(mut core) = self.core.lock() else {
return;
};
if input_state.alternate_screen {
core.terminal.write(b"\x1b[?1049h");
}
let _ = core.terminal.mode_set(
crate::ghostty::MODE_APPLICATION_CURSOR_KEYS,
input_state.application_cursor,
);
let _ = core.terminal.mode_set(
crate::ghostty::MODE_BRACKETED_PASTE,
input_state.bracketed_paste,
);
let _ = core.terminal.mode_set(
crate::ghostty::MODE_FOCUS_EVENT,
input_state.focus_reporting,
);
let _ = core.terminal.mode_set(
crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL,
input_state.mouse_alternate_scroll,
);
for mode in [
MODE_MOUSE_X10,
MODE_MOUSE_PRESS_RELEASE,
MODE_MOUSE_BUTTON_MOTION,
MODE_MOUSE_ANY_MOTION,
] {
let _ = core.terminal.mode_set(mode, false);
}
let mouse_mode = match input_state.mouse_protocol_mode {
crate::input::MouseProtocolMode::None => None,
crate::input::MouseProtocolMode::Press => Some(MODE_MOUSE_X10),
crate::input::MouseProtocolMode::PressRelease => Some(MODE_MOUSE_PRESS_RELEASE),
crate::input::MouseProtocolMode::ButtonMotion => Some(MODE_MOUSE_BUTTON_MOTION),
crate::input::MouseProtocolMode::AnyMotion => Some(MODE_MOUSE_ANY_MOTION),
};
if let Some(mode) = mouse_mode {
let _ = core.terminal.mode_set(mode, true);
}
let _ = core
.terminal
.mode_set(crate::ghostty::MODE_MOUSE_UTF8, false);
let _ = core
.terminal
.mode_set(crate::ghostty::MODE_MOUSE_SGR, false);
match input_state.mouse_protocol_encoding {
crate::input::MouseProtocolEncoding::Default => {}
crate::input::MouseProtocolEncoding::Utf8 => {
let _ = core
.terminal
.mode_set(crate::ghostty::MODE_MOUSE_UTF8, true);
}
crate::input::MouseProtocolEncoding::Sgr => {
let _ = core.terminal.mode_set(crate::ghostty::MODE_MOUSE_SGR, true);
}
}
if input_state.modify_other_keys {
core.terminal.write(b"\x1b[>4;2m");
}
if let Ok(mut key_encoder) = self.key_encoder.lock() {
key_encoder.set_from_terminal(&core.terminal);
}
}
#[cfg(unix)]
pub fn seed_keyboard_protocol_flags(&self, flags: u16) {
if flags == 0 {
return;
}
self.seed_keyboard_protocol_ansi(&format!("\x1b[>{flags}u"));
}
#[cfg(unix)]
pub fn seed_keyboard_protocol_ansi(&self, ansi: &str) {
if ansi.is_empty() {
return;
}
let Ok(mut core) = self.core.lock() else {
return;
};
core.kitty_keyboard.observe(ansi.as_bytes());
core.terminal.write(ansi.as_bytes());
if let Ok(mut key_encoder) = self.key_encoder.lock() {
key_encoder.set_from_terminal(&core.terminal);
}
}
pub fn resize(
&self,
rows: u16,
cols: u16,
cell_width_px: u32,
cell_height_px: u32,
) -> Vec<Bytes> {
if let Ok(mut core) = self.core.lock() {
let offset_from_bottom = core
.terminal
.scrollbar()
.ok()
.map(|scrollbar| {
scrollbar
.total
.saturating_sub(scrollbar.offset + scrollbar.len)
})
.unwrap_or(0);
let bottom_before_resize = ghostty_detection_text(&core)
.map(|text| !text.trim().is_empty())
.unwrap_or(false);
let resize_recovery_probe_lines = usize::from(rows)
.saturating_mul(8)
.max(DEFAULT_DETECTION_ROWS);
let replay_ansi = if core.terminal.active_screen().ok()
== Some(crate::ghostty::ActiveScreen::Primary)
&& bottom_before_resize
{
ghostty_recent_ansi(&core, resize_recovery_probe_lines, true)
.ok()
.filter(|ansi| !ansi.trim().is_empty())
} else {
None
};
let _ = core
.terminal
.resize(cols, rows, cell_width_px, cell_height_px);
let terminal_responses = self.drain_pending_pty_responses();
let bottom_is_blank = ghostty_detection_text(&core)
.map(|text| text.trim().is_empty())
.unwrap_or(false);
if bottom_is_blank {
if let Some(ansi) = replay_ansi.as_deref() {
core.terminal.scroll_viewport_bottom();
core.terminal.write(ansi.as_bytes());
}
}
ghostty_set_scroll_offset_from_bottom(&mut core.terminal, offset_from_bottom);
if offset_from_bottom > 0 {
let mut remaining = offset_from_bottom.min(resize_recovery_probe_lines);
while remaining > 0
&& ghostty_visible_text(&mut core)
.map(|text| text.trim().is_empty())
.unwrap_or(false)
{
core.terminal.scroll_viewport_delta(1);
remaining -= 1;
}
}
terminal_responses
} else {
Vec::new()
}
}
pub fn scroll_up(&self, lines: usize) {
if let Ok(mut core) = self.core.lock() {
core.terminal.scroll_viewport_delta(-(lines as isize));
}
}
pub fn scroll_down(&self, lines: usize) {
if let Ok(mut core) = self.core.lock() {
core.terminal.scroll_viewport_delta(lines as isize);
}
}
pub fn scroll_reset(&self) {
if let Ok(mut core) = self.core.lock() {
core.terminal.scroll_viewport_bottom();
}
}
pub fn set_scroll_offset_from_bottom(&self, lines: usize) {
if let Ok(mut core) = self.core.lock() {
ghostty_set_scroll_offset_from_bottom(&mut core.terminal, lines);
}
}
pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
let Ok(core) = self.core.lock() else {
return None;
};
let scrollbar = core.terminal.scrollbar().ok()?;
Some(ScrollMetrics {
offset_from_bottom: scrollbar
.total
.saturating_sub(scrollbar.offset + scrollbar.len),
max_offset_from_bottom: scrollbar.total.saturating_sub(scrollbar.len),
viewport_rows: scrollbar.len,
})
}
pub fn keyboard_protocol(&self) -> Option<crate::input::KeyboardProtocol> {
let Ok(core) = self.core.lock() else {
return None;
};
Some(crate::input::KeyboardProtocol::from_kitty_flags(
core.terminal.kitty_keyboard_flags().ok()? as u16,
))
}
#[cfg(unix)]
pub fn kitty_keyboard_state_ansi(&self) -> Option<String> {
let core = self.core.lock().ok()?;
core.kitty_keyboard.replay_ansi()
}
pub fn input_state(&self) -> Option<InputState> {
let Ok(core) = self.core.lock() else {
return None;
};
let alternate_screen =
core.terminal.active_screen().ok()? == crate::ghostty::ActiveScreen::Alternate;
let application_cursor = core
.terminal
.mode_get(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS)
.ok()?;
let bracketed_paste = core
.terminal
.mode_get(crate::ghostty::MODE_BRACKETED_PASTE)
.ok()?;
let focus_reporting = core
.terminal
.mode_get(crate::ghostty::MODE_FOCUS_EVENT)
.ok()?;
let mouse_sgr = core
.terminal
.mode_get(crate::ghostty::MODE_MOUSE_SGR)
.ok()?;
let mouse_utf8 = core
.terminal
.mode_get(crate::ghostty::MODE_MOUSE_UTF8)
.ok()?;
let mouse_alternate_scroll = core
.terminal
.mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL)
.ok()?;
let mouse_protocol_mode = if core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? {
crate::input::MouseProtocolMode::AnyMotion
} else if core.terminal.mode_get(MODE_MOUSE_BUTTON_MOTION).ok()? {
crate::input::MouseProtocolMode::ButtonMotion
} else if core.terminal.mode_get(MODE_MOUSE_PRESS_RELEASE).ok()? {
crate::input::MouseProtocolMode::PressRelease
} else if core.terminal.mode_get(MODE_MOUSE_X10).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,
modify_other_keys: core
.terminal
.keyboard_state_ansi()
.ok()
.is_some_and(|ansi| !ansi.is_empty()),
})
}
pub fn wheel_routing(&self) -> Option<crate::pane::WheelRouting> {
let Ok(core) = self.core.lock() else {
return None;
};
let alternate_screen =
core.terminal.active_screen().ok()? == crate::ghostty::ActiveScreen::Alternate;
let mouse_alternate_scroll = core
.terminal
.mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL)
.ok()?;
let mouse_reporting = core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()?
|| core.terminal.mode_get(MODE_MOUSE_BUTTON_MOTION).ok()?
|| core.terminal.mode_get(MODE_MOUSE_PRESS_RELEASE).ok()?
|| core.terminal.mode_get(MODE_MOUSE_X10).ok()?;
Some(if mouse_reporting {
crate::pane::WheelRouting::MouseReport
} else if alternate_screen && mouse_alternate_scroll {
crate::pane::WheelRouting::AlternateScroll
} else {
crate::pane::WheelRouting::HostScroll
})
}
pub fn cursor_state(&self) -> Option<TerminalCursorState> {
let mut core = self.core.lock().ok()?;
let current = current_cursor_state(&mut core);
effective_cursor_state(&mut core, current)
}
pub fn synchronized_output_active(&self) -> bool {
self.core
.lock()
.ok()
.and_then(|core| {
core.terminal
.mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT)
.ok()
})
.unwrap_or(false)
}
pub fn encode_terminal_key(
&self,
key: crate::input::TerminalKey,
protocol: crate::input::KeyboardProtocol,
) -> Vec<u8> {
if ghostty_prefers_herdr_text_encoding(key) {
return crate::input::encode_terminal_key(key, protocol);
}
let Some(event) = ghostty_key_event_from_terminal_key(key) else {
return crate::input::encode_terminal_key(key, protocol);
};
let Ok(mut encoder) = self.key_encoder.lock() else {
return crate::input::encode_terminal_key(key, protocol);
};
match encoder.encode(&event) {
Ok(bytes)
if !bytes.is_empty() && encoded_key_preserves_event_kind(&bytes, key, protocol) =>
{
bytes
}
Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol),
}
}
pub fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
let Ok(core) = self.core.lock() else {
return None;
};
let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
let event = ghostty_mouse_event_from_button_kind(kind, column, row, modifiers)?;
encoder
.encode(&event)
.ok()
.filter(|bytes| !bytes.is_empty())
}
pub fn encode_mouse_motion(
&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;
};
if !core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? {
return None;
}
let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
let event = ghostty_mouse_event_from_motion_kind(kind, column, row, modifiers)?;
encoder
.encode(&event)
.ok()
.filter(|bytes| !bytes.is_empty())
}
pub fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
let Ok(core) = self.core.lock() else {
return None;
};
let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
let event = ghostty_mouse_event_from_wheel_kind(kind, column, row, modifiers)?;
encoder
.encode(&event)
.ok()
.filter(|bytes| !bytes.is_empty())
}
pub fn visible_text(&self) -> String {
self.core
.lock()
.ok()
.and_then(|mut core| ghostty_visible_text(&mut core).ok())
.unwrap_or_default()
}
pub fn visible_ansi(&self) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_visible_ansi(&core).ok())
.unwrap_or_default()
}
pub fn detection_text(&self) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_detection_text(&core).ok())
.unwrap_or_default()
}
pub fn recent_text(&self, lines: usize) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_recent_text(&core, lines).ok())
.unwrap_or_default()
}
pub fn recent_ansi(&self, lines: usize) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_recent_ansi(&core, lines, false).ok())
.unwrap_or_default()
}
pub fn recent_unwrapped_text(&self, lines: usize) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_recent_text_unwrapped(&core, lines).ok())
.unwrap_or_default()
}
pub fn recent_unwrapped_ansi(&self, lines: usize) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_recent_ansi(&core, lines, true).ok())
.unwrap_or_default()
}
pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
self.core
.lock()
.ok()
.and_then(|mut core| ghostty_extract_selection(&mut core, selection).ok())
}
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
self.core
.lock()
.ok()
.and_then(|mut core| ghostty_visible_hyperlinks(&mut core, area).ok())
.unwrap_or_default()
}
pub fn kitty_image_placements_with_data_filter<F>(
&self,
needs_data: F,
) -> Vec<crate::ghostty::KittyImagePlacement>
where
F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool,
{
self.core
.lock()
.ok()
.and_then(|core| {
core.terminal
.kitty_image_placements_with_data_filter(needs_data)
.ok()
})
.unwrap_or_default()
}
pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) {
let Ok(mut core) = self.core.lock() else {
return;
};
let host_theme = core.host_terminal_theme;
let initial_default_foreground = core.initial_default_foreground;
let initial_default_background = core.initial_default_background;
let GhosttyPaneCore {
terminal,
render_state,
decscusr_tracker,
..
} = &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, initial_default_background));
let default_fg = colors
.and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground));
let resolved_fg = colors.map(|c| ghostty_color(c.foreground));
let resolved_bg = colors.map(|c| ghostty_color(c.background));
let hide_kitty_placeholders = crate::kitty_graphics::is_enabled();
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 grapheme_bytes = Vec::new();
let mut symbol_scratch = String::new();
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 basic = cells.basic_data().unwrap_or_default();
let style = ghostty_cell_style(
&cells,
&basic,
default_fg,
default_bg,
resolved_fg,
resolved_bg,
);
let symbol = match ghostty_buffer_symbol_into(
&cells,
basic.wide,
hide_kitty_placeholders,
&mut grapheme_bytes,
&mut symbol_scratch,
) {
Ok(symbol) => symbol,
Err(_) => {
symbol_scratch.clear();
symbol_scratch.push_str(ghostty_blank_symbol_for_width(basic.wide));
symbol_scratch.as_str()
}
};
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;
}
}
ghostty_clear_render_dirty(render_state, area.height);
let current_cursor = cursor_state_from_render_state(render_state, decscusr_tracker);
if show_cursor {
if let Some(cursor) =
effective_cursor_state(&mut core, current_cursor).filter(|cursor| cursor.visible)
{
if cursor.x < area.width && cursor.y < area.height {
frame.set_cursor_position((area.x + cursor.x, area.y + cursor.y));
}
}
}
}
pub fn collect_dirty_patch(
&self,
area_width: u16,
area_height: u16,
) -> TerminalDirtyPatchOutcome {
self.core
.lock()
.ok()
.map(|mut core| ghostty_collect_dirty_patch(&mut core, area_width, area_height))
.unwrap_or(TerminalDirtyPatchOutcome::Fallback)
}
}
fn encoded_key_preserves_event_kind(
bytes: &[u8],
key: crate::input::TerminalKey,
protocol: crate::input::KeyboardProtocol,
) -> bool {
if !protocol.reports_event_types() || key.kind == crossterm::event::KeyEventKind::Press {
return true;
}
std::str::from_utf8(bytes)
.ok()
.and_then(crate::input::parse_terminal_key_sequence)
.is_some_and(|parsed| {
parsed.code == key.code && parsed.modifiers == key.modifiers && parsed.kind == key.kind
})
}
fn cursor_position_settle_pending(core: &GhosttyPaneCore) -> bool {
core.cursor_settle_state.pending()
}
fn effective_cursor_state(
core: &mut GhosttyPaneCore,
current: Option<TerminalCursorState>,
) -> Option<TerminalCursorState> {
if !CURSOR_POSITION_SETTLE_ENABLED {
return current;
}
core.cursor_settle_state
.reported_cursor(current, Instant::now())
}
fn render_delay_after_pty_write(
synchronized_output: bool,
has_kitty_graphics_sequence: bool,
cursor_position_settle_pending: bool,
cursor_position_settle_enabled: bool,
) -> Option<Duration> {
if synchronized_output {
None
} else if has_kitty_graphics_sequence {
Some(KITTY_GRAPHICS_REDRAW_SETTLE)
} else if cursor_position_settle_enabled && cursor_position_settle_pending {
Some(CURSOR_POSITION_SETTLE)
} else {
None
}
}
fn current_cursor_state(core: &mut GhosttyPaneCore) -> Option<TerminalCursorState> {
let GhosttyPaneCore {
terminal,
render_state,
decscusr_tracker,
..
} = core;
render_state.update(terminal).ok()?;
cursor_state_from_render_state(render_state, decscusr_tracker)
}
fn cursor_state_from_render_state(
render_state: &mut crate::ghostty::RenderState,
decscusr_tracker: &DecscusrTracker,
) -> Option<TerminalCursorState> {
let cursor = render_state.cursor_viewport().ok()??;
let shape = if decscusr_tracker.cursor_shape_overridden() {
render_state
.cursor_visual_style()
.ok()
.zip(render_state.cursor_blinking().ok())
.map(|(style, blinking)| decscusr_cursor_shape(style, blinking))
.unwrap_or(0)
} else {
0
};
Some(TerminalCursorState {
x: cursor.x,
y: cursor.y,
visible: render_state.cursor_visible().ok()?,
shape,
})
}
type VisibleHyperlinks = Vec<((u16, u16), String, String)>;
fn ghostty_clear_render_dirty(render_state: &mut crate::ghostty::RenderState, area_height: u16) {
let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else {
return;
};
let Ok(mut rows) = render_state.populate_row_iterator(&mut row_iterator) else {
return;
};
let mut y = 0u16;
while y < area_height && rows.next() {
let _ = rows.clear_dirty();
y += 1;
}
let _ = render_state.set_dirty(crate::ghostty::Dirty::Clean);
}
fn ghostty_collect_dirty_patch(
core: &mut GhosttyPaneCore,
area_width: u16,
area_height: u16,
) -> TerminalDirtyPatchOutcome {
let prof_started = crate::render_prof::timer();
macro_rules! finish {
($outcome:expr) => {{
let outcome = $outcome;
if let Some(started) = prof_started {
crate::render_prof::duration("dirty_collect.total", started.elapsed());
match &outcome {
TerminalDirtyPatchOutcome::Clean => {
crate::render_prof::event("dirty_collect.clean");
}
TerminalDirtyPatchOutcome::Fallback => {
crate::render_prof::event("dirty_collect.fallback");
}
TerminalDirtyPatchOutcome::Patch(patch) => {
crate::render_prof::event("dirty_collect.patch");
crate::render_prof::counter("dirty_collect.rows", patch.rows.len() as u64);
let cells = patch.rows.iter().map(|(_, cells)| cells.len() as u64).sum();
crate::render_prof::counter("dirty_collect.cells", cells);
}
}
}
return outcome;
}};
}
macro_rules! fallback {
($reason:literal) => {{
crate::render_prof::event(concat!("dirty_fallback.", $reason));
finish!(TerminalDirtyPatchOutcome::Fallback);
}};
}
let host_theme = core.host_terminal_theme;
let initial_default_foreground = core.initial_default_foreground;
let initial_default_background = core.initial_default_background;
let GhosttyPaneCore {
terminal,
render_state,
..
} = core;
if render_state.update(terminal).is_err() {
fallback!("render_state_update_error");
}
match render_state.dirty() {
Ok(crate::ghostty::Dirty::Clean) => finish!(TerminalDirtyPatchOutcome::Clean),
Ok(crate::ghostty::Dirty::Partial) => {}
Ok(crate::ghostty::Dirty::Full) => fallback!("dirty_full"),
Err(_) => fallback!("dirty_read_error"),
}
let colors = render_state.colors().ok();
let default_bg = colors
.and_then(|c| ghostty_default_bg(c.background, host_theme, initial_default_background));
let default_fg = colors
.and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground));
let resolved_fg = colors.map(|c| ghostty_color(c.foreground));
let resolved_bg = colors.map(|c| ghostty_color(c.background));
let hide_kitty_placeholders = crate::kitty_graphics::is_enabled();
let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else {
fallback!("row_iterator_new_error");
};
let Ok(mut row_cells) = crate::ghostty::RowCells::new() else {
fallback!("row_cells_new_error");
};
let Ok(mut rows) = render_state.populate_row_iterator(&mut row_iterator) else {
fallback!("populate_rows_error");
};
let mut grapheme_bytes = Vec::new();
let mut symbol_scratch = String::new();
let mut patch_rows = Vec::new();
let mut y = 0u16;
while y < area_height && rows.next() {
let Ok(dirty) = rows.dirty() else {
fallback!("row_dirty_read_error");
};
if dirty {
match rows.selection() {
Ok(None) => {}
Ok(Some(_)) => fallback!("row_selection_present"),
Err(_) => fallback!("row_selection_error"),
}
let Ok(mut cells) = rows.populate_cells(&mut row_cells) else {
fallback!("populate_cells_error");
};
let mut patch_cells = Vec::with_capacity(usize::from(area_width));
let mut x = 0u16;
while x < area_width && cells.next() {
let Ok(basic) = cells.basic_data() else {
fallback!("basic_data_error");
};
if basic.has_hyperlink {
fallback!("hyperlink_present");
}
let style = ghostty_cell_style(
&cells,
&basic,
default_fg,
default_bg,
resolved_fg,
resolved_bg,
);
let symbol = match ghostty_buffer_symbol_into(
&cells,
basic.wide,
hide_kitty_placeholders,
&mut grapheme_bytes,
&mut symbol_scratch,
) {
Ok(symbol) => symbol.to_owned(),
Err(_) => ghostty_blank_symbol_for_width(basic.wide).to_owned(),
};
patch_cells.push(cell_data_from_style(symbol, style));
x += 1;
}
while x < area_width {
patch_cells.push(blank_cell_data(default_fg, default_bg));
x += 1;
}
patch_rows.push((y, patch_cells));
}
y += 1;
}
let dirty_ys: std::collections::HashSet<u16> = patch_rows.iter().map(|(row, _)| *row).collect();
if !dirty_ys.is_empty() {
let Ok(mut clear_row_iterator) = crate::ghostty::RowIterator::new() else {
fallback!("clear_row_iterator_new_error");
};
let Ok(mut clear_rows) = render_state.populate_row_iterator(&mut clear_row_iterator) else {
fallback!("clear_populate_rows_error");
};
let mut clear_y = 0u16;
while clear_y < area_height && clear_rows.next() {
if dirty_ys.contains(&clear_y) && clear_rows.clear_dirty().is_err() {
fallback!("clear_dirty_error");
}
clear_y += 1;
}
}
if render_state
.set_dirty(crate::ghostty::Dirty::Clean)
.is_err()
{
fallback!("set_clean_error");
}
finish!(TerminalDirtyPatchOutcome::Patch(TerminalDirtyPatch {
rows: patch_rows
}));
}
fn ghostty_visible_hyperlinks(
core: &mut GhosttyPaneCore,
area: Rect,
) -> Result<VisibleHyperlinks, 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 links = Vec::new();
let mut y = 0u16;
while y < area.height && rows.next() {
let mut cells = rows.populate_cells(&mut row_cells)?;
let mut x = 0u16;
while x < area.width && cells.next() {
if cells.has_hyperlink()? {
if let Some(uri) = terminal.viewport_hyperlink_uri(x, y.into())? {
links.push(((area.x + x, area.y + y), ghostty_cell_symbol(&cells)?, uri));
}
}
x += 1;
}
y += 1;
}
Ok(links)
}
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_visible_ansi(core: &GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
let rows = core.terminal.rows()?;
let cols = core.terminal.cols()?;
if rows == 0 || cols == 0 {
return Ok(String::new());
}
core.terminal.read_ansi_viewport(
(0, 0),
(cols.saturating_sub(1), u32::from(rows.saturating_sub(1))),
false,
)
}
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)
}
#[cfg(windows)]
fn windows_powershell_current_prompt_cwd(core: &mut GhosttyPaneCore) -> Option<std::path::PathBuf> {
if core.terminal.active_screen().ok()? != crate::ghostty::ActiveScreen::Primary {
return None;
}
let cursor = current_cursor_state(core)?;
let rows = core.terminal.rows().ok()?;
let cols = core.terminal.cols().ok()?;
if rows == 0 || cols == 0 || cursor.y >= rows {
return None;
}
let total_rows = core.terminal.total_rows().ok()?;
let viewport_start = total_rows.saturating_sub(usize::from(rows));
let cursor_row = viewport_start + usize::from(cursor.y);
if core
.terminal
.read_text_screen(
(0, cursor_row as u32),
(cols.saturating_sub(1), cursor_row as u32),
false,
)
.ok()?
.trim()
.is_empty()
{
return None;
}
let text = core
.terminal
.read_text_screen(
(0, viewport_start as u32),
(cols.saturating_sub(1), cursor_row as u32),
false,
)
.ok()?;
windows_powershell_prompt_cwd(&text)
}
#[cfg(windows)]
fn windows_powershell_prompt_cwd(text: &str) -> Option<std::path::PathBuf> {
text.lines()
.rev()
.find(|line| !line.trim().is_empty())
.and_then(windows_powershell_prompt_line_cwd)
}
#[cfg(windows)]
fn windows_powershell_prompt_line_cwd(line: &str) -> Option<std::path::PathBuf> {
let line = line.trim_end();
let rest = line.strip_prefix("PS ")?;
let marker = rest.find('>')?;
let mut raw_cwd = rest[..marker].trim_end();
let suffix = rest[marker..].trim_end();
if !suffix.chars().all(|ch| ch == '>') {
return None;
}
if let Some((_, filesystem_path)) = raw_cwd.rsplit_once("::") {
raw_cwd = filesystem_path;
}
let cwd = std::path::PathBuf::from(raw_cwd);
(cwd.is_absolute() && cwd.is_dir()).then_some(cwd)
}
fn ghostty_recent_text(
core: &GhosttyPaneCore,
lines: usize,
) -> Result<String, crate::ghostty::Error> {
let text = ghostty_recent_text_for_terminal(&core.terminal, lines)?;
#[cfg(windows)]
{
if text.trim().is_empty() {
let fallback = windows_recent_fallback::recent_text(core, lines, false);
if !fallback.trim().is_empty() {
return Ok(fallback);
}
}
}
Ok(text)
}
fn ghostty_recent_text_unwrapped(
core: &GhosttyPaneCore,
lines: usize,
) -> Result<String, crate::ghostty::Error> {
let text = ghostty_recent_text_unwrapped_for_terminal(&core.terminal, lines)?;
#[cfg(windows)]
{
if text.trim().is_empty() {
let fallback = windows_recent_fallback::recent_text(core, lines, true);
if !fallback.trim().is_empty() {
return Ok(fallback);
}
}
}
Ok(text)
}
fn ghostty_recent_ansi(
core: &GhosttyPaneCore,
lines: usize,
unwrap: bool,
) -> Result<String, crate::ghostty::Error> {
let ansi = ghostty_recent_ansi_for_terminal(&core.terminal, lines, unwrap)?;
#[cfg(windows)]
{
if ansi.trim().is_empty() {
let fallback = windows_recent_fallback::recent_text(core, lines, unwrap);
if !fallback.trim().is_empty() {
return Ok(fallback);
}
}
}
Ok(ansi)
}
fn ghostty_recent_text_for_terminal(
terminal: &crate::ghostty::Terminal,
lines: usize,
) -> Result<String, crate::ghostty::Error> {
let Some((start, end, cols)) = ghostty_recent_read_range(terminal, lines)? else {
return Ok(String::new());
};
let mut rows = Vec::with_capacity(end.saturating_sub(start).saturating_add(1));
for y in start..=end {
rows.push(ghostty_screen_row(terminal, cols, y as u32)?);
}
trim_trailing_blank_rows(&mut rows);
Ok(recent_text_from_rows(&rows, lines))
}
fn ghostty_recent_text_unwrapped_for_terminal(
terminal: &crate::ghostty::Terminal,
lines: usize,
) -> Result<String, crate::ghostty::Error> {
let Some((start, end, cols)) = ghostty_recent_read_range(terminal, lines)? else {
return Ok(String::new());
};
terminal.read_text_screen(
(0, start as u32),
(cols.saturating_sub(1), end as u32),
false,
)
}
fn ghostty_recent_ansi_for_terminal(
terminal: &crate::ghostty::Terminal,
lines: usize,
unwrap: bool,
) -> Result<String, crate::ghostty::Error> {
let Some((start, end, cols)) = ghostty_recent_read_range(terminal, lines)? else {
return Ok(String::new());
};
terminal.read_ansi_screen(
(0, start as u32),
(cols.saturating_sub(1), end as u32),
false,
unwrap,
)
}
fn ghostty_recent_read_range(
terminal: &crate::ghostty::Terminal,
lines: usize,
) -> Result<Option<(usize, usize, u16)>, crate::ghostty::Error> {
let total_rows = terminal.total_rows()?;
let cols = terminal.cols()?;
if total_rows == 0 || cols == 0 || lines == 0 {
return Ok(None);
}
let end = total_rows.saturating_sub(1);
let start = end.saturating_add(1).saturating_sub(lines);
Ok(Some((start, end, cols)))
}
fn ghostty_set_scroll_offset_from_bottom(
terminal: &mut crate::ghostty::Terminal,
offset_from_bottom: usize,
) {
let Ok(scrollbar) = terminal.scrollbar() else {
terminal.scroll_viewport_bottom();
return;
};
let max_offset = scrollbar.total.saturating_sub(scrollbar.len);
terminal.scroll_viewport_row(max_offset.saturating_sub(offset_from_bottom));
}
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(
terminal: &crate::ghostty::Terminal,
cols: u16,
y: u32,
) -> Result<String, crate::ghostty::Error> {
let mut line = String::new();
for x in 0..cols {
let (wide, graphemes) = terminal.screen_cell(x, y)?;
if wide == crate::ghostty::CellWide::SpacerTail {
continue;
}
if graphemes.is_empty()
|| graphemes.first().copied() == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER)
{
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> {
if cells.wide()? == crate::ghostty::CellWide::SpacerTail {
return Ok(String::new());
}
let text = cells.grapheme_text()?;
if text.chars().next().map(u32::from) == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER) {
return Ok(" ".to_string());
}
if text.is_empty() {
return Ok(" ".to_string());
}
Ok(text)
}
pub(super) fn ghostty_blank_symbol_for_width(wide: crate::ghostty::CellWide) -> &'static str {
match wide {
crate::ghostty::CellWide::Wide => " ",
crate::ghostty::CellWide::SpacerTail => "",
crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => " ",
}
}
#[cfg(test)]
pub(super) fn ghostty_normalize_buffer_symbol(
symbol: &str,
wide: crate::ghostty::CellWide,
) -> String {
let expected_width = match wide {
crate::ghostty::CellWide::Wide => 2,
crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => 1,
crate::ghostty::CellWide::SpacerTail => 0,
};
let actual_width = symbol.width();
if actual_width == expected_width {
return symbol.to_string();
}
if wide == crate::ghostty::CellWide::Narrow && actual_width == 2 {
return symbol.to_string();
}
ghostty_blank_symbol_for_width(wide).to_string()
}
fn ghostty_buffer_symbol_into<'a>(
cells: &crate::ghostty::RowCellIter<'_>,
wide: crate::ghostty::CellWide,
hide_kitty_placeholders: bool,
grapheme_bytes: &mut Vec<u8>,
symbol_scratch: &'a mut String,
) -> Result<&'a str, crate::ghostty::Error> {
symbol_scratch.clear();
match wide {
crate::ghostty::CellWide::SpacerTail => {}
crate::ghostty::CellWide::SpacerHead => symbol_scratch.push(' '),
crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => {
cells.grapheme_text_into(grapheme_bytes, symbol_scratch)?;
let hidden_kitty_placeholder = hide_kitty_placeholders
&& symbol_scratch.chars().next().map(u32::from)
== Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER);
if hidden_kitty_placeholder || symbol_scratch.is_empty() {
symbol_scratch.clear();
symbol_scratch.push(' ');
}
}
}
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_scratch.width();
if actual_width != expected_width
&& !(wide == crate::ghostty::CellWide::Narrow && actual_width == 2)
{
symbol_scratch.clear();
symbol_scratch.push_str(ghostty_blank_symbol_for_width(wide));
}
Ok(symbol_scratch.as_str())
}
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 blank_cell_data(default_fg: Option<Color>, default_bg: Option<Color>) -> CellData {
cell_data_from_style(
" ".to_string(),
ghostty_default_style(default_fg, default_bg),
)
}
fn cell_data_from_style(symbol: String, style: Style) -> CellData {
CellData {
symbol: if symbol.is_empty() {
" ".to_string()
} else {
symbol
},
fg: crate::protocol::color_to_u32(style.fg.unwrap_or(Color::Reset)),
bg: crate::protocol::color_to_u32(style.bg.unwrap_or(Color::Reset)),
modifier: crate::protocol::modifier_to_u16(style.add_modifier),
skip: false,
hyperlink: None,
}
}
fn ghostty_default_style(default_fg: Option<Color>, default_bg: Option<Color>) -> Style {
let mut style = Style::default();
if let Some(fg) = default_fg {
style = style.fg(fg);
}
if let Some(bg) = default_bg {
style = style.bg(bg);
}
style
}
fn ghostty_cell_style(
cells: &crate::ghostty::RowCellIter<'_>,
basic: &crate::ghostty::CellBasicData,
default_fg: Option<Color>,
default_bg: Option<Color>,
resolved_fg: Option<Color>,
resolved_bg: Option<Color>,
) -> Style {
let mut fg = basic
.style
.fg_color
.map(ghostty_cell_color)
.or_else(|| cells.fg_color().ok().flatten().map(ghostty_color))
.or(default_fg);
let mut bg = cells
.content_bg_color()
.ok()
.flatten()
.or(basic.style.bg_color)
.map(ghostty_cell_color)
.or_else(|| cells.bg_color().ok().flatten().map(ghostty_color))
.or(default_bg);
if basic.style.invisible {
fg = bg.or(default_bg);
}
if basic.style.inverse {
// When the background is transparent (None), resolve it to the
// actual terminal background color before swapping. Otherwise
// the swapped fg becomes None (Color::Reset) which the host
// terminal renders as its default foreground — the same hue as
// the new bg, making inverse text invisible.
if bg.is_none() {
bg = resolved_bg;
}
if fg.is_none() {
fg = resolved_fg;
}
std::mem::swap(&mut fg, &mut bg);
}
let mut style = ghostty_default_style(fg, bg);
if let Some(underline_color) = basic.style.underline_color.map(ghostty_cell_color) {
style = style.underline_color(underline_color);
}
let mut modifiers = Modifier::empty();
if basic.style.bold {
modifiers |= Modifier::BOLD;
}
if basic.style.italic {
modifiers |= Modifier::ITALIC;
}
if basic.style.faint {
modifiers |= Modifier::DIM;
}
if basic.style.blink {
modifiers |= Modifier::SLOW_BLINK;
}
if basic.style.underlined {
modifiers |= Modifier::UNDERLINED;
}
if basic.style.strikethrough {
modifiers |= Modifier::CROSSED_OUT;
}
modifiers = crate::protocol::modifier_with_underline_style(modifiers, basic.style.underline);
style.add_modifier(modifiers)
}
#[derive(Debug)]
enum OrderedPtyResponseEvent {
DefaultColor(DefaultColorTrackedEvent),
Xtgettcap(XtgettcapResponse),
}
impl OrderedPtyResponseEvent {
fn end_offset(&self) -> usize {
match self {
Self::DefaultColor(event) => event.end_offset,
Self::Xtgettcap(response) => response.end_offset,
}
}
}
fn remove_last_matching_libghostty_color_reply(
responses: &mut Vec<Bytes>,
event: DefaultColorEvent,
) {
if let Some(index) = responses
.iter()
.rposition(|response| is_matching_libghostty_color_reply(response, event))
{
responses.remove(index);
}
}
fn is_matching_libghostty_color_reply(response: &Bytes, event: DefaultColorEvent) -> bool {
let prefix = match event {
DefaultColorEvent::Query(query) => format!("\x1b]{};rgb:", query.osc_number()),
DefaultColorEvent::PaletteQuery(index) => format!("\x1b]4;{index};rgb:"),
DefaultColorEvent::Set(_) | DefaultColorEvent::Reset(_) => return false,
};
response.starts_with(prefix.as_bytes())
&& (response.ends_with(b"\x07") || response.ends_with(b"\x1b\\"))
}
fn respond_to_default_color_event(
core: &mut GhosttyPaneCore,
event: DefaultColorEvent,
) -> Option<Bytes> {
match event {
DefaultColorEvent::Query(_) | DefaultColorEvent::PaletteQuery(_) => {
default_color_event_response(core, event)
}
DefaultColorEvent::Set(query) => {
mark_child_default_color_changed(core, query, true);
None
}
DefaultColorEvent::Reset(query) => {
mark_child_default_color_changed(core, query, false);
apply_cached_host_default_color(core, query);
None
}
}
}
fn default_color_event_response(
core: &mut GhosttyPaneCore,
event: DefaultColorEvent,
) -> Option<Bytes> {
match event {
DefaultColorEvent::Query(query) => default_color_query_response(query, core),
DefaultColorEvent::PaletteQuery(index) => palette_color_query_response(index, core),
DefaultColorEvent::Set(_) | DefaultColorEvent::Reset(_) => None,
}
}
fn default_color_query_response(
query: DefaultColorQuery,
core: &mut GhosttyPaneCore,
) -> Option<Bytes> {
let color = match query {
DefaultColorQuery::Foreground if !core.child_default_foreground_changed => core
.host_terminal_theme
.foreground
.map(host_theme_color_to_ghostty),
DefaultColorQuery::Background if !core.child_default_background_changed => core
.host_terminal_theme
.background
.map(host_theme_color_to_ghostty),
DefaultColorQuery::Cursor => cursor_color_query_color(core),
_ => None,
}?;
Some(osc_rgb_response(
&query.osc_number().to_string(),
color.r,
color.g,
color.b,
))
}
fn cursor_color_query_color(core: &mut GhosttyPaneCore) -> Option<crate::ghostty::RgbColor> {
let host_foreground = core.host_terminal_theme.foreground;
let child_foreground_changed = core.child_default_foreground_changed;
core.terminal
.effective_cursor_color()
.ok()
.flatten()
.or_else(|| {
if child_foreground_changed {
core.terminal.effective_foreground_color().ok().flatten()
} else {
host_foreground
.map(host_theme_color_to_ghostty)
.or_else(|| core.terminal.effective_foreground_color().ok().flatten())
}
})
}
fn palette_color_query_response(index: u8, core: &mut GhosttyPaneCore) -> Option<Bytes> {
let GhosttyPaneCore {
terminal,
render_state,
..
} = core;
render_state.update(terminal).ok()?;
let colors = render_state.colors().ok()?;
let color = colors.palette[usize::from(index)];
Some(osc_rgb_response(
&format!("4;{index}"),
color.r,
color.g,
color.b,
))
}
fn osc_rgb_response(command: &str, r: u8, g: u8, b: u8) -> Bytes {
let r = u16::from(r) * 257;
let g = u16::from(g) * 257;
let b = u16::from(b) * 257;
Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\"))
}
fn host_theme_color_to_ghostty(color: crate::terminal_theme::RgbColor) -> crate::ghostty::RgbColor {
crate::ghostty::RgbColor {
r: color.r,
g: color.g,
b: color.b,
}
}
fn apply_cached_host_default_color(core: &mut GhosttyPaneCore, query: DefaultColorQuery) {
write_host_terminal_theme_selective(
&mut core.terminal,
core.host_terminal_theme,
matches!(query, DefaultColorQuery::Foreground),
matches!(query, DefaultColorQuery::Background),
);
}
fn mark_child_default_color_changed(
core: &mut GhosttyPaneCore,
query: DefaultColorQuery,
changed: bool,
) {
match query {
DefaultColorQuery::Foreground => core.child_default_foreground_changed = changed,
DefaultColorQuery::Background => core.child_default_background_changed = changed,
DefaultColorQuery::Cursor => {}
}
}
fn ghostty_default_fg(
color: crate::ghostty::RgbColor,
host_theme: crate::terminal_theme::TerminalTheme,
initial_default_foreground: Option<crate::ghostty::RgbColor>,
) -> Option<Color> {
if let Some(host_foreground) = host_theme.foreground {
if host_foreground == terminal_theme_color(color) {
None
} else {
Some(ghostty_color(color))
}
} else if initial_default_foreground.is_some_and(|initial| initial != color) {
Some(ghostty_color(color))
} else {
None
}
}
fn ghostty_default_bg(
color: crate::ghostty::RgbColor,
host_theme: crate::terminal_theme::TerminalTheme,
initial_default_background: Option<crate::ghostty::RgbColor>,
) -> Option<Color> {
if let Some(host_background) = host_theme.background {
if host_background == terminal_theme_color(color) {
None
} else {
Some(ghostty_color(color))
}
} else if initial_default_background.is_some_and(|initial| initial != color) {
Some(ghostty_color(color))
} else {
None
}
}
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_cell_color(color: crate::ghostty::CellColor) -> Color {
match color {
crate::ghostty::CellColor::Palette(index) => Color::Indexed(index),
crate::ghostty::CellColor::Rgb(color) => ghostty_color(color),
}
}
fn ghostty_color(color: crate::ghostty::RgbColor) -> Color {
Color::Rgb(color.r, color.g, color.b)
}
fn lines_to_text(lines: Vec<String>) -> String {
let text = lines.join("\n");
if text.is_empty() {
text
} else {
format!("{text}\n")
}
}
pub(super) fn trim_trailing_blank_rows(rows: &mut Vec<String>) {
while rows.last().is_some_and(|row| row.trim().is_empty()) {
rows.pop();
}
}
fn recent_text_from_rows(rows: &[String], lines: usize) -> String {
let start = rows.len().saturating_sub(lines);
let text = rows[start..].join("\n");
if text.is_empty() {
text
} else {
format!("{text}\n")
}
}
fn contains_kitty_graphics_sequence(bytes: &[u8]) -> bool {
bytes.windows(3).any(|window| window == b"\x1b_G")
}
fn should_probe_host_terminal_theme_restore(core: &GhosttyPaneCore) -> bool {
if core.transient_default_color_owner_pgid.is_none() || core.host_terminal_theme.is_empty() {
return false;
}
!core
.terminal
.active_screen()
.map(|screen| screen == crate::ghostty::ActiveScreen::Alternate)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{layout::Rect, style::Color};
use tokio::sync::mpsc;
fn text_cell(text: &str) -> crate::ghostty::ScreenTextCell {
crate::ghostty::ScreenTextCell {
wide: crate::ghostty::CellWide::Narrow,
graphemes: text.chars().map(u32::from).collect(),
}
}
fn wide_text_cells(text: &str) -> [crate::ghostty::ScreenTextCell; 2] {
[
crate::ghostty::ScreenTextCell {
wide: crate::ghostty::CellWide::Wide,
graphemes: text.chars().map(u32::from).collect(),
},
crate::ghostty::ScreenTextCell {
wide: crate::ghostty::CellWide::SpacerTail,
graphemes: Vec::new(),
},
]
}
fn text_row(
cells: impl IntoIterator<Item = crate::ghostty::ScreenTextCell>,
soft_wrapped: bool,
) -> crate::ghostty::ScreenTextRow {
crate::ghostty::ScreenTextRow {
cells: cells.into_iter().collect(),
soft_wrapped,
wrap_continuation: false,
}
}
fn search_primary(
buffer: &RetainedTextBuffer,
query: &str,
case_sensitive: bool,
) -> Vec<TerminalTextMatch> {
buffer.search(query, case_sensitive, crate::ghostty::ActiveScreen::Primary)
}
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 write_wrapped_contract_lines(terminal: &mut crate::ghostty::Terminal, count: usize) {
for i in 0..count {
terminal.write(format!("WRAP-{i:03}-abcdefghijklmnopqrstuvwxyz\r\n").as_bytes());
}
terminal.write(b"END");
}
#[test]
fn retained_text_search_crosses_soft_wraps_but_not_hard_lines() {
let buffer = RetainedTextBuffer::new(
5,
vec![
text_row("abcde".chars().map(|ch| text_cell(&ch.to_string())), true),
text_row("fgh ".chars().map(|ch| text_cell(&ch.to_string())), false),
text_row("abc ".chars().map(|ch| text_cell(&ch.to_string())), false),
],
);
let matches = search_primary(&buffer, "def", true);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].start, TerminalTextPoint { row: 0, col: 3 });
assert_eq!(matches[0].end, TerminalTextPoint { row: 1, col: 0 });
assert!(search_primary(&buffer, "hab", true).is_empty());
}
#[test]
fn retained_text_search_maps_wide_and_combining_graphemes_to_cells() {
let mut cells = vec![text_cell("A")];
cells.extend(wide_text_cells("界"));
cells.push(text_cell("e\u{301}"));
cells.push(text_cell("Z"));
let buffer = RetainedTextBuffer::new(5, vec![text_row(cells, false)]);
let matches = search_primary(&buffer, "界e\u{301}", true);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].start, TerminalTextPoint { row: 0, col: 1 });
assert_eq!(matches[0].end, TerminalTextPoint { row: 0, col: 3 });
assert!(search_primary(&buffer, "\u{301}", true).is_empty());
}
#[test]
fn retained_text_search_skips_wide_spacer_heads_at_soft_wraps() {
let mut first = "abcd"
.chars()
.map(|ch| text_cell(&ch.to_string()))
.collect::<Vec<_>>();
first.push(crate::ghostty::ScreenTextCell {
wide: crate::ghostty::CellWide::SpacerHead,
graphemes: Vec::new(),
});
let mut second = wide_text_cells("界").to_vec();
second.extend("xyz".chars().map(|ch| text_cell(&ch.to_string())));
let buffer =
RetainedTextBuffer::new(5, vec![text_row(first, true), text_row(second, false)]);
let matches = search_primary(&buffer, "d界", true);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].start, TerminalTextPoint { row: 0, col: 3 });
assert_eq!(matches[0].end, TerminalTextPoint { row: 1, col: 1 });
}
#[test]
fn retained_text_word_motion_does_not_split_at_a_wide_spacer_head() {
let mut first = "abcd"
.chars()
.map(|ch| text_cell(&ch.to_string()))
.collect::<Vec<_>>();
first.push(crate::ghostty::ScreenTextCell {
wide: crate::ghostty::CellWide::SpacerHead,
graphemes: Vec::new(),
});
let mut second = wide_text_cells("界").to_vec();
second.extend("xyz".chars().map(|ch| text_cell(&ch.to_string())));
let buffer =
RetainedTextBuffer::new(5, vec![text_row(first, true), text_row(second, false)]);
assert_eq!(
buffer.word_motion(0, 0, TerminalWordMotion::NextStart),
None
);
assert_eq!(
buffer.word_motion(0, 0, TerminalWordMotion::NextEnd),
Some(TerminalTextPoint { row: 1, col: 4 })
);
}
#[test]
fn retained_text_search_is_literal_and_unicode_case_aware() {
let buffer = RetainedTextBuffer::new(
12,
vec![text_row(
"CAFÉ a.b ".chars().map(|ch| text_cell(&ch.to_string())),
false,
)],
);
assert_eq!(search_primary(&buffer, "café", false).len(), 1);
assert!(search_primary(&buffer, "café", true).is_empty());
assert_eq!(search_primary(&buffer, "a.b", true).len(), 1);
assert!(search_primary(&buffer, "a?b", true).is_empty());
}
#[test]
fn retained_text_word_motions_use_tmux_separators_across_rows() {
let buffer = RetainedTextBuffer::new(
6,
vec![
text_row("a_b.c ".chars().map(|ch| text_cell(&ch.to_string())), false),
text_row("—d ".chars().map(|ch| text_cell(&ch.to_string())), false),
],
);
assert_eq!(
buffer.word_motion(0, 0, TerminalWordMotion::NextStart),
Some(TerminalTextPoint { row: 0, col: 3 })
);
assert_eq!(
buffer.word_motion(0, 3, TerminalWordMotion::NextStart),
Some(TerminalTextPoint { row: 0, col: 4 })
);
assert_eq!(
buffer.word_motion(0, 4, TerminalWordMotion::NextStart),
Some(TerminalTextPoint { row: 1, col: 0 })
);
assert_eq!(
buffer.word_motion(1, 1, TerminalWordMotion::PreviousStart),
Some(TerminalTextPoint { row: 1, col: 0 })
);
}
#[test]
fn live_terminal_match_validation_rejects_overwritten_text() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap();
terminal.write(b"alpha needle");
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
let text_match = pane.search_text_matches("needle", true)[0];
assert!(pane.text_match_is_current(text_match));
pane.ghostty
.core
.lock()
.unwrap()
.terminal
.write(b"\r\x1b[2Kalpha changed");
assert!(!pane.text_match_is_current(text_match));
}
#[test]
fn live_terminal_match_validation_handles_soft_wrapped_matches() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap();
terminal.write(b"abcdef");
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
let text_match = pane.search_text_matches("def", true)[0];
assert_eq!(text_match.start, TerminalTextPoint { row: 0, col: 3 });
assert_eq!(text_match.end, TerminalTextPoint { row: 1, col: 0 });
assert!(pane.text_match_is_current(text_match));
}
#[test]
fn live_terminal_match_validation_rejects_an_active_screen_change() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap();
terminal.write(b"alpha needle");
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
let text_match = pane.search_text_matches("needle", true)[0];
pane.ghostty
.core
.lock()
.unwrap()
.terminal
.write(b"\x1b[?1049hneedle");
assert!(!pane.text_match_is_current(text_match));
}
#[test]
fn live_terminal_word_motion_expands_across_long_blank_history() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(10, 3, 200).unwrap();
terminal.write(b"origin\r\n");
for _ in 0..80 {
terminal.write(b"\r\n");
}
let last_row = terminal.total_rows().unwrap().saturating_sub(1) as u32;
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
assert_eq!(
pane.word_motion_target(last_row, 0, TerminalWordMotion::PreviousStart),
Some(TerminalTextPoint { row: 0, col: 0 })
);
}
#[test]
fn live_terminal_word_end_expands_through_a_long_soft_wrap() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(2, 3, 200).unwrap();
let word = "a".repeat(132);
terminal.write(word.as_bytes());
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
let text_match = pane.search_text_matches(&word, true)[0];
assert_eq!(
pane.word_motion_target(
text_match.start.row,
text_match.start.col,
TerminalWordMotion::NextEnd,
),
Some(text_match.end)
);
}
fn current_palette_color(pane: &GhosttyPaneTerminal, index: u8) -> crate::ghostty::RgbColor {
let mut core = pane.core.lock().unwrap();
let GhosttyPaneCore {
terminal,
render_state,
..
} = &mut *core;
render_state.update(terminal).unwrap();
render_state.colors().unwrap().palette[usize::from(index)]
}
fn expected_osc_rgb_response(command: &str, color: crate::ghostty::RgbColor) -> Bytes {
let r = u16::from(color.r) * 257;
let g = u16::from(color.g) * 257;
let b = u16::from(color.b) * 257;
Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\"))
}
#[test]
fn process_pty_bytes_reports_latest_libghostty_pwd_callback() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let partial = pane.process_pty_bytes(pane_id, 0, b"\x1b]7;file:///tmp/herdr%20", &tx);
assert_eq!(partial.reported_cwd, None);
let completed = pane.process_pty_bytes(pane_id, 0, b"repo\x07", &tx);
#[cfg(not(windows))]
assert_eq!(
completed.reported_cwd,
Some(std::path::PathBuf::from("/tmp/herdr repo"))
);
#[cfg(windows)]
assert_eq!(
completed.reported_cwd,
Some(std::path::PathBuf::from("\\tmp\\herdr repo"))
);
let latest = pane.process_pty_bytes(
pane_id,
0,
b"\x1b]9;9;/tmp/conemu\x1b\\\x1b]1337;CurrentDir=/tmp/iterm2\x1b\\",
&tx,
);
assert_eq!(
latest.reported_cwd,
Some(std::path::PathBuf::from("/tmp/iterm2"))
);
}
#[test]
fn seeded_history_pwd_does_not_leak_into_live_output() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
pane.seed_history_ansi("\x1b]7;file:///tmp/restored\x07");
let result = pane.process_pty_bytes(PaneId::from_raw(1), 0, b"live output", &tx);
assert_eq!(result.reported_cwd, None);
}
#[cfg(windows)]
#[test]
fn windows_powershell_prompt_cwd_uses_latest_default_prompt_path() {
let cwd = std::env::current_dir().unwrap();
let text = format!("PS C:\\old> cd {}\nPS {}>", cwd.display(), cwd.display());
assert_eq!(windows_powershell_prompt_cwd(&text).as_ref(), Some(&cwd));
}
#[cfg(windows)]
#[test]
fn windows_powershell_prompt_cwd_requires_current_prompt_line() {
let cwd = std::env::current_dir().unwrap();
let text = format!("PS {}>\ncommand output", cwd.display());
assert_eq!(windows_powershell_prompt_cwd(&text), None);
}
#[cfg(windows)]
#[test]
fn windows_powershell_prompt_cwd_ignores_command_echo() {
let cwd = std::env::current_dir().unwrap();
let text = format!("PS {}> echo hi", cwd.display());
assert_eq!(windows_powershell_prompt_cwd(&text), None);
}
#[cfg(windows)]
fn process_windows_powershell_prompt_bytes(
bytes: &[u8],
cols: u16,
rows: u16,
enabled: bool,
) -> ProcessBytesResult {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(cols, rows, 100).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
pane.set_windows_powershell_prompt_cwd_reporting(enabled);
pane.process_pty_bytes(PaneId::from_raw(1), 0, bytes, &tx)
}
#[cfg(windows)]
#[test]
fn process_pty_bytes_reports_windows_powershell_prompt_cwd() {
let cwd = std::env::current_dir().unwrap();
let bytes = format!("PS C:\\old> cd {}\r\nPS {}>", cwd.display(), cwd.display());
let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 80, 24, true);
assert_eq!(result.reported_cwd.as_ref(), Some(&cwd));
}
#[cfg(windows)]
#[test]
fn process_pty_bytes_reports_wrapped_windows_powershell_prompt_cwd() {
let cwd = std::env::current_dir().unwrap();
let bytes = format!("PS {}>", cwd.display());
let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 12, 8, true);
assert_eq!(result.reported_cwd.as_ref(), Some(&cwd));
}
#[cfg(windows)]
#[test]
fn process_pty_bytes_ignores_prompt_like_output_on_previous_line() {
let cwd = std::env::current_dir().unwrap();
let bytes = format!("PS {}>\r\n", cwd.display());
let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 80, 24, true);
assert_eq!(result.reported_cwd, None);
}
#[cfg(windows)]
#[test]
fn process_pty_bytes_ignores_windows_powershell_prompt_cwd_on_alternate_screen() {
let cwd = std::env::current_dir().unwrap();
let bytes = format!("\x1b[?1049hPS {}>", cwd.display());
let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 80, 24, true);
assert_eq!(result.reported_cwd, None);
}
#[cfg(windows)]
#[test]
fn process_pty_bytes_skips_windows_powershell_prompt_cwd_when_disabled() {
let cwd = std::env::current_dir().unwrap();
let bytes = format!("PS {}>", cwd.display());
let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 80, 24, false);
assert_eq!(result.reported_cwd, None);
}
fn expected_xtgettcap_response(cap_hex: &str, value: Option<&[u8]>) -> Bytes {
let mut response = format!("\x1bP1+r{cap_hex}").into_bytes();
if let Some(value) = value {
response.push(b'=');
append_upper_hex(value, &mut response);
}
response.extend_from_slice(b"\x1b\\");
Bytes::from(response)
}
fn append_upper_hex(bytes: &[u8], output: &mut Vec<u8>) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
for &byte in bytes {
output.push(HEX[usize::from(byte >> 4)]);
output.push(HEX[usize::from(byte & 0x0f)]);
}
}
#[test]
fn decscusr_cursor_shape_preserves_blinking_variants() {
assert_eq!(
decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Block, true),
1
);
assert_eq!(
decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Block, false),
2
);
assert_eq!(
decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Underline, true),
3
);
assert_eq!(
decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Underline, false),
4
);
assert_eq!(
decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Bar, true),
5
);
assert_eq!(
decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Bar, false),
6
);
assert_eq!(
decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::BlockHollow, false),
2
);
}
#[test]
fn cursor_state_uses_terminal_default_until_child_sets_shape() {
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);
assert_eq!(pane.cursor_state().unwrap().shape, 0);
pane.process_pty_bytes(pane_id, 0, b"\x1b[6 q", &tx);
assert_eq!(pane.cursor_state().unwrap().shape, 6);
}
#[test]
fn cursor_state_returns_terminal_default_after_decscusr_reset() {
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);
pane.process_pty_bytes(pane_id, 0, b"\x1b[2 q", &tx);
assert_eq!(pane.cursor_state().unwrap().shape, 2);
pane.process_pty_bytes(pane_id, 0, b"\x1b[0 q", &tx);
assert_eq!(pane.cursor_state().unwrap().shape, 0);
}
#[test]
fn cursor_shape_tracker_handles_split_decscusr_sequences() {
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);
pane.process_pty_bytes(pane_id, 0, b"\x1b[", &tx);
pane.process_pty_bytes(pane_id, 0, b"5 ", &tx);
pane.process_pty_bytes(pane_id, 0, b"q", &tx);
assert_eq!(pane.cursor_state().unwrap().shape, 5);
}
#[test]
#[cfg(windows)]
fn cursor_state_holds_pty_position_change_until_settle_window() {
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);
pane.process_pty_bytes(pane_id, 0, b"x", &tx);
assert_eq!(
pane.cursor_state()
.map(|cursor| (cursor.x, cursor.y, cursor.visible)),
Some((1, 0, true))
);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[6;21H", &tx);
assert_eq!(result.render_delay, Some(CURSOR_POSITION_SETTLE));
assert_eq!(
pane.cursor_state()
.map(|cursor| (cursor.x, cursor.y, cursor.visible)),
Some((1, 0, true))
);
}
#[test]
#[cfg(not(windows))]
fn cursor_state_uses_live_position_when_settle_policy_disabled() {
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);
pane.process_pty_bytes(pane_id, 0, b"x", &tx);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[6;21H", &tx);
assert_eq!(result.render_delay, None);
assert_eq!(
pane.cursor_state()
.map(|cursor| (cursor.x, cursor.y, cursor.visible)),
Some((20, 5, true))
);
}
#[test]
fn cursor_settle_policy_controls_render_delay() {
assert_eq!(
render_delay_after_pty_write(false, false, true, true),
Some(CURSOR_POSITION_SETTLE)
);
assert_eq!(
render_delay_after_pty_write(false, false, true, false),
None
);
assert_eq!(
render_delay_after_pty_write(false, true, true, false),
Some(KITTY_GRAPHICS_REDRAW_SETTLE)
);
assert_eq!(render_delay_after_pty_write(true, false, true, true), None);
}
#[test]
fn host_terminal_theme_restore_probe_skips_when_no_transient_override() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let core = pane.core.lock().unwrap();
assert!(!should_probe_host_terminal_theme_restore(&core));
}
#[test]
fn host_terminal_theme_restore_probe_skips_when_host_theme_unknown() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.transient_default_color_owner_pgid = Some(42);
}
let core = pane.core.lock().unwrap();
assert!(!should_probe_host_terminal_theme_restore(&core));
}
#[test]
fn host_terminal_theme_restore_probe_skips_on_alternate_screen() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
terminal.write(b"\x1b[?1049h");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.transient_default_color_owner_pgid = Some(42);
core.host_terminal_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,
}),
};
}
let core = pane.core.lock().unwrap();
assert!(!should_probe_host_terminal_theme_restore(&core));
}
#[test]
fn host_terminal_theme_restore_probe_runs_when_restore_is_pending() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.transient_default_color_owner_pgid = Some(42);
core.host_terminal_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,
}),
};
}
let core = pane.core.lock().unwrap();
assert!(should_probe_host_terminal_theme_restore(&core));
}
#[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_enter_backspace_release_in_legacy_pane_emits_nothing() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
for code in [
crossterm::event::KeyCode::Enter,
crossterm::event::KeyCode::Backspace,
] {
let press = pane.encode_terminal_key(
crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()),
crate::input::KeyboardProtocol::Legacy,
);
let release = pane.encode_terminal_key(
crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty())
.with_kind(crossterm::event::KeyEventKind::Release),
crate::input::KeyboardProtocol::Legacy,
);
assert!(!press.is_empty(), "{code:?} press should emit bytes");
assert!(
release.is_empty(),
"{code:?} release should emit nothing in a legacy pane, got {release:?}"
);
}
}
#[test]
fn ghostty_report_event_pane_keeps_basic_compatibility_keys_legacy() {
let (tx, _rx) = mpsc::channel(4);
// Push kitty flags including REPORT_EVENT_TYPES (0b10) + DISAMBIGUATE (0b1).
let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
terminal.write(b"\x1b[>3u");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
for (code, expected) in [
(crossterm::event::KeyCode::Enter, b"\r".as_slice()),
(crossterm::event::KeyCode::Backspace, b"\x7f".as_slice()),
] {
let press = pane.encode_terminal_key(
crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()),
pane.keyboard_protocol().unwrap(),
);
assert_eq!(
press, expected,
"{code:?} press should stay legacy-compatible without REPORT_ALL_KEYS"
);
let release = pane.encode_terminal_key(
crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty())
.with_kind(crossterm::event::KeyEventKind::Release),
pane.keyboard_protocol().unwrap(),
);
assert!(
release.is_empty(),
"{code:?} release should not fall back to legacy bytes, got {release:?}"
);
}
}
#[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");
}
#[cfg(unix)]
#[test]
fn ghostty_seed_handoff_input_state_restores_input_modes() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
pane.seed_handoff_input_state(InputState {
alternate_screen: true,
application_cursor: true,
bracketed_paste: true,
focus_reporting: true,
mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion,
mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr,
mouse_alternate_scroll: true,
modify_other_keys: true,
});
assert_eq!(
pane.input_state(),
Some(InputState {
alternate_screen: true,
application_cursor: true,
bracketed_paste: true,
focus_reporting: true,
mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion,
mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr,
mouse_alternate_scroll: true,
modify_other_keys: true,
})
);
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");
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
assert_eq!(encoded, b"\x1b[27;2;13~");
}
#[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_kitty_pane_encodes_shift_enter_as_csi_u() {
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);
pane.process_pty_bytes(pane_id, 0, b"\x1b[>5u", &tx);
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
assert_eq!(
pane.keyboard_protocol(),
Some(crate::input::KeyboardProtocol::Kitty { flags: 5 })
);
assert_eq!(encoded, b"\x1b[13;2u");
}
#[cfg(unix)]
#[test]
fn ghostty_seed_keyboard_protocol_flags_restores_shift_enter_encoding() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
pane.seed_keyboard_protocol_flags(5);
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
assert_eq!(
pane.keyboard_protocol(),
Some(crate::input::KeyboardProtocol::Kitty { flags: 5 })
);
assert_eq!(encoded, b"\x1b[13;2u");
}
#[cfg(unix)]
#[test]
fn ghostty_keyboard_protocol_state_replays_nested_stack() {
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);
pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u\x1b[>5u", &tx);
let ansi = pane.kitty_keyboard_state_ansi().unwrap();
let (restored_tx, _restored_rx) = mpsc::channel(4);
let restored_terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let restored = GhosttyPaneTerminal::new(restored_terminal, restored_tx).unwrap();
restored.seed_keyboard_protocol_ansi(&ansi);
assert_eq!(
restored.keyboard_protocol(),
Some(crate::input::KeyboardProtocol::Kitty { flags: 5 })
);
let (pop_tx, _pop_rx) = mpsc::channel(4);
restored.process_pty_bytes(pane_id, 0, b"\x1b[<u", &pop_tx);
assert_eq!(
restored.keyboard_protocol(),
Some(crate::input::KeyboardProtocol::Kitty { flags: 1 })
);
}
#[test]
fn ghostty_modify_other_keys_mode_one_preserves_shift_enter() {
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);
pane.process_pty_bytes(pane_id, 0, b"\x1b[>4;1m", &tx);
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
assert_eq!(encoded, b"\x1b[27;2;13~");
}
#[test]
fn ghostty_kitty_pane_encodes_parsed_legacy_alt_backspace_as_csi_u() {
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);
pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx);
let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
assert_eq!(encoded, b"\x1b[127;3u");
}
#[test]
fn ghostty_pane_characterizes_ctrl_backspace_encoding() {
let (tx, _rx) = mpsc::channel(4);
let legacy = GhosttyPaneTerminal::new(
crate::ghostty::Terminal::new(80, 24, 0).unwrap(),
tx.clone(),
)
.unwrap();
let ctrl_backspace = crate::input::TerminalKey::new(
crossterm::event::KeyCode::Backspace,
crossterm::event::KeyModifiers::CONTROL,
);
assert_eq!(
legacy.encode_terminal_key(ctrl_backspace, crate::input::KeyboardProtocol::Legacy),
b"\x08"
);
let plain_backspace = crate::input::TerminalKey::new(
crossterm::event::KeyCode::Backspace,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(
legacy.encode_terminal_key(plain_backspace, crate::input::KeyboardProtocol::Legacy),
b"\x7f"
);
let kitty = GhosttyPaneTerminal::new(
crate::ghostty::Terminal::new(80, 24, 0).unwrap(),
tx.clone(),
)
.unwrap();
let pane_id = PaneId::from_raw(1);
kitty.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx);
assert_eq!(
kitty.encode_terminal_key(ctrl_backspace, crate::input::KeyboardProtocol::Legacy),
b"\x1b[127;5u"
);
}
#[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_mouse_drag_without_motion_reporting_is_not_forwarded() {
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::Drag(crossterm::event::MouseButton::Left),
4,
6,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(encoded, None);
}
#[test]
fn ghostty_mouse_moved_encoding_uses_any_motion_state() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
terminal.write(b"\x1b[?1003h\x1b[?1006h");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let encoded = pane.encode_mouse_motion(
crossterm::event::MouseEventKind::Moved,
4,
6,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(encoded.as_deref(), Some(&b"\x1b[<35;5;7M"[..]));
}
#[test]
fn ghostty_mouse_sgr_pixels_downgrades_to_cell_coordinates() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
terminal.write(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let encoded = pane.encode_mouse_motion(
crossterm::event::MouseEventKind::Moved,
4,
6,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(encoded.as_deref(), Some(&b"\x1b[<35;5;7M"[..]));
}
#[test]
fn ghostty_normalize_buffer_symbol_prefers_grapheme_width_when_metadata_disagrees() {
const WIDE_GRAPHEME: &str = "🙂";
const FLAG_GRAPHEME: &str = "🇧🇷";
const FAMILY_GRAPHEME: &str = "👨‍👩‍👧";
const VS16_GRAPHEME: &str = "⚠️";
const EMOJI_GRAPHEME: &str = "💳";
assert_eq!(
ghostty_normalize_buffer_symbol(WIDE_GRAPHEME, crate::ghostty::CellWide::Wide),
WIDE_GRAPHEME
);
assert_eq!(
ghostty_normalize_buffer_symbol("a", crate::ghostty::CellWide::Wide),
" "
);
assert_eq!(
ghostty_normalize_buffer_symbol(FLAG_GRAPHEME, crate::ghostty::CellWide::Wide),
FLAG_GRAPHEME
);
assert_eq!(
ghostty_normalize_buffer_symbol(FAMILY_GRAPHEME, crate::ghostty::CellWide::Wide),
FAMILY_GRAPHEME
);
assert_eq!(
ghostty_normalize_buffer_symbol("⌨️", crate::ghostty::CellWide::Narrow),
"⌨️"
);
assert_eq!(
ghostty_normalize_buffer_symbol(VS16_GRAPHEME, crate::ghostty::CellWide::Narrow),
VS16_GRAPHEME
);
assert_eq!(
ghostty_normalize_buffer_symbol(EMOJI_GRAPHEME, crate::ghostty::CellWide::Narrow),
EMOJI_GRAPHEME
);
assert_eq!(
ghostty_normalize_buffer_symbol(" ", crate::ghostty::CellWide::SpacerTail),
""
);
assert_eq!(
ghostty_normalize_buffer_symbol("xx", crate::ghostty::CellWide::SpacerHead),
" "
);
}
fn render_cells_to_symbols(
terminal: &mut crate::ghostty::Terminal,
) -> Vec<(crate::ghostty::CellWide, String)> {
let mut render_state = crate::ghostty::RenderState::new().unwrap();
render_state.update(terminal).unwrap();
let mut row_iterator = crate::ghostty::RowIterator::new().unwrap();
let mut rows = render_state
.populate_row_iterator(&mut row_iterator)
.unwrap();
let mut row_cells = crate::ghostty::RowCells::new().unwrap();
let mut grapheme_bytes = Vec::new();
let mut symbol_scratch = String::new();
let mut out = Vec::new();
if rows.next() {
let mut cells = rows.populate_cells(&mut row_cells).unwrap();
while cells.next() {
let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow);
let symbol = ghostty_buffer_symbol_into(
&cells,
wide,
false,
&mut grapheme_bytes,
&mut symbol_scratch,
)
.unwrap()
.to_string();
out.push((wide, symbol));
}
}
out
}
#[test]
fn grapheme_cluster_mode_renders_flag_emoji_in_single_wide_cell() {
let mut terminal = crate::ghostty::Terminal::new(40, 1, 0).unwrap();
terminal.write("🇧🇷".as_bytes());
let cells = render_cells_to_symbols(&mut terminal);
assert!(
cells
.iter()
.any(|(wide, symbol)| *wide == crate::ghostty::CellWide::Wide && symbol == "🇧🇷"),
"expected a wide cell containing the full flag grapheme, got {cells:?}"
);
}
#[test]
fn grapheme_cluster_mode_renders_zwj_family_in_single_wide_cell() {
let mut terminal = crate::ghostty::Terminal::new(40, 1, 0).unwrap();
terminal.write("👨\u{200d}👩\u{200d}👧".as_bytes());
let cells = render_cells_to_symbols(&mut terminal);
assert!(
cells
.iter()
.any(|(wide, symbol)| *wide == crate::ghostty::CellWide::Wide
&& symbol == "👨\u{200d}👩\u{200d}👧"),
"expected a wide cell containing the full ZWJ grapheme, got {cells:?}"
);
}
#[test]
fn pane_scrollback_controls_round_trip_and_clamp_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);
for offset in [
0,
before.max_offset_from_bottom / 2,
before.max_offset_from_bottom,
usize::MAX,
] {
pane.set_scroll_offset_from_bottom(offset);
let after = pane.scroll_metrics().expect("scroll metrics after scroll");
assert_eq!(
after.offset_from_bottom,
offset.min(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 plain_text_reads_skip_wide_character_spacer_cells() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(40, 3, 100).unwrap();
terminal.write("日本語テスト ABC 123".as_bytes());
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
assert_eq!(pane.visible_text(), "日本語テスト ABC 123\n");
assert_eq!(pane.recent_text(3), "日本語テスト ABC 123\n");
assert_eq!(pane.recent_unwrapped_text(3), "日本語テスト ABC 123");
assert_eq!(pane.detection_text(), "日本語テスト ABC 123\n");
}
#[test]
fn visible_ansi_preserves_cell_style_sequences() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap();
terminal.write(b"\x1b[31;1mred\x1b[0m plain");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let ansi = pane.visible_ansi();
assert!(ansi.contains("red"));
assert!(ansi.contains("plain"));
assert!(ansi.contains("\x1b["));
}
#[test]
fn recent_ansi_can_read_styled_scrollback() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap();
terminal.write(b"\x1b[34mblue\x1b[0m\r\nline2\r\nline3\r\nline4");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let ansi = pane.recent_ansi(4);
assert!(ansi.contains("blue"));
assert!(ansi.contains("line4"));
assert!(ansi.contains("\x1b["));
}
#[test]
fn resize_shrinks_both_axes_with_cursor_at_old_bottom() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(8, 4, 10_000).unwrap();
terminal.write(b"alpha\r\nbeta\r\ngamma\r\ndelta");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
pane.resize(3, 7, 8, 16);
assert_eq!(pane.visible_text(), "beta\ngamma\ndelta\n");
assert_eq!(pane.detection_text(), "beta\ngamma\ndelta\n");
assert_eq!(
pane.scroll_metrics(),
Some(ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 1,
viewport_rows: 3,
})
);
}
#[test]
fn resize_reflow_keeps_scrolled_viewport_and_bottom_detection_sane() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(12, 4, 10_000).unwrap();
write_wrapped_contract_lines(&mut terminal, 40);
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let bottom_snapshot = pane.detection_text();
assert!(bottom_snapshot.contains("END"));
let initial = pane.scroll_metrics().expect("initial scroll metrics");
assert!(initial.max_offset_from_bottom > 0);
pane.set_scroll_offset_from_bottom(initial.max_offset_from_bottom / 2);
assert!(!pane.visible_text().trim().is_empty());
for (rows, cols) in [(4, 10), (4, 7), (6, 18), (3, 9), (5, 12)] {
let before_resize = pane.scroll_metrics().expect("scroll metrics before resize");
pane.resize(rows, cols, 0, 0);
let metrics = pane.scroll_metrics().expect("scroll metrics after resize");
assert_eq!(metrics.viewport_rows, rows as usize);
assert_eq!(
metrics.offset_from_bottom,
before_resize
.offset_from_bottom
.min(metrics.max_offset_from_bottom)
);
assert!(
metrics.offset_from_bottom > 0,
"resize should preserve a scrolled viewport instead of jumping to bottom"
);
assert!(metrics.max_offset_from_bottom > 0);
let visible = pane.visible_text();
assert!(
!visible.trim().is_empty(),
"visible text should not be empty after resize to {rows}x{cols}; metrics={metrics:?}; detection={:?}; recent={:?}",
pane.detection_text(),
pane.recent_text(6)
);
assert!(
pane.detection_text().contains("END"),
"bottom detection should remain independent from the scrolled viewport after resize"
);
}
}
#[test]
fn resize_recovery_does_not_replay_history_when_visible_screen_was_blank() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(20, 3, 10_000).unwrap();
terminal.write(b"old history\r\n\x1b[2J\x1b[H");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
assert!(pane.visible_text().trim().is_empty());
assert!(pane.detection_text().trim().is_empty());
pane.resize(3, 20, 0, 0);
assert!(pane.visible_text().trim().is_empty());
assert!(pane.detection_text().trim().is_empty());
assert!(pane.recent_text(3).trim().is_empty());
}
#[test]
fn resize_recovery_does_not_replay_scrolled_history_over_blank_bottom() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(20, 3, 10_000).unwrap();
write_numbered_lines(&mut terminal, 20);
terminal.write(b"\x1b[2J\x1b[H");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
assert!(pane.detection_text().trim().is_empty());
let metrics = pane.scroll_metrics().expect("scroll metrics");
pane.set_scroll_offset_from_bottom(metrics.max_offset_from_bottom);
assert!(!pane.visible_text().trim().is_empty());
pane.resize(3, 20, 0, 0);
assert!(pane.detection_text().trim().is_empty());
assert!(pane.recent_text(3).trim().is_empty());
}
#[test]
fn resize_returns_in_band_size_report_response() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
terminal.mode_set(2048, true).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let responses = pane.resize(40, 100, 9, 18);
assert_eq!(
responses,
vec![Bytes::from_static(b"\x1B[48;40;100;720;900t")]
);
}
#[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 kitty_graphics_write_requests_render_with_settle_backstop() {
crate::kitty_graphics::set_enabled(true);
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 result = pane_terminal.process_pty_bytes(
pane_id,
0,
b"\x1b_Ga=T,f=32,t=d,i=7,p=1,s=1,v=1,q=2;/wAA/w==\x1b\\",
&tx,
);
assert!(result.request_render);
assert_eq!(result.render_delay, Some(KITTY_GRAPHICS_REDRAW_SETTLE));
}
#[test]
fn seeded_history_is_rendered_on_next_draw() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 100).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
pane.seed_history_ansi("restored history");
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 row = (0..16).map(|x| buffer[(x, 0)].symbol()).collect::<String>();
assert_eq!(row, "restored history");
}
#[test]
fn render_leaves_unknown_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 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().fg, Some(Color::Reset));
assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset));
assert_eq!(buffer[(2, 0)].symbol(), " ");
assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset));
assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset));
}
#[test]
fn render_blanks_kitty_unicode_placeholders_when_graphics_enabled() {
crate::kitty_graphics::set_enabled(true);
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.terminal
.write("before\u{10eeee}\u{0305}\u{0305}after".as_bytes());
}
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();
crate::kitty_graphics::set_enabled(false);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 0)].symbol(), "b");
assert_eq!(buffer[(6, 0)].symbol(), " ");
assert_eq!(buffer[(7, 0)].symbol(), "a");
assert_eq!(pane.visible_text().lines().next(), Some("before after"));
assert_eq!(pane.recent_text(5), "before after\n");
}
#[test]
fn render_keeps_explicit_cell_foreground_when_host_is_unknown() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.terminal.write(b"\x1b[38;2;68;85;102mhi\x1b[0m");
}
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_fg = Some(Color::Rgb(0x44, 0x55, 0x66));
assert_eq!(buffer[(0, 0)].symbol(), "h");
assert_eq!(buffer[(0, 0)].style().fg, expected_fg);
assert_eq!(buffer[(2, 0)].symbol(), " ");
assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset));
}
#[test]
fn render_keeps_explicit_cell_background_when_host_is_unknown() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.terminal.write(b"\x1b[48;2;68;85;102mhi\x1b[0m");
}
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, Some(Color::Reset));
}
#[test]
fn render_preserves_palette_colors_instead_of_flattening_to_rgb() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.terminal.write(
b"\x1b[31mR\x1b[0m \x1b[38;5;171mI\x1b[0m \x1b[48;5;4mB\x1b[0m \x1b[38;2;1;2;3mT",
);
}
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(), "R");
assert_eq!(buffer[(0, 0)].style().fg, Some(Color::Indexed(1)));
assert_eq!(buffer[(2, 0)].symbol(), "I");
assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Indexed(171)));
assert_eq!(buffer[(4, 0)].symbol(), "B");
assert_eq!(buffer[(4, 0)].style().bg, Some(Color::Indexed(4)));
assert_eq!(buffer[(6, 0)].symbol(), "T");
assert_eq!(buffer[(6, 0)].style().fg, Some(Color::Rgb(1, 2, 3)));
}
#[test]
fn render_preserves_palette_background_fill_cells() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.terminal.write(b"\x1b[48;5;4m\x1b[K");
}
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();
for x in 0..20 {
assert_eq!(buffer[(x, 0)].symbol(), " ");
assert_eq!(buffer[(x, 0)].style().bg, Some(Color::Indexed(4)));
}
}
#[test]
fn render_preserves_rgb_background_fill_cells() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.terminal.write(b"\x1b[48;2;17;34;51m\x1b[K");
}
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();
for x in 0..20 {
assert_eq!(buffer[(x, 0)].symbol(), " ");
assert_eq!(buffer[(x, 0)].style().bg, Some(Color::Rgb(17, 34, 51)));
}
}
#[test]
fn process_pty_bytes_does_not_advertise_unsupported_glyph_protocol() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b_25a1;s\x1b\\", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_libghostty_query_responses_without_queuing_input() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[6n", &tx);
assert_eq!(result.terminal_responses.len(), 1);
assert!(String::from_utf8_lossy(&result.terminal_responses[0]).contains('R'));
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_xtgettcap_truecolor_query_responses_without_queuing_input() {
let (tx, mut rx) = mpsc::channel(8);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(
pane_id,
0,
b"\x1bP+q5463;524742;73657472676266;73657472676262\x1b\\",
&tx,
);
assert_eq!(
result.terminal_responses,
vec![
expected_xtgettcap_response("5463", None),
expected_xtgettcap_response("524742", Some(b"8")),
expected_xtgettcap_response("73657472676266", Some(b"\\E[38:2:%p1%d:%p2%d:%p3%dm")),
expected_xtgettcap_response("73657472676262", Some(b"\\E[48:2:%p1%d:%p2%d:%p3%dm")),
]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_split_xtgettcap_query_response() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q4", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b"D73\x1b", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx);
assert_eq!(
result.terminal_responses,
vec![expected_xtgettcap_response(
"4D73",
Some(b"\\E]52;%p1%s;%p2%s\\007")
)]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_orders_device_attribute_reply_before_following_xtgettcap_reply() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[c\x1bP+q5463\x1b\\", &tx);
assert_eq!(result.terminal_responses.len(), 2);
assert!(String::from_utf8_lossy(&result.terminal_responses[0]).contains('c'));
assert_eq!(
result.terminal_responses[1],
expected_xtgettcap_response("5463", None)
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_orders_xtgettcap_reply_before_following_device_attribute_reply() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5463\x1b\\\x1b[c", &tx);
assert_eq!(result.terminal_responses.len(), 2);
assert_eq!(
result.terminal_responses[0],
expected_xtgettcap_response("5463", None)
);
assert!(String::from_utf8_lossy(&result.terminal_responses[1]).contains('c'));
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_orders_xtgettcap_reply_before_following_default_color_reply() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 0x00,
g: 0x2b,
b: 0x36,
}),
});
let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5463\x1b\\\x1b]11;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![
expected_xtgettcap_response("5463", None),
Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"),
]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn host_theme_update_preserves_child_default_color_override() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;#112233\x07", &tx);
assert!(result.terminal_responses.is_empty());
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 0xaa,
g: 0xbb,
b: 0xcc,
}),
});
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]11;rgb:1111/2222/3333\x07")]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn child_default_color_reset_restores_cached_host_color() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.process_pty_bytes(pane_id, 0, b"\x1b]11;#112233\x07", &tx);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 0xaa,
g: 0xbb,
b: 0xcc,
}),
});
pane.process_pty_bytes(pane_id, 0, b"\x1b]111\x07", &tx);
assert!(!pane.has_transient_default_color_override());
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]11;rgb:aaaa/bbbb/cccc\x1b\\")]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_recovers_xtgettcap_after_osc_bel_terminator() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]0;title\x07\x1bP+q5463\x1b\\", &tx);
assert_eq!(
result.terminal_responses,
vec![expected_xtgettcap_response("5463", None)]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_ignores_unknown_and_unsupported_xtgettcap_queries() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q6E6F7065;4D7\x1b\\", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_underline_color_xtgettcap_query_responses() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(
pane_id,
0,
b"\x1bP+q5375;536D756C78;536574756C63\x1b\\",
&tx,
);
assert_eq!(
result.terminal_responses,
vec![
expected_xtgettcap_response("5375", None),
expected_xtgettcap_response("536D756C78", Some(b"\\E[4:%p1%dm")),
expected_xtgettcap_response(
"536574756C63",
Some(b"\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m")
),
]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn render_preserves_underline_color() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.terminal.write(b"\x1b[4m\x1b[58:2::17:34:51mU");
}
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 style = terminal.backend().buffer()[(0, 0)].style();
assert!(style.add_modifier.contains(Modifier::UNDERLINED));
assert_eq!(style.underline_color, Some(Color::Rgb(17, 34, 51)));
}
#[test]
fn dirty_patch_preserves_curly_underline_style() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
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 mut core = pane.core.lock().unwrap();
core.terminal.write(b"\x1b[4:3mU");
}
let patch = match pane.collect_dirty_patch(20, 5) {
TerminalDirtyPatchOutcome::Patch(patch) => patch,
other => panic!("expected dirty patch, got {other:?}"),
};
let cell = &patch.rows[0].1[0];
assert_eq!(cell.symbol, "U");
assert_eq!(
crate::protocol::underline_style_from_modifier(cell.modifier),
3
);
}
#[test]
fn full_frame_preserves_curly_underline_style() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
{
let mut core = pane.core.lock().unwrap();
core.terminal.write(b"\x1b[4:3mU");
}
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 frame =
crate::protocol::FrameData::from_ratatui_buffer(terminal.backend().buffer(), None);
assert_eq!(frame.cells[0].symbol, "U");
assert_eq!(
crate::protocol::underline_style_from_modifier(frame.cells[0].modifier),
3
);
}
#[test]
fn process_pty_bytes_orders_default_color_reply_before_following_device_attribute_reply() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 0x00,
g: 0x2b,
b: 0x36,
}),
});
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07\x1b[c", &tx);
assert_eq!(result.terminal_responses.len(), 2);
assert_eq!(
result.terminal_responses[0],
Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\")
);
assert!(String::from_utf8_lossy(&result.terminal_responses[1]).contains('c'));
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_palette_color_query_response_without_queuing_input() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let color = current_palette_color(&pane, 0);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]4;0;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![expected_osc_rgb_response("4;0", color)]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_split_palette_color_query_response() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let color = current_palette_color(&pane, 255);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]4;25", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b"5;?\x1b", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx);
assert_eq!(
result.terminal_responses,
vec![expected_osc_rgb_response("4;255", color)]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_ignores_malformed_and_preserves_multi_palette_queries() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result = pane.process_pty_bytes(
pane_id,
0,
b"\x1b]4;;?\x07\x1b]4;-1;?\x07\x1b]4;256;?\x07\x1b]4;0;?;1;?\x07\x1b]4;0;rgb:1111/2222/3333\x07",
&tx,
);
assert_eq!(result.terminal_responses.len(), 1);
assert!(result.terminal_responses[0].starts_with(b"\x1b]4;0;rgb:"));
assert_eq!(
result.terminal_responses[0]
.windows(4)
.filter(|window| *window == b"rgb:")
.count(),
2
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_orders_palette_reply_before_following_terminal_replies() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let color = current_palette_color(&pane, 0);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 0x00,
g: 0x2b,
b: 0x36,
}),
});
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]4;0;?\x07\x1b]11;?\x07\x1b[c", &tx);
assert_eq!(result.terminal_responses.len(), 3);
assert_eq!(
result.terminal_responses[0],
expected_osc_rgb_response("4;0", color)
);
assert_eq!(
result.terminal_responses[1],
Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\")
);
assert!(String::from_utf8_lossy(&result.terminal_responses[2]).contains('c'));
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_default_color_query_responses_without_queuing_input() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 0x00,
g: 0x2b,
b: 0x36,
}),
});
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\")]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_preserves_untracked_multi_color_query_responses() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0x65,
g: 0x7b,
b: 0x83,
}),
background: Some(crate::terminal_theme::RgbColor {
r: 0xfd,
g: 0xf6,
b: 0xe3,
}),
});
let palette = pane.process_pty_bytes(pane_id, 0, b"\x1b]4;0;?;1;?\x1b\\", &tx);
let palette_response = palette.terminal_responses.concat();
assert!(palette_response.starts_with(b"\x1b]4;0;rgb:"));
assert_eq!(
palette_response
.windows(4)
.filter(|window| *window == b"rgb:")
.count(),
2
);
let defaults = pane.process_pty_bytes(pane_id, 0, b"\x1b]10;?;?;?\x1b\\", &tx);
let default_response = defaults.terminal_responses.concat();
assert!(
default_response.starts_with(b"\x1b]10;rgb:"),
"unexpected default-color report: {:?}",
String::from_utf8_lossy(&default_response)
);
assert_eq!(
default_response
.windows(4)
.filter(|window| *window == b"rgb:")
.count(),
3
);
let core = pane.core.lock().unwrap();
assert!(!core.child_default_foreground_changed);
assert!(!core.child_default_background_changed);
drop(core);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_preserves_earlier_aggregate_palette_reply() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
let result =
pane.process_pty_bytes(pane_id, 0, b"\x1b]4;0;?;1;?\x1b\\\x1b]4;0;?\x1b\\", &tx);
assert_eq!(result.terminal_responses.len(), 2);
assert_eq!(
result.terminal_responses[0]
.windows(4)
.filter(|window| *window == b"rgb:")
.count(),
2
);
assert!(result.terminal_responses[1].starts_with(b"\x1b]4;0;rgb:"));
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_preserves_libghostty_reply_for_child_color_override() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.process_pty_bytes(pane_id, 0, b"\x1b]10;rgb:11/22/33\x07", &tx);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]10;?\x1b\\", &tx);
assert_eq!(result.terminal_responses.len(), 1);
assert!(result.terminal_responses[0].starts_with(b"\x1b]10;rgb:1111/2222/3333"));
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_tracks_later_multi_value_color_set() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.process_pty_bytes(pane_id, 0, b"\x1b]10;?;rgb:44/55/66\x1b\\", &tx);
let core = pane.core.lock().unwrap();
assert!(!core.child_default_foreground_changed);
assert!(core.child_default_background_changed);
}
#[test]
fn process_pty_bytes_returns_cursor_color_query_response_from_foreground_fallback() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0x65,
g: 0x7b,
b: 0x83,
}),
background: None,
});
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]12;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]12;rgb:6565/7b7b/8383\x1b\\")]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_cursor_color_query_response_from_child_foreground() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0x65,
g: 0x7b,
b: 0x83,
}),
background: None,
});
pane.process_pty_bytes(pane_id, 0, b"\x1b]10;rgb:11/22/33\x07", &tx);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]12;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]12;rgb:1111/2222/3333\x1b\\")]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_explicit_cursor_color_query_response() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0x65,
g: 0x7b,
b: 0x83,
}),
background: None,
});
pane.process_pty_bytes(pane_id, 0, b"\x1b]12;rgb:11/22/33\x07", &tx);
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]12;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]12;rgb:1111/2222/3333\x1b\\")]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_default_color_query_responses_in_order() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0x65,
g: 0x7b,
b: 0x83,
}),
background: Some(crate::terminal_theme::RgbColor {
r: 0xfd,
g: 0xf6,
b: 0xe3,
}),
});
let result =
pane.process_pty_bytes(pane_id, 0, b"\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![
Bytes::from_static(b"\x1b]10;rgb:6565/7b7b/8383\x1b\\"),
Bytes::from_static(b"\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\\"),
Bytes::from_static(b"\x1b]12;rgb:6565/7b7b/8383\x1b\\"),
]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_split_default_color_query_response() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 0xfd,
g: 0xf6,
b: 0xe3,
}),
});
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b";?\x1b", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\\")]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_returns_split_cursor_color_query_response() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0xfd,
g: 0xf6,
b: 0xe3,
}),
background: None,
});
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]12", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b";?\x1b", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]12;rgb:fdfd/f6f6/e3e3\x1b\\")]
);
assert!(rx.try_recv().is_err());
}
#[test]
fn process_pty_bytes_tracks_default_color_set_and_reset_before_replying() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
let pane_id = PaneId::from_raw(1);
pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
foreground: None,
background: Some(crate::terminal_theme::RgbColor {
r: 0xfd,
g: 0xf6,
b: 0xe3,
}),
});
let result =
pane.process_pty_bytes(pane_id, 0, b"\x1b]11;rgb:11/22/33\x07\x1b]11;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]11;rgb:1111/2222/3333\x07")]
);
assert!(rx.try_recv().is_err());
let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]111\x07\x1b]11;?\x07", &tx);
assert_eq!(
result.terminal_responses,
vec![Bytes::from_static(b"\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\\")]
);
assert!(rx.try_recv().is_err());
}
#[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().fg, Some(Color::Reset));
assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset));
assert_eq!(buffer[(2, 0)].symbol(), " ");
assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset));
assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset));
}
#[test]
fn render_keeps_explicit_default_foreground_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]10;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_fg = Some(Color::Rgb(0x44, 0x55, 0x66));
assert_eq!(buffer[(0, 0)].symbol(), "h");
assert_eq!(buffer[(0, 0)].style().fg, expected_fg);
assert_eq!(buffer[(2, 0)].symbol(), " ");
assert_eq!(buffer[(2, 0)].style().fg, expected_fg);
}
#[test]
fn render_keeps_explicit_default_background_when_it_differs_from_host() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let host_theme = crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0xaa,
g: 0xbb,
b: 0xcc,
}),
background: Some(crate::terminal_theme::RgbColor {
r: 0x11,
g: 0x22,
b: 0x33,
}),
};
pane.apply_host_terminal_theme(host_theme);
{
let mut core = pane.core.lock().unwrap();
core.terminal.write(b"\x1b]11;rgb:44/55/66\x1b\\hi");
}
let backend = ratatui::backend::TestBackend::new(20, 5);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
.unwrap();
let buffer = terminal.backend().buffer();
let expected_bg = Some(Color::Rgb(0x44, 0x55, 0x66));
assert_eq!(buffer[(0, 0)].symbol(), "h");
assert_eq!(buffer[(0, 0)].style().bg, expected_bg);
assert_eq!(buffer[(2, 0)].symbol(), " ");
assert_eq!(buffer[(2, 0)].style().bg, expected_bg);
}
#[test]
fn render_inverse_text_swaps_fg_and_resolved_bg_when_bg_is_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();
// SGR 7 enables inverse/reverse video
core.terminal.write(b"\x1b[7mhi\x1b[27m");
}
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 cell = &buffer[(0, 0)];
assert_eq!(cell.symbol(), "h");
// After inverse: fg should be the resolved bg, bg should be the original fg.
// fg must NOT be Color::Reset (which would be the same hue as bg).
assert_eq!(cell.style().fg, Some(Color::Rgb(0x11, 0x22, 0x33)));
assert_eq!(cell.style().bg, Some(Color::Rgb(0xaa, 0xbb, 0xcc)));
}
#[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()]);
}
}