perf: adopt libghostty integration improvements

refs #276
This commit is contained in:
Ogulcan Celik 2026-07-17 03:26:33 +03:00
parent 6f9ac7c108
commit 8d2af4e046
5 changed files with 337 additions and 173 deletions

View File

@ -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<VisibleText
pending_wrap = false;
}
let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
cells.push(VisibleTextCell {
byte_index,
ch,
@ -2252,7 +2251,7 @@ pub(crate) fn logical_cell_for_visible_cell(
visible_text_cells(text, pane_width)
.into_iter()
.find(|cell| {
let width = UnicodeWidthChar::width(cell.ch).unwrap_or(0) as u16;
let width = u16::from(crate::ghostty::unicode_codepoint_width(cell.ch as u32));
cell.screen_row == target_row
&& if width == 0 {
target_col == cell.screen_col
@ -2285,7 +2284,7 @@ fn text_cells(row: &str) -> Vec<TextCell> {
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()
}

View File

@ -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<u16> {
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<u16> {
}
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)

View File

@ -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<WritePtyCallback>,
#[derive(Default)]
struct TerminalCallbackState {
write_pty: Option<Box<WritePtyCallback>>,
pwd_changes: Vec<Vec<u8>>,
}
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::<TerminalCallbackState>()) };
let Some(callback) = state.write_pty.as_mut() else {
return;
}
let state = unsafe { &mut *(userdata.cast::<WritePtyCallbackState>()) };
};
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::<TerminalCallbackState>()) };
state.pwd_changes.push(bytes);
}
fn install_png_decoder_once() {
@ -556,6 +583,18 @@ fn decode_png_rgba(bytes: &[u8]) -> Option<DecodedPng> {
})
}
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<Vec<u8>, 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<Vec<u8>, Error> {
pub struct Terminal {
raw: ffi::GhosttyTerminal,
write_pty_callback: Option<Box<WritePtyCallbackState>>,
callback_state: Box<TerminalCallbackState>,
kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>,
kitty_empty_generation: Cell<Option<u64>>,
}
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::<c_void>();
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<Vec<u8>> {
mem::take(&mut self.callback_state.pwd_changes)
}
pub fn mode_get(&self, mode: u16) -> Result<bool, Error> {
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<u16, Error> {
self.get_u16(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLS)
}
@ -1153,6 +1212,30 @@ impl Terminal {
}
}
fn kitty_graphics(&self) -> Result<ffi::GhosttyKittyGraphics, Error> {
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<u64, Error> {
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<Vec<KittyImagePlacement>, 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<u64, Error> {
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::<u8>::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 _;

View File

@ -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<u8>,
pending: Vec<PathBuf>,
}
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<PathBuf> {
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<PathBuf> {
self.pending.drain(..).next_back()
}
}
fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> {
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
// -----------------------------------------------------------------------

View File

@ -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"