diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /src/pane/terminal.rs | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'src/pane/terminal.rs')
| -rw-r--r-- | src/pane/terminal.rs | 311 |
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(); |
