vt: track cwd via OSC 7; add a new-window action

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I498a1938ca0129d10cf0e230d27188ed6a6a6964
This commit is contained in:
raf 2026-06-25 13:31:15 +03:00
commit 0c0da3d035
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
3 changed files with 87 additions and 0 deletions

View file

@ -20,6 +20,7 @@ pub enum Action {
FontDecrease, FontDecrease,
FontReset, FontReset,
Fullscreen, Fullscreen,
NewWindow,
} }
impl Action { impl Action {
@ -37,6 +38,7 @@ impl Action {
"font-decrease" => Self::FontDecrease, "font-decrease" => Self::FontDecrease,
"font-reset" => Self::FontReset, "font-reset" => Self::FontReset,
"fullscreen" => Self::Fullscreen, "fullscreen" => Self::Fullscreen,
"new-window" => Self::NewWindow,
_ => return None, _ => return None,
}) })
} }
@ -171,6 +173,7 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[
("Ctrl+minus", "font-decrease"), ("Ctrl+minus", "font-decrease"),
("Ctrl+0", "font-reset"), ("Ctrl+0", "font-reset"),
("F11", "fullscreen"), ("F11", "fullscreen"),
("Ctrl+Shift+N", "new-window"),
]; ];
/// Map a key token to a keysym: a single character, or a named special key. /// Map a key token to a keysym: a single character, or a named special key.

View file

@ -102,6 +102,8 @@ pub struct Term {
theme: Theme, theme: Theme,
/// Set when the child rings the bell (`BEL`); cleared by the front-end. /// Set when the child rings the bell (`BEL`); cleared by the front-end.
bell: bool, bell: bool,
/// Working directory reported by the shell via OSC 7, for new windows.
cwd: Option<String>,
} }
impl Term { impl Term {
@ -118,9 +120,15 @@ impl Term {
clipboard_ops: Vec::new(), clipboard_ops: Vec::new(),
theme: Theme::default(), theme: Theme::default(),
bell: false, bell: false,
cwd: None,
} }
} }
/// The working directory last reported by the shell (OSC 7), if any.
pub fn cwd(&self) -> Option<&str> {
self.cwd.as_deref()
}
/// Take and clear the pending bell flag. /// Take and clear the pending bell flag.
pub fn take_bell(&mut self) -> bool { pub fn take_bell(&mut self) -> bool {
std::mem::take(&mut self.bell) std::mem::take(&mut self.bell)
@ -653,6 +661,12 @@ impl Perform for Term {
self.title = Some(String::from_utf8_lossy(text).into_owned()); self.title = Some(String::from_utf8_lossy(text).into_owned());
} }
} }
// OSC 7: the shell reports its cwd as a `file://host/path` URI.
Some(&n) if n == b"7" => {
if let Some(uri) = params.get(1) {
self.cwd = file_uri_path(uri);
}
}
// OSC 4: set/query palette entries (pairs of index;spec). // OSC 4: set/query palette entries (pairs of index;spec).
Some(&n) if n == b"4" => self.osc_palette(params, bell), Some(&n) if n == b"4" => self.osc_palette(params, bell),
// OSC 104: reset palette (all, or the listed indices). // OSC 104: reset palette (all, or the listed indices).
@ -796,6 +810,38 @@ fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {
Some(out) Some(out)
} }
/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding
/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns
/// `None` if it is not a usable absolute path.
fn file_uri_path(uri: &[u8]) -> Option<String> {
let rest = uri.strip_prefix(b"file://").unwrap_or(uri);
// Skip the authority (host) up to the first '/', which begins the path.
let slash = rest.iter().position(|&b| b == b'/')?;
let path_bytes = percent_decode(&rest[slash..]);
let path = String::from_utf8(path_bytes).ok()?;
path.starts_with('/').then_some(path)
}
/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.
fn percent_decode(s: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut i = 0;
while i < s.len() {
if s[i] == b'%' && i + 2 < s.len() {
let hi = (s[i + 1] as char).to_digit(16);
let lo = (s[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(s[i]);
i += 1;
}
out
}
/// Decode an even-length lowercase/uppercase hex string into bytes. /// Decode an even-length lowercase/uppercase hex string into bytes.
fn decode_hex(s: &[u8]) -> Option<Vec<u8>> { fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) { if s.is_empty() || !s.len().is_multiple_of(2) {
@ -1068,6 +1114,18 @@ mod tests {
assert_eq!(t.grid().row_text(0), "─│"); assert_eq!(t.grid().row_text(0), "─│");
} }
#[test]
fn osc7_tracks_cwd_and_decodes_percent() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");
assert_eq!(t.cwd(), Some("/home/user/my dir"));
// A non-file or relative URI leaves the previous value untouched? It
// simply does not match a path, so cwd stays None here.
let mut t2 = Term::new(20, 1);
feed(&mut t2, b"\x1b]7;file://host\x07");
assert_eq!(t2.cwd(), None);
}
#[test] #[test]
fn title_via_osc() { fn title_via_osc() {
let mut t = Term::new(20, 1); let mut t = Term::new(20, 1);

View file

@ -638,6 +638,32 @@ impl App {
Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)), Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)),
Action::FontReset => self.change_font_size(self.config.main.font_size), Action::FontReset => self.change_font_size(self.config.main.font_size),
Action::Fullscreen => self.toggle_fullscreen(), Action::Fullscreen => self.toggle_fullscreen(),
Action::NewWindow => self.spawn_new_window(),
}
}
/// Launch another beer process in the shell's reported working directory
/// (OSC 7), inheriting the same config. The child is fully detached.
fn spawn_new_window(&mut self) {
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(err) => {
tracing::warn!("locate beer executable: {err}");
return;
}
};
let mut cmd = std::process::Command::new(exe);
if let Some(path) = self.config_path.as_ref() {
cmd.arg("--config").arg(path);
}
if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {
cmd.current_dir(cwd);
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if let Err(err) = cmd.spawn() {
tracing::warn!("spawn new window: {err}");
} }
} }