aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 03:26:33 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 03:26:33 +0300
commit8d2af4e046c2a95dfc54aafd6f88da12389d76e8 (patch)
tree5643a1ec2f04b150d465817f2896ada4f2fbc46b /src/pane
parent6f9ac7c108e22510e1b27e3242477fac660fa4cb (diff)
perf: adopt libghostty integration improvements
refs #276
Diffstat (limited to 'src/pane')
-rw-r--r--src/pane/osc.rs123
-rw-r--r--src/pane/terminal.rs110
2 files changed, 100 insertions, 133 deletions
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<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;
- }
- }
- }
-
- 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()
+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 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
// -----------------------------------------------------------------------
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"