From 8d2af4e046c2a95dfc54aafd6f88da12389d76e8 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 17 Jul 2026 03:26:33 +0300 Subject: [PATCH] perf: adopt libghostty integration improvements refs #276 --- src/app/actions.rs | 9 +- src/app/input/copy_mode.rs | 10 +- src/ghostty/mod.rs | 258 ++++++++++++++++++++++++++++++++----- src/pane/osc.rs | 123 +++--------------- src/pane/terminal.rs | 110 ++++++++++++---- 5 files changed, 337 insertions(+), 173 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index 68286952..3fedeeb3 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -11,7 +11,6 @@ use crate::layout::{find_in_direction, NavDirection}; use crate::selection::Selection; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; -use unicode_width::UnicodeWidthChar; use super::state::{ text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, @@ -2220,7 +2219,7 @@ pub(crate) fn visible_text_cells(text: &str, pane_width: u16) -> Vec Vec { let mut next_col = 0u16; row.chars() .map(|ch| { - let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); let start_col = if width == 0 { next_col.saturating_sub(1) } else { @@ -3190,7 +3189,7 @@ mod tests { let prefix = &row[..byte_idx]; prefix .chars() - .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16) + .map(|ch| u16::from(crate::ghostty::unicode_codepoint_width(ch as u32))) .sum() } diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index f6c322ea..e6c0d074 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -1,5 +1,4 @@ use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; -use unicode_width::UnicodeWidthChar; use crate::{ app::{ @@ -941,7 +940,7 @@ fn last_character_col(text: &str) -> Option { let mut col = 0u16; let mut last_col = None; for ch in text.chars() { - let width = UnicodeWidthChar::width(ch).unwrap_or(1) as u16; + let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); if width > 0 { last_col = Some(col); col = col.saturating_add(width); @@ -951,7 +950,7 @@ fn last_character_col(text: &str) -> Option { } fn char_cell_width(ch: char) -> u16 { - UnicodeWidthChar::width(ch).unwrap_or(1).max(1) as u16 + u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1) } fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { @@ -1703,6 +1702,11 @@ mod tests { submit_copy_search(&mut app, '?', "000000"); assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!( + copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), + 0 + ); let runtime = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index d2213c37..a664ee05 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -10,6 +10,7 @@ )] pub mod bindings; +use std::cell::Cell; use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; use std::ffi::c_void; @@ -442,8 +443,10 @@ impl CellWide { type WritePtyCallback = dyn FnMut(&[u8]) + Send; -struct WritePtyCallbackState { - callback: Box, +#[derive(Default)] +struct TerminalCallbackState { + write_pty: Option>, + pwd_changes: Vec>, } unsafe extern "C" fn write_pty_trampoline( @@ -452,19 +455,43 @@ unsafe extern "C" fn write_pty_trampoline( data: *const u8, len: usize, ) { - if userdata.is_null() { + if userdata.is_null() || (data.is_null() && len != 0) { return; } - if data.is_null() && len != 0 { + let state = unsafe { &mut *(userdata.cast::()) }; + let Some(callback) = state.write_pty.as_mut() else { return; - } - let state = unsafe { &mut *(userdata.cast::()) }; + }; let bytes = if len == 0 { &[] } else { unsafe { slice::from_raw_parts(data, len) } }; - (state.callback)(bytes); + callback(bytes); +} + +unsafe extern "C" fn pwd_changed_trampoline(terminal: ffi::GhosttyTerminal, userdata: *mut c_void) { + if terminal.is_null() || userdata.is_null() { + return; + } + let mut pwd = ffi::GhosttyString::default(); + let result = unsafe { + ffi::ghostty_terminal_get( + terminal, + ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_PWD, + (&mut pwd as *mut ffi::GhosttyString).cast(), + ) + }; + if result != ffi::GhosttyResult_GHOSTTY_SUCCESS || (pwd.ptr.is_null() && pwd.len != 0) { + return; + } + let bytes = if pwd.len == 0 { + Vec::new() + } else { + unsafe { slice::from_raw_parts(pwd.ptr, pwd.len) }.to_vec() + }; + let state = unsafe { &mut *(userdata.cast::()) }; + state.pwd_changes.push(bytes); } fn install_png_decoder_once() { @@ -556,6 +583,18 @@ fn decode_png_rgba(bytes: &[u8]) -> Option { }) } +pub fn unicode_codepoint_width(codepoint: u32) -> u8 { + unsafe { ffi::ghostty_unicode_codepoint_width(codepoint) } +} + +pub fn unicode_grapheme_width(codepoints: &[u32]) -> (usize, u8) { + let mut width = 0u8; + let consumed = unsafe { + ffi::ghostty_unicode_grapheme_width(codepoints.as_ptr(), codepoints.len(), &mut width) + }; + (consumed, width) +} + pub fn encode_focus(event: FocusEvent) -> Result, Error> { let mut required = 0usize; // SAFETY: null buffer + out len is the documented way to query required size. @@ -582,8 +621,9 @@ pub fn encode_focus(event: FocusEvent) -> Result, Error> { pub struct Terminal { raw: ffi::GhosttyTerminal, - write_pty_callback: Option>, + callback_state: Box, kitty_fingerprints: Mutex>, + kitty_empty_generation: Cell>, } impl Terminal { @@ -599,13 +639,27 @@ impl Terminal { ffi::ghostty_terminal_new(ptr::null(), &mut raw, options).into_result()?; } - let terminal = Self { + let mut terminal = Self { raw, - write_pty_callback: None, + callback_state: Box::default(), kitty_fingerprints: Mutex::new(HashMap::new()), + kitty_empty_generation: Cell::new(None), }; + let userdata = (&mut *terminal.callback_state as *mut TerminalCallbackState).cast(); let glyph_protocol = false; unsafe { + ffi::ghostty_terminal_set( + terminal.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_USERDATA, + userdata, + ) + .into_result()?; + ffi::ghostty_terminal_set( + terminal.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_PWD_CHANGED, + (pwd_changed_trampoline as *const ()).cast(), + ) + .into_result()?; ffi::ghostty_terminal_set( terminal.raw, ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL, @@ -688,17 +742,7 @@ impl Terminal { where F: FnMut(&[u8]) + Send + 'static, { - let mut state = Box::new(WritePtyCallbackState { - callback: Box::new(callback), - }); - let userdata = (&mut *state as *mut WritePtyCallbackState).cast::(); unsafe { - ffi::ghostty_terminal_set( - self.raw, - ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_USERDATA, - userdata.cast(), - ) - .into_result()?; ffi::ghostty_terminal_set( self.raw, ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_WRITE_PTY, @@ -706,10 +750,14 @@ impl Terminal { ) .into_result()?; } - self.write_pty_callback = Some(state); + self.callback_state.write_pty = Some(Box::new(callback)); Ok(()) } + pub fn take_pwd_changes(&mut self) -> Vec> { + mem::take(&mut self.callback_state.pwd_changes) + } + pub fn mode_get(&self, mode: u16) -> Result { let mut out = false; unsafe { ffi::ghostty_terminal_mode_get(self.raw, mode, &mut out).into_result()? }; @@ -1072,6 +1120,17 @@ impl Terminal { } } + pub fn scroll_viewport_row(&mut self, row: usize) { + let viewport = ffi::GhosttyTerminalScrollViewport { + tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_ROW, + value: ffi::GhosttyTerminalScrollViewportValue { row }, + }; + // SAFETY: self.raw is valid and viewport value matches the tag. + unsafe { + ffi::ghostty_terminal_scroll_viewport(self.raw, viewport); + } + } + pub fn cols(&self) -> Result { self.get_u16(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLS) } @@ -1153,6 +1212,30 @@ impl Terminal { } } + fn kitty_graphics(&self) -> Result { + let mut graphics: ffi::GhosttyKittyGraphics = ptr::null_mut(); + unsafe { + ffi::ghostty_terminal_get( + self.raw, + ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, + (&mut graphics as *mut ffi::GhosttyKittyGraphics).cast(), + ) + .into_result()?; + } + Ok(graphics) + } + + pub fn kitty_graphics_generation(&self) -> Result { + let graphics = self.kitty_graphics()?; + if graphics.is_null() { + return Ok(0); + } + kitty_graphics_u64( + graphics, + ffi::GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, + ) + } + pub fn kitty_image_placements(&self) -> Result, Error> { self.kitty_image_placements_with_data_filter(|_| true) } @@ -1164,18 +1247,17 @@ impl Terminal { where F: FnMut(KittyImageDescriptor) -> bool, { - let mut graphics: ffi::GhosttyKittyGraphics = ptr::null_mut(); - unsafe { - ffi::ghostty_terminal_get( - self.raw, - ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, - (&mut graphics as *mut ffi::GhosttyKittyGraphics).cast(), - ) - .into_result()?; - } + let graphics = self.kitty_graphics()?; if graphics.is_null() { return Ok(Vec::new()); } + let generation = kitty_graphics_u64( + graphics, + ffi::GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, + )?; + if generation == 0 || self.kitty_empty_generation.get() == Some(generation) { + return Ok(Vec::new()); + } let mut iterator: ffi::GhosttyKittyGraphicsPlacementIterator = ptr::null_mut(); unsafe { @@ -1191,13 +1273,21 @@ impl Terminal { let _guard = KittyPlacementIteratorGuard { raw: iterator }; let mut placements = Vec::new(); + let mut storage_has_placements = false; while unsafe { ffi::ghostty_kitty_graphics_placement_next(iterator) } { + storage_has_placements = true; if let Some(placement) = self.kitty_image_placement(graphics, iterator, &mut needs_data)? { placements.push(placement); } } + if !storage_has_placements { + self.kitty_empty_generation.set(Some(generation)); + self.prune_kitty_fingerprints(&[]); + return Ok(Vec::new()); + } + placements.extend(self.kitty_virtual_image_placements(graphics, &mut needs_data)?); placements.sort_by_key(|placement| placement.z); self.prune_kitty_fingerprints(&placements); @@ -1890,6 +1980,18 @@ impl KittyVirtualRun { } } +fn kitty_graphics_u64( + graphics: ffi::GhosttyKittyGraphics, + data: ffi::GhosttyKittyGraphicsData, +) -> Result { + let mut out = 0u64; + unsafe { + ffi::ghostty_kitty_graphics_get(graphics, data, (&mut out as *mut u64).cast()) + .into_result()?; + } + Ok(out) +} + fn kitty_image_u32( image: ffi::GhosttyKittyGraphicsImage, data: ffi::GhosttyKittyGraphicsImageData, @@ -2984,6 +3086,48 @@ mod tests { ); } + #[test] + fn kitty_storage_generation_skips_only_proven_empty_storage() { + let mut terminal = Terminal::new(10, 5, 1_000_000).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.resize(10, 5, 8, 16).unwrap(); + + assert_eq!(terminal.kitty_graphics_generation().unwrap(), 0); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + + terminal.write(b"\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"); + let transmitted = terminal.kitty_graphics_generation().unwrap(); + assert_ne!(transmitted, 0); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + assert_eq!(terminal.kitty_empty_generation.get(), Some(transmitted)); + + terminal.write(b"plain text"); + assert_eq!(terminal.kitty_graphics_generation().unwrap(), transmitted); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + + terminal.write(b"\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"); + let placed = terminal.kitty_graphics_generation().unwrap(); + assert_ne!(placed, transmitted); + assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1); + + terminal.resize(10, 5, 12, 24).unwrap(); + assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed); + assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1); + + write_numbered_lines(&mut terminal, 20); + assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + assert_ne!(terminal.kitty_empty_generation.get(), Some(placed)); + terminal.scroll_viewport_row(0); + assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1); + + terminal.write(b"\x1b_Ga=d,d=A\x1b\\"); + let deleted = terminal.kitty_graphics_generation().unwrap(); + assert_ne!(deleted, placed); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + assert_eq!(terminal.kitty_empty_generation.get(), Some(deleted)); + } + #[test] fn build_info_contract_matches_expected_vendored_features() { let _simd = build_info_bool(ffi::GhosttyBuildInfo_GHOSTTY_BUILD_INFO_SIMD); @@ -3096,6 +3240,38 @@ mod tests { assert_eq!(placements[0].render.grid_rows, 1); } + #[test] + fn unicode_width_helpers_match_terminal_layout_rules() { + assert_eq!(unicode_codepoint_width('A' as u32), 1); + assert_eq!(unicode_codepoint_width('\u{301}' as u32), 0); + assert_eq!(unicode_codepoint_width('界' as u32), 2); + assert_eq!(unicode_codepoint_width(0x11_0000), 1); + + let cases: &[(&[u32], usize, u8)] = &[ + (&[], 0, 0), + (&['e' as u32, '\u{301}' as u32], 2, 1), + (&['⚠' as u32, '\u{fe0f}' as u32], 2, 2), + (&['⚠' as u32, '\u{fe0e}' as u32], 2, 1), + (&['🇧' as u32, '🇷' as u32], 2, 2), + (&['👍' as u32, '🏽' as u32], 2, 2), + ( + &[ + '👨' as u32, + '\u{200d}' as u32, + '👩' as u32, + '\u{200d}' as u32, + '👧' as u32, + ], + 5, + 2, + ), + (&[0x11_0000, 'A' as u32], 1, 1), + ]; + for &(codepoints, consumed, width) in cases { + assert_eq!(unicode_grapheme_width(codepoints), (consumed, width)); + } + } + #[test] fn focus_encoding_matches_expected_sequences() { assert_eq!(encode_focus(FocusEvent::Gained).unwrap(), b"\x1b[I"); @@ -3103,7 +3279,7 @@ mod tests { } #[test] - fn write_pty_callback_receives_terminal_query_responses() { + fn terminal_callbacks_report_pty_responses_and_pwd_changes() { let mut terminal = Terminal::new(8, 3, 100).unwrap(); let responses = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); let sink = responses.clone(); @@ -3111,11 +3287,12 @@ mod tests { .set_write_pty_callback(move |bytes| sink.lock().unwrap().extend_from_slice(bytes)) .unwrap(); - terminal.write(b"\x1b[6n"); + terminal.write(b"\x1b[6n\x1b]7;file:///tmp/herdr\x07"); let output = responses.lock().unwrap().clone(); assert!(!output.is_empty()); assert!(String::from_utf8_lossy(&output).contains("R")); + assert_eq!(terminal.take_pwd_changes(), [b"file:///tmp/herdr".to_vec()]); } #[test] @@ -3226,6 +3403,23 @@ mod tests { assert_eq!(after.len, before.len); } + #[test] + fn absolute_scroll_row_round_trips_and_clamps() { + let mut terminal = Terminal::new(80, 3, 1_000_000).unwrap(); + write_numbered_lines(&mut terminal, 1000); + + let before = terminal.scrollbar().unwrap(); + let max_row = before.total.saturating_sub(before.len); + assert!(max_row > 0); + + for row in [0, max_row / 2, max_row, usize::MAX] { + terminal.scroll_viewport_row(row); + let after = terminal.scrollbar().unwrap(); + assert_eq!(after.offset, row.min(max_row)); + assert_eq!(after.len, before.len); + } + } + #[test] fn deep_scrollback_resize_preserves_unicode_and_hyperlinks() { use std::fmt::Write as _; diff --git a/src/pane/osc.rs b/src/pane/osc.rs index f7cf9eb9..22e76ff1 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -396,84 +396,13 @@ impl Osc52Forwarder { } } -/// Reconstructs cwd-reporting OSC sequences from child output. Shell -/// integrations commonly use OSC 7 (`file://...`), while Windows Terminal -/// documents OSC 9;9 for the same practical purpose. -#[derive(Debug, Default)] -pub(super) struct CwdOscTracker { - state: Osc52ForwarderState, - body: Vec, - pending: Vec, -} - -impl CwdOscTracker { - pub(super) fn observe(&mut self, bytes: &[u8]) { - for &byte in bytes { - match self.state { - Osc52ForwarderState::Ground => { - if byte == 0x1b { - self.state = Osc52ForwarderState::Escape; - } - } - Osc52ForwarderState::Escape => { - if byte == b']' { - self.body.clear(); - self.state = Osc52ForwarderState::OscBody; - } else if byte == 0x1b { - self.state = Osc52ForwarderState::Escape; - } else { - self.state = Osc52ForwarderState::Ground; - } - } - Osc52ForwarderState::OscBody => match byte { - 0x07 => { - self.finalize(); - self.state = Osc52ForwarderState::Ground; - } - 0x1b => self.state = Osc52ForwarderState::OscEscape, - _ => self.body.push(byte), - }, - Osc52ForwarderState::OscEscape => { - if byte == b'\\' { - self.finalize(); - self.state = Osc52ForwarderState::Ground; - } else { - self.body.push(0x1b); - self.body.push(byte); - self.state = Osc52ForwarderState::OscBody; - } - } - } - - if self.body.len() > 4096 { - self.body.clear(); - self.state = Osc52ForwarderState::Ground; - } - } +pub(super) fn parse_reported_cwd(value: &[u8]) -> Option { + let value = std::str::from_utf8(value).ok()?.trim(); + if value.starts_with("file://") { + return parse_file_uri_cwd(value); } - - fn finalize(&mut self) { - if let Some(cwd) = parse_cwd_osc(&self.body) { - self.pending.push(cwd); - } - self.body.clear(); - } - - pub(super) fn drain_latest(&mut self) -> Option { - self.pending.drain(..).next_back() - } -} - -fn parse_cwd_osc(body: &[u8]) -> Option { - let body = std::str::from_utf8(body).ok()?; - if let Some(uri) = body.strip_prefix("7;") { - return parse_file_uri_cwd(uri); - } - if let Some(path) = body.strip_prefix("9;9;") { - let path = path.trim().trim_matches('"'); - return (!path.is_empty()).then(|| PathBuf::from(path)); - } - None + let path = value.trim_matches('"'); + (!path.is_empty()).then(|| PathBuf::from(path)) } /// Maximum retained string length for agent OSC title and progress payloads. @@ -1077,46 +1006,28 @@ mod tests { } #[test] - fn cwd_osc_tracker_detects_split_osc7_sequence() { - let mut tracker = CwdOscTracker::default(); - - tracker.observe(b"\x1b]7;file:///tmp/herdr%20repo"); - assert_eq!(tracker.drain_latest(), None); - tracker.observe(b"\x07"); - + fn reported_cwd_parses_file_uri_and_bare_paths() { assert_eq!( - tracker.drain_latest(), + parse_reported_cwd(b"file:///tmp/herdr%20repo"), Some(std::path::PathBuf::from("/tmp/herdr repo")) ); - } - - #[test] - fn cwd_osc_tracker_detects_windows_terminal_cwd_sequence() { - let mut tracker = CwdOscTracker::default(); - - tracker.observe(b"\x1b]9;9;C:\\Users\\herdr\\src\\herdr\x1b\\"); - assert_eq!( - tracker.drain_latest(), + parse_reported_cwd(b"C:\\Users\\herdr\\src\\herdr"), Some(std::path::PathBuf::from("C:\\Users\\herdr\\src\\herdr")) ); - } - - // The quoted form is what Windows Terminal's documented shell integration - // snippet emits. Herdr's own injected prompt integration deliberately - // emits the path unquoted; see WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND. - #[test] - fn cwd_osc_tracker_detects_quoted_powershell_prompt_cwd_sequence() { - let mut tracker = CwdOscTracker::default(); - - tracker.observe(b"PS C:\\my proj> \x1b]9;9;\"C:\\my proj\"\x1b\\"); - assert_eq!( - tracker.drain_latest(), + parse_reported_cwd(b"\"C:\\my proj\""), Some(std::path::PathBuf::from("C:\\my proj")) ); } + #[test] + fn reported_cwd_rejects_invalid_or_empty_values() { + assert_eq!(parse_reported_cwd(b""), None); + assert_eq!(parse_reported_cwd(b"\xff"), None); + assert_eq!(parse_reported_cwd(b"file://remote/tmp"), None); + } + // ----------------------------------------------------------------------- // AgentOscStateTracker tests // ----------------------------------------------------------------------- diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index cf7a82f4..8e33b1b3 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -28,10 +28,10 @@ use super::{ kitty_keyboard::KittyKeyboardTracker, osc::{ contains_scrollback_clear_sequence, current_transient_default_color_owner, - maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, - write_host_terminal_theme_selective, AgentOscStateTracker, CwdOscTracker, - DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, - DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker, + 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}, }; @@ -162,7 +162,6 @@ pub(crate) struct GhosttyPaneCore { pub child_default_foreground_changed: bool, pub child_default_background_changed: bool, pub osc52_forwarder: Osc52Forwarder, - pub cwd_osc_tracker: CwdOscTracker, pub osc_debug_tracker: OscDebugTracker, pub agent_osc_state: AgentOscStateTracker, pub xtgettcap_query_tracker: XtgettcapQueryTracker, @@ -917,7 +916,6 @@ impl GhosttyPaneTerminal { child_default_foreground_changed: false, child_default_background_changed: false, osc52_forwarder: Osc52Forwarder::default(), - cwd_osc_tracker: CwdOscTracker::default(), osc_debug_tracker: OscDebugTracker::default(), agent_osc_state: AgentOscStateTracker::default(), xtgettcap_query_tracker: XtgettcapQueryTracker::default(), @@ -1050,6 +1048,7 @@ impl GhosttyPaneTerminal { }; }; + 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) { @@ -1063,8 +1062,6 @@ impl GhosttyPaneTerminal { core.osc52_forwarder.observe(bytes); let clipboard_writes = core.osc52_forwarder.drain_pending(); - core.cwd_osc_tracker.observe(bytes); - let reported_cwd = core.cwd_osc_tracker.drain_latest(); core.osc_debug_tracker.observe(bytes); for event in core.osc_debug_tracker.drain_pending() { debug!( @@ -1119,6 +1116,12 @@ impl GhosttyPaneTerminal { 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); @@ -1406,7 +1409,7 @@ impl GhosttyPaneTerminal { core.terminal.write(ansi.as_bytes()); } } - ghostty_restore_scroll_offset_from_bottom(&mut core.terminal, offset_from_bottom); + 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 @@ -1444,10 +1447,7 @@ impl GhosttyPaneTerminal { pub fn set_scroll_offset_from_bottom(&self, lines: usize) { if let Ok(mut core) = self.core.lock() { - core.terminal.scroll_viewport_bottom(); - if lines > 0 { - core.terminal.scroll_viewport_delta(-(lines as isize)); - } + ghostty_set_scroll_offset_from_bottom(&mut core.terminal, lines); } } @@ -2381,22 +2381,16 @@ fn ghostty_recent_read_range( Ok(Some((start, end, cols))) } -fn ghostty_restore_scroll_offset_from_bottom( +fn ghostty_set_scroll_offset_from_bottom( terminal: &mut crate::ghostty::Terminal, offset_from_bottom: usize, ) { - terminal.scroll_viewport_bottom(); - if offset_from_bottom == 0 { - return; - } let Ok(scrollbar) = terminal.scrollbar() else { + terminal.scroll_viewport_bottom(); return; }; let max_offset = scrollbar.total.saturating_sub(scrollbar.len); - let offset = offset_from_bottom.min(max_offset).min(isize::MAX as usize) as isize; - if offset > 0 { - terminal.scroll_viewport_delta(-offset); - } + terminal.scroll_viewport_row(max_offset.saturating_sub(offset_from_bottom)); } fn ghostty_extract_selection( @@ -3188,6 +3182,52 @@ mod tests { 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() { @@ -4125,7 +4165,7 @@ mod tests { } #[test] - fn pane_scrollback_controls_reach_top_without_ui_interference() { + 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); @@ -4135,10 +4175,20 @@ mod tests { assert!(before.max_offset_from_bottom > 0); assert_eq!(before.offset_from_bottom, 0); - pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom); + 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) + ); + } - let after = pane.scroll_metrics().expect("scroll metrics after scroll"); - assert_eq!(after.offset_from_bottom, after.max_offset_from_bottom); assert!(pane.visible_text().contains("000000")); } @@ -4270,11 +4320,17 @@ mod tests { 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!(metrics.offset_from_bottom <= metrics.max_offset_from_bottom); + 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"