aboutsummaryrefslogtreecommitdiff
path: root/src/pane/terminal.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /src/pane/terminal.rs
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'src/pane/terminal.rs')
-rw-r--r--src/pane/terminal.rs311
1 files changed, 206 insertions, 105 deletions
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 1dc5a88d..071bb20a 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -16,6 +16,8 @@ use unicode_width::UnicodeWidthStr;
use crate::layout::PaneId;
use crate::protocol::CellData;
+#[cfg(test)]
+mod migration_tests;
#[cfg(windows)]
mod windows_recent_fallback;
@@ -35,7 +37,7 @@ use super::{
AgentOscStateTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker,
DefaultColorQuery, DefaultColorTrackedEvent, OscDebugTracker,
},
- xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
+ xtgettcap::{C1XtgettcapQueryTracker, C1XtgettcapResponse},
};
const DEFAULT_DETECTION_ROWS: usize = 24;
@@ -201,11 +203,11 @@ pub(crate) struct GhosttyPaneCore {
pub transient_default_color_owner_pgid: Option<u32>,
pub default_color_tracker: DefaultColorOscTracker,
pub default_color_event_tracker: DefaultColorEventTracker,
+ c1_xtgettcap_tracker: C1XtgettcapQueryTracker,
pub child_default_foreground_changed: bool,
pub child_default_background_changed: bool,
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,
@@ -1154,11 +1156,11 @@ impl GhosttyPaneTerminal {
transient_default_color_owner_pgid: None,
default_color_tracker: DefaultColorOscTracker::default(),
default_color_event_tracker: DefaultColorEventTracker::default(),
+ c1_xtgettcap_tracker: C1XtgettcapQueryTracker::default(),
child_default_foreground_changed: false,
child_default_background_changed: false,
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,
@@ -1384,19 +1386,18 @@ impl GhosttyPaneTerminal {
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.c1_xtgettcap_tracker.observe(filtered_bytes.as_ref());
+ let c1_xtgettcap_responses = core.c1_xtgettcap_tracker.drain_pending();
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,
+ c1_xtgettcap_responses,
&mut terminal_responses,
);
let terminal_bells = core.terminal.take_bell_count();
@@ -1470,25 +1471,32 @@ impl GhosttyPaneTerminal {
bytes: &[u8],
default_color_events: Vec<DefaultColorTrackedEvent>,
in_progress_default_color_event: Option<DefaultColorEvent>,
- xtgettcap_responses: Vec<XtgettcapResponse>,
+ c1_xtgettcap_responses: Vec<C1XtgettcapResponse>,
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);
-
+ // Only legacy C1 replies need merging; ordinary XTGETTCAP remains native.
+ let mut events: Vec<_> = default_color_events
+ .into_iter()
+ .map(OrderedColorOrC1Event::Color)
+ .chain(
+ c1_xtgettcap_responses
+ .into_iter()
+ .map(OrderedColorOrC1Event::C1),
+ )
+ .collect();
+ events.sort_by_key(OrderedColorOrC1Event::end_offset);
let mut written = 0;
for event in events {
let end_offset = event.end_offset().min(bytes.len());
+ if matches!(&event, OrderedColorOrC1Event::C1(response) if response.suppress_native) {
+ // Suppress only the unhook byte's replies, not earlier native queries.
+ let prefix_end = end_offset.saturating_sub(1).max(written);
+ if prefix_end > written {
+ core.terminal.write(&bytes[written..prefix_end]);
+ terminal_responses.extend(self.drain_pending_pty_responses());
+ written = prefix_end;
+ }
+ }
let mut libghostty_responses = Vec::new();
if end_offset > written {
core.terminal.write(&bytes[written..end_offset]);
@@ -1496,7 +1504,7 @@ impl GhosttyPaneTerminal {
written = end_offset;
}
match event {
- OrderedPtyResponseEvent::DefaultColor(event) => {
+ OrderedColorOrC1Event::Color(event) => {
let replacement = respond_to_default_color_event(core, event.event);
if replacement.is_some() {
remove_last_matching_libghostty_color_reply(
@@ -1507,9 +1515,16 @@ impl GhosttyPaneTerminal {
terminal_responses.extend(libghostty_responses);
terminal_responses.extend(replacement);
}
- OrderedPtyResponseEvent::Xtgettcap(response) => {
+ OrderedColorOrC1Event::C1(response) => {
+ if response.suppress_native {
+ libghostty_responses.retain(|reply| {
+ !reply.starts_with(b"\x1bP1+r") && !reply.starts_with(b"\x1bP0+r")
+ });
+ }
terminal_responses.extend(libghostty_responses);
- terminal_responses.push(response.bytes);
+ if !response.suppress_native {
+ terminal_responses.push(response.bytes);
+ }
}
}
}
@@ -1648,6 +1663,8 @@ impl GhosttyPaneTerminal {
cell_height_px: u32,
) -> Vec<Bytes> {
if let Ok(mut core) = self.core.lock() {
+ #[cfg(windows)]
+ windows_recent_fallback::refresh_if_needed(&mut core);
let offset_from_bottom = core
.terminal
.scrollbar()
@@ -1675,6 +1692,12 @@ impl GhosttyPaneTerminal {
None
};
+ #[cfg(windows)]
+ if core.recent_fallback.usable {
+ // Track the old viewport's start without pinning trailing blank rows.
+ core.terminal.track_row(0);
+ }
+
let _ = core
.terminal
.resize(cols, rows, cell_width_px, cell_height_px);
@@ -2409,21 +2432,17 @@ 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 cursor = render_state.cursor().ok()?;
+ let viewport = cursor.viewport?;
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)
+ decscusr_cursor_shape(cursor.visual_style, cursor.blinking)
} else {
0
};
Some(TerminalCursorState {
- x: cursor.x,
- y: cursor.y,
- visible: render_state.cursor_visible().ok()?,
+ x: viewport.x,
+ y: viewport.y,
+ visible: cursor.visible,
shape,
})
}
@@ -2431,6 +2450,9 @@ fn cursor_state_from_render_state(
type VisibleHyperlinks = Vec<((u16, u16), String, String)>;
fn ghostty_clear_render_dirty(render_state: &mut crate::ghostty::RenderState, area_height: u16) {
+ if render_state.rows().is_ok_and(|rows| area_height >= rows) && render_state.clean().is_ok() {
+ return;
+ }
let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else {
return;
};
@@ -2492,16 +2514,11 @@ fn ghostty_collect_dirty_patch(
if render_state.update(terminal).is_err() {
fallback!("render_state_update_error");
}
- let collect_all_rows = match render_state.dirty() {
+ match render_state.dirty() {
Ok(crate::ghostty::Dirty::Clean) => finish!(TerminalDirtyPatchOutcome::Clean),
- Ok(crate::ghostty::Dirty::Partial) => false,
- // A full dirty state means that every visible row may have changed. It
- // is still safe to send this as a bounded patch: the client replaces
- // only this pane's viewport, rather than falling back to the whole
- // shell surface.
- Ok(crate::ghostty::Dirty::Full) => true,
+ Ok(crate::ghostty::Dirty::Partial | crate::ghostty::Dirty::Full) => {}
Err(_) => fallback!("dirty_read_error"),
- };
+ }
let colors = render_state.colors().ok();
let default_bg = colors
@@ -2527,77 +2544,73 @@ fn ghostty_collect_dirty_patch(
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");
+ while let Some(y) = rows.next_dirty() {
+ if y >= area_height {
+ break;
+ }
+ 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");
};
- if collect_all_rows || 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");
};
- 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,
- palette_overrides.as_ref(),
- );
- 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;
+ if basic.has_hyperlink {
+ fallback!("hyperlink_present");
}
- patch_rows.push((y, patch_cells));
+ let style = ghostty_cell_style(
+ &cells,
+ &basic,
+ default_fg,
+ default_bg,
+ resolved_fg,
+ resolved_bg,
+ palette_overrides.as_ref(),
+ );
+ 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;
}
- y += 1;
+ while x < area_width {
+ patch_cells.push(blank_cell_data(default_fg, default_bg));
+ x += 1;
+ }
+ patch_rows.push((y, patch_cells));
}
// Nothing above mutates dirty state. Only clear it after every row has
// been collected successfully, so a safety fallback leaves the next
// collection with the same information.
- let dirty_ys: std::collections::HashSet<u16> = patch_rows.iter().map(|(row, _)| *row).collect();
- if !dirty_ys.is_empty() {
+ if !patch_rows.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() {
+ while let Some(y) = clear_rows.next_dirty() {
+ if y >= area_height {
+ break;
+ }
+ if clear_rows.clear_dirty().is_err() {
fallback!("clear_dirty_error");
}
- clear_y += 1;
}
}
if render_state
@@ -3182,17 +3195,16 @@ fn ghostty_cell_style(
style.add_modifier(modifiers)
}
-#[derive(Debug)]
-enum OrderedPtyResponseEvent {
- DefaultColor(DefaultColorTrackedEvent),
- Xtgettcap(XtgettcapResponse),
+enum OrderedColorOrC1Event {
+ Color(DefaultColorTrackedEvent),
+ C1(C1XtgettcapResponse),
}
-impl OrderedPtyResponseEvent {
+impl OrderedColorOrC1Event {
fn end_offset(&self) -> usize {
match self {
- Self::DefaultColor(event) => event.end_offset,
- Self::Xtgettcap(response) => response.end_offset,
+ Self::Color(event) => event.end_offset,
+ Self::C1(response) => response.end_offset,
}
}
}
@@ -5921,6 +5933,55 @@ mod tests {
}
#[test]
+ fn process_pty_bytes_returns_fragmented_c1_xtgettcap_once_in_order() {
+ for query in [
+ b"\x90+q5463;524742\x9c".as_slice(),
+ b"\x1bP+q5463;524742\x9c".as_slice(),
+ b"\x90+q5463;524742\x1b\\".as_slice(),
+ ] {
+ for fragmented in [false, true] {
+ 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 {
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0,
+ g: 0x2b,
+ b: 0x36,
+ }),
+ ..Default::default()
+ });
+ let mut replies = pane
+ .process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx)
+ .terminal_responses;
+ for chunk in query.chunks(if fragmented { 1 } else { query.len() }) {
+ replies.extend(
+ pane.process_pty_bytes(pane_id, 0, chunk, &tx)
+ .terminal_responses,
+ );
+ }
+ replies.extend(
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x1b\\\x1bP+q5375\x1b\\", &tx)
+ .terminal_responses,
+ );
+ assert_eq!(
+ replies,
+ vec![
+ Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"),
+ expected_xtgettcap_response("5463", None),
+ expected_xtgettcap_response("524742", Some(b"8")),
+ Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"),
+ expected_xtgettcap_response("5375", None),
+ ],
+ "query={query:?}, fragmented={fragmented}"
+ );
+ 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();
@@ -5930,10 +5991,12 @@ mod tests {
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);
+ let result = pane.process_pty_bytes(pane_id, 0, b"d73", &tx);
assert!(result.terminal_responses.is_empty());
assert!(rx.try_recv().is_err());
- let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx);
+ // libghostty unhooks DCS on ESC, before the final ST backslash.
+ // Splitting ST must not lose the reply or emit it again on completion.
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1b", &tx);
assert_eq!(
result.terminal_responses,
@@ -5943,6 +6006,9 @@ mod tests {
)]
);
assert!(rx.try_recv().is_err());
+ let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx);
+ assert!(result.terminal_responses.is_empty());
+ assert!(rx.try_recv().is_err());
}
#[test]
@@ -6082,6 +6148,41 @@ mod tests {
}
#[test]
+ fn process_pty_bytes_orders_default_color_reset_reply_before_xtgettcap() {
+ 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 {
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x00,
+ g: 0x2b,
+ b: 0x36,
+ }),
+ ..Default::default()
+ });
+
+ let result = pane.process_pty_bytes(
+ pane_id,
+ 0,
+ b"\x1b]11;#112233\x07\x1b]111\x07\x1b]11;?\x1b",
+ &tx,
+ );
+ assert!(result.terminal_responses.is_empty());
+ let result = pane.process_pty_bytes(pane_id, 0, b"\\\x1bP+q436f\x1b\\", &tx);
+
+ assert_eq!(
+ result.terminal_responses,
+ vec![
+ Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"),
+ // Co is provided by the native map, beyond our old eight keys.
+ expected_xtgettcap_response("436F", Some(b"256")),
+ ]
+ );
+ 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();