aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 00:07:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:44 +0300
commit3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch)
treea52280bccc824ca656f8bc986a756f30901974e4 /src/pane
parent5d7e56737ef30d9de5802b0684a9428c40131dda (diff)
feat: add native windows beta support
Diffstat (limited to 'src/pane')
-rw-r--r--src/pane/kitty_keyboard.rs1
-rw-r--r--src/pane/osc.rs166
-rw-r--r--src/pane/terminal.rs17
3 files changed, 183 insertions, 1 deletions
diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs
index 1b7d3274..89824ee3 100644
--- a/src/pane/kitty_keyboard.rs
+++ b/src/pane/kitty_keyboard.rs
@@ -81,6 +81,7 @@ impl KittyKeyboardTracker {
}
}
+ #[cfg(unix)]
pub(crate) fn replay_ansi(&self) -> Option<String> {
if self.stack.is_empty() {
return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags));
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 37407ade..1d37f1c0 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -1,4 +1,5 @@
use std::borrow::Cow;
+use std::path::PathBuf;
use tracing::info;
@@ -359,6 +360,145 @@ 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()
+ }
+}
+
+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
+}
+
+fn parse_file_uri_cwd(uri: &str) -> Option<PathBuf> {
+ let rest = uri.strip_prefix("file://")?;
+ let path = if rest.starts_with('/') {
+ rest
+ } else if let Some(slash) = rest.find('/') {
+ let host = &rest[..slash];
+ if !(host.is_empty() || host.eq_ignore_ascii_case("localhost")) {
+ return None;
+ }
+ &rest[slash..]
+ } else {
+ rest
+ };
+ let path = percent_decode_utf8(path)?;
+
+ #[cfg(windows)]
+ {
+ let mut path = path;
+ if path.len() >= 3
+ && path.as_bytes()[0] == b'/'
+ && path.as_bytes()[2] == b':'
+ && path.as_bytes()[1].is_ascii_alphabetic()
+ {
+ path.remove(0);
+ }
+ return Some(PathBuf::from(path.replace('/', "\\")));
+ }
+
+ #[cfg(not(windows))]
+ Some(PathBuf::from(path))
+}
+
+fn percent_decode_utf8(input: &str) -> Option<String> {
+ let bytes = input.as_bytes();
+ let mut output = Vec::with_capacity(bytes.len());
+ let mut idx = 0;
+ while idx < bytes.len() {
+ if bytes[idx] == b'%' {
+ let hi = *bytes.get(idx + 1)?;
+ let lo = *bytes.get(idx + 2)?;
+ output.push(hex_value(hi)? * 16 + hex_value(lo)?);
+ idx += 3;
+ } else {
+ output.push(bytes[idx]);
+ idx += 1;
+ }
+ }
+ String::from_utf8(output).ok()
+}
+
+fn hex_value(byte: u8) -> Option<u8> {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' => Some(byte - b'A' + 10),
+ _ => None,
+ }
+}
+
/// Accepts `52;c;<base64>` and `52;;<base64>`.
/// Queries (`?`) are rejected because herdr has no reply path.
/// The payload must decode as base64 before it is forwarded.
@@ -601,6 +741,32 @@ 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");
+
+ assert_eq!(
+ tracker.drain_latest(),
+ 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(),
+ Some(std::path::PathBuf::from("C:\\Users\\herdr\\src\\herdr"))
+ );
+ }
+
+ #[test]
fn default_color_event_tracker_detects_queries_sets_and_resets() {
let mut tracker = DefaultColorEventTracker::default();
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index cf8506ba..9bfcf57f 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -23,7 +23,7 @@ use super::{
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, DefaultColorEvent, DefaultColorEventTracker,
+ write_host_terminal_theme, CwdOscTracker, DefaultColorEvent, DefaultColorEventTracker,
DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder,
},
xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
@@ -101,6 +101,7 @@ pub(crate) struct ProcessBytesResult {
pub request_render: bool,
pub render_delay: Option<Duration>,
pub clipboard_writes: Vec<Vec<u8>>,
+ pub reported_cwd: Option<std::path::PathBuf>,
pub terminal_responses: Vec<Bytes>,
}
@@ -123,6 +124,7 @@ 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 xtgettcap_query_tracker: XtgettcapQueryTracker,
}
@@ -264,6 +266,7 @@ impl PaneTerminal {
self.ghostty.keyboard_protocol().unwrap_or(fallback)
}
+ #[cfg(unix)]
pub fn kitty_keyboard_state_ansi(&self) -> Option<String> {
self.ghostty
.kitty_keyboard_state_ansi()
@@ -352,6 +355,7 @@ impl GhosttyPaneTerminal {
child_default_foreground_changed: false,
child_default_background_changed: false,
osc52_forwarder: Osc52Forwarder::default(),
+ cwd_osc_tracker: CwdOscTracker::default(),
xtgettcap_query_tracker: XtgettcapQueryTracker::default(),
}),
key_encoder: Mutex::new(key_encoder),
@@ -419,6 +423,7 @@ impl GhosttyPaneTerminal {
request_render: false,
render_delay: None,
clipboard_writes: Vec::new(),
+ reported_cwd: None,
terminal_responses: Vec::new(),
};
};
@@ -436,6 +441,8 @@ 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();
let alternate_screen = core
.terminal
@@ -507,6 +514,7 @@ impl GhosttyPaneTerminal {
request_render,
render_delay,
clipboard_writes,
+ reported_cwd,
terminal_responses,
}
}
@@ -576,6 +584,7 @@ impl GhosttyPaneTerminal {
}
}
+ #[cfg(unix)]
pub fn seed_handoff_input_state(&self, input_state: InputState) {
let Ok(mut core) = self.core.lock() else {
return;
@@ -647,6 +656,7 @@ impl GhosttyPaneTerminal {
}
}
+ #[cfg(unix)]
pub fn seed_keyboard_protocol_flags(&self, flags: u16) {
if flags == 0 {
return;
@@ -654,6 +664,7 @@ impl GhosttyPaneTerminal {
self.seed_keyboard_protocol_ansi(&format!("\x1b[>{flags}u"));
}
+ #[cfg(unix)]
pub fn seed_keyboard_protocol_ansi(&self, ansi: &str) {
if ansi.is_empty() {
return;
@@ -785,6 +796,7 @@ impl GhosttyPaneTerminal {
))
}
+ #[cfg(unix)]
pub fn kitty_keyboard_state_ansi(&self) -> Option<String> {
let core = self.core.lock().ok()?;
core.kitty_keyboard.replay_ansi()
@@ -2144,6 +2156,7 @@ mod tests {
assert_eq!(encoded, b"\x1bOA");
}
+ #[cfg(unix)]
#[test]
fn ghostty_seed_handoff_input_state_restores_input_modes() {
let (tx, _rx) = mpsc::channel(4);
@@ -2254,6 +2267,7 @@ mod tests {
assert_eq!(encoded, b"\x1b[13;2u");
}
+ #[cfg(unix)]
#[test]
fn ghostty_seed_keyboard_protocol_flags_restores_shift_enter_encoding() {
let (tx, _rx) = mpsc::channel(4);
@@ -2271,6 +2285,7 @@ mod tests {
assert_eq!(encoded, b"\x1b[13;2u");
}
+ #[cfg(unix)]
#[test]
fn ghostty_keyboard_protocol_state_replays_nested_stack() {
let (tx, _rx) = mpsc::channel(4);