use std::sync::Arc;
use alacritty_terminal::vte::Perform;
const MAX_SEMANTIC_VALUE_LEN: usize = 8192;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalWorkingDirectory {
pub host: Option<Arc<str>>,
pub path: Arc<[u8]>,
pub source: TerminalWorkingDirectorySource,
}
impl TerminalWorkingDirectory {
pub fn path_str(&self) -> Option<&str> {
std::str::from_utf8(&self.path).ok()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TerminalWorkingDirectorySource {
Osc7,
Osc9,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TerminalCommandPhase {
#[default]
Unknown,
Prompt,
Input,
Executing,
Completed {
exit_status: Option<i32>,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TerminalSemanticEvent {
WorkingDirectoryChanged(TerminalWorkingDirectory),
CommandPhaseChanged(TerminalCommandPhase),
ExecutableChanged(Option<Arc<str>>),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TerminalSemanticState {
pub cwd: Option<TerminalWorkingDirectory>,
pub command_phase: TerminalCommandPhase,
pub executable: Option<Arc<str>>,
}
#[derive(Debug, Default)]
pub(super) struct SemanticObserver {
state: TerminalSemanticState,
events: Vec<TerminalSemanticEvent>,
}
impl SemanticObserver {
pub(super) fn state(&self) -> TerminalSemanticState {
self.state.clone()
}
pub(super) fn restore_state(&mut self, state: TerminalSemanticState) {
self.state = state;
}
pub(super) fn drain_events(&mut self) -> Vec<TerminalSemanticEvent> {
std::mem::take(&mut self.events)
}
pub(super) fn peek_events(&self) -> &[TerminalSemanticEvent] {
&self.events
}
pub(super) fn event_count(&self) -> usize {
self.events.len()
}
fn set_cwd(&mut self, cwd: TerminalWorkingDirectory) {
if self.state.cwd.as_ref() != Some(&cwd) {
self.state.cwd = Some(cwd.clone());
self.events
.push(TerminalSemanticEvent::WorkingDirectoryChanged(cwd));
}
}
fn set_phase(&mut self, phase: TerminalCommandPhase) {
if self.state.command_phase != phase {
self.state.command_phase = phase;
self.events
.push(TerminalSemanticEvent::CommandPhaseChanged(phase));
}
}
fn set_executable(&mut self, executable: Option<Arc<str>>) {
if self.state.executable != executable {
self.state.executable = executable.clone();
self.events
.push(TerminalSemanticEvent::ExecutableChanged(executable));
}
}
fn handle_osc7(&mut self, rest: &[&[u8]]) {
let Some(raw) = join_params(rest) else {
return;
};
let Some((host, path)) = parse_file_uri(&raw) else {
return;
};
self.set_cwd(TerminalWorkingDirectory {
host,
path: path.into(),
source: TerminalWorkingDirectorySource::Osc7,
});
}
fn handle_osc9_9(&mut self, rest: &[&[u8]]) {
let Some(raw) = join_params(rest) else {
return;
};
if raw.is_empty() || raw.contains(&0) {
return;
}
self.set_cwd(TerminalWorkingDirectory {
host: None,
path: raw.into(),
source: TerminalWorkingDirectorySource::Osc9,
});
}
fn handle_osc133(&mut self, rest: &[&[u8]]) {
let Some(&marker) = rest.first() else {
return;
};
let Some(&letter) = marker.first() else {
return;
};
let mut exit_status: Option<i32> = None;
let mut executable: Option<Arc<str>> = None;
for param in rest.iter().skip(1) {
if let Some(eq) = param.iter().position(|&b| b == b'=') {
let key = ¶m[..eq];
let value = ¶m[eq + 1..];
if value.len() > MAX_SEMANTIC_VALUE_LEN {
continue;
}
match key {
b"hyprmux_exe" => {
if let Some(decoded) = percent_decode(value)
&& let Some(name) = normalize_executable(&decoded)
{
executable = Some(name);
}
}
b"cmdline_url" => {
if let Some(decoded) = percent_decode(value)
&& let Some(name) = first_token_basename(&decoded)
{
executable = Some(name);
}
}
b"exit_code" if letter == b'D' => {
exit_status = parse_ascii_i32(value);
}
_ => {}
}
} else if letter == b'D' {
exit_status = exit_status.or_else(|| parse_ascii_i32(param));
}
}
match letter {
b'A' => {
self.set_phase(TerminalCommandPhase::Prompt);
self.set_executable(None);
}
b'B' => self.set_phase(TerminalCommandPhase::Input),
b'C' => {
self.set_phase(TerminalCommandPhase::Executing);
if executable.is_some() {
self.set_executable(executable);
}
}
b'D' => self.set_phase(TerminalCommandPhase::Completed { exit_status }),
_ => {}
}
}
}
impl Perform for SemanticObserver {
fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
let Some(&code) = params.first() else {
return;
};
match code {
b"7" => self.handle_osc7(¶ms[1..]),
b"9" if params.get(1).copied() == Some(&b"9"[..]) => self.handle_osc9_9(¶ms[2..]),
b"133" => self.handle_osc133(¶ms[1..]),
_ => {}
}
}
}
fn join_params(parts: &[&[u8]]) -> Option<Vec<u8>> {
if parts.is_empty() {
return Some(Vec::new());
}
let total: usize = parts.iter().map(|p| p.len()).sum::<usize>() + parts.len() - 1;
if total > MAX_SEMANTIC_VALUE_LEN {
return None;
}
let mut out = Vec::with_capacity(total);
for (i, part) in parts.iter().enumerate() {
if i > 0 {
out.push(b';');
}
out.extend_from_slice(part);
}
Some(out)
}
fn parse_file_uri(raw: &[u8]) -> Option<(Option<Arc<str>>, Vec<u8>)> {
let remainder: &[u8] = if let Some(stripped) = strip_scheme(raw, b"file://") {
stripped
} else if let Some(stripped) = raw.strip_prefix(b"//") {
stripped
} else {
raw
};
let (host_raw, path_raw) = match remainder.iter().position(|&b| b == b'/') {
Some(idx) => (&remainder[..idx], &remainder[idx..]),
None => (&[][..], remainder),
};
let host = if host_raw.is_empty() {
None
} else {
let decoded = percent_decode(host_raw)?;
let host = String::from_utf8(decoded).ok()?;
Some(Arc::from(host))
};
let path = percent_decode(path_raw)?;
if path.is_empty() || path.contains(&0) {
return None;
}
Some((host, path))
}
fn strip_scheme<'a>(raw: &'a [u8], scheme: &[u8]) -> Option<&'a [u8]> {
if raw.len() < scheme.len() {
return None;
}
let (prefix, rest) = raw.split_at(scheme.len());
prefix.eq_ignore_ascii_case(scheme).then_some(rest)
}
fn percent_decode(input: &[u8]) -> Option<Vec<u8>> {
if input.len() > MAX_SEMANTIC_VALUE_LEN {
return None;
}
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
match input[i] {
b'%' => {
let hi = *input.get(i + 1)?;
let lo = *input.get(i + 2)?;
let value = (hex_digit(hi)? << 4) | hex_digit(lo)?;
out.push(value);
i += 3;
}
byte => {
out.push(byte);
i += 1;
}
}
}
Some(out)
}
fn hex_digit(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,
}
}
fn parse_ascii_i32(bytes: &[u8]) -> Option<i32> {
std::str::from_utf8(bytes).ok()?.trim().parse().ok()
}
fn normalize_executable(decoded: &[u8]) -> Option<Arc<str>> {
if decoded.is_empty() || decoded.len() > MAX_SEMANTIC_VALUE_LEN {
return None;
}
let text = std::str::from_utf8(decoded).ok()?;
basename(text.trim())
}
fn first_token_basename(decoded: &[u8]) -> Option<Arc<str>> {
if decoded.len() > MAX_SEMANTIC_VALUE_LEN {
return None;
}
let text = std::str::from_utf8(decoded).ok()?;
let first = text.split_whitespace().next()?;
basename(first)
}
fn basename(path: &str) -> Option<Arc<str>> {
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
if name.is_empty() || name.contains('\0') {
return None;
}
Some(Arc::from(name))
}
#[cfg(test)]
mod tests {
use super::*;
use alacritty_terminal::vte::Parser as VteParser;
fn drive(bytes: &[u8]) -> (TerminalSemanticState, Vec<TerminalSemanticEvent>) {
let mut parser = VteParser::new();
let mut observer = SemanticObserver::default();
parser.advance(&mut observer, bytes);
(observer.state(), observer.drain_events())
}
#[test]
fn osc7_decodes_local_path_without_host() {
let (state, events) = drive(b"\x1b]7;file:///home/user/my%20project\x07");
let cwd = state.cwd.expect("cwd");
assert_eq!(cwd.host, None);
assert_eq!(cwd.path_str(), Some("/home/user/my project"));
assert_eq!(cwd.source, TerminalWorkingDirectorySource::Osc7);
assert_eq!(events.len(), 1);
}
#[test]
fn osc7_preserves_remote_host() {
let (state, _) = drive(b"\x1b]7;file://build-box/srv/app\x07");
let cwd = state.cwd.expect("cwd");
assert_eq!(cwd.host, Some(Arc::from("build-box")));
assert_eq!(cwd.path_str(), Some("/srv/app"));
}
#[test]
fn osc7_accepts_st_termination_and_split_pty_chunks() {
let mut parser = VteParser::new();
let mut observer = SemanticObserver::default();
for chunk in [
&b"\x1b]7;file://"[..],
&b"/home/a"[..],
&b"/b\x1b"[..],
&b"\\"[..],
] {
parser.advance(&mut observer, chunk);
}
let cwd = observer.state().cwd.expect("cwd");
assert_eq!(cwd.path_str(), Some("/home/a/b"));
}
#[test]
fn osc7_rejects_non_utf8_host_but_keeps_raw_path_bytes() {
let (state, _) = drive(b"\x1b]7;file:///tmp/bad%FFname\x07");
let cwd = state.cwd.expect("cwd");
assert_eq!(cwd.path.as_ref(), b"/tmp/bad\xffname");
assert_eq!(cwd.path_str(), None);
}
#[test]
fn osc7_rejects_malformed_percent_escape() {
let (state, events) = drive(b"\x1b]7;file:///tmp/bad%2\x07");
assert!(state.cwd.is_none());
assert!(events.is_empty());
}
#[test]
fn osc7_semicolons_in_path_round_trip() {
let (state, _) = drive(b"\x1b]7;file:///tmp/a;b\x07");
assert_eq!(
state.cwd.expect("cwd").path_str(),
Some("/tmp/a;b"),
"semicolon in path must survive OSC param splitting"
);
}
#[test]
fn osc9_9_reports_windows_style_path_without_host() {
let (state, _) = drive(b"\x1b]9;9;C:\\Users\\dev\\project\x07");
let cwd = state.cwd.expect("cwd");
assert_eq!(cwd.host, None);
assert_eq!(cwd.path_str(), Some("C:\\Users\\dev\\project"));
assert_eq!(cwd.source, TerminalWorkingDirectorySource::Osc9);
}
#[test]
fn osc133_tracks_prompt_input_execution_and_completion() {
let mut parser = VteParser::new();
let mut observer = SemanticObserver::default();
parser.advance(&mut observer, b"\x1b]133;A\x07");
assert_eq!(observer.state().command_phase, TerminalCommandPhase::Prompt);
parser.advance(&mut observer, b"\x1b]133;B\x07");
assert_eq!(observer.state().command_phase, TerminalCommandPhase::Input);
parser.advance(&mut observer, b"\x1b]133;C\x07");
assert_eq!(
observer.state().command_phase,
TerminalCommandPhase::Executing
);
parser.advance(&mut observer, b"\x1b]133;D;0\x07");
assert_eq!(
observer.state().command_phase,
TerminalCommandPhase::Completed {
exit_status: Some(0)
}
);
let events = observer.drain_events();
assert_eq!(events.len(), 4);
}
#[test]
fn osc133_completion_reports_nonzero_exit_status() {
let (state, _) = drive(b"\x1b]133;D;127\x07");
assert_eq!(
state.command_phase,
TerminalCommandPhase::Completed {
exit_status: Some(127)
}
);
}
#[test]
fn osc133_completion_without_code_reports_none() {
let (state, _) = drive(b"\x1b]133;D\x07");
assert_eq!(
state.command_phase,
TerminalCommandPhase::Completed { exit_status: None }
);
}
#[test]
fn osc133_hyprmux_extension_reports_executable_basename() {
let (state, _) = drive(b"\x1b]133;C;hyprmux_exe=%2Fusr%2Fbin%2Fhtop\x07");
assert_eq!(state.executable, Some(Arc::from("htop")));
}
#[test]
fn osc133_cmdline_url_extracts_first_token_basename_only() {
let (state, _) = drive(b"\x1b]133;C;cmdline_url=%2Fusr%2Fbin%2Fgrep%20--flag%20secret\x07");
assert_eq!(state.executable, Some(Arc::from("grep")));
}
#[test]
fn osc133_new_prompt_clears_previous_executable() {
let mut parser = VteParser::new();
let mut observer = SemanticObserver::default();
parser.advance(&mut observer, b"\x1b]133;C;hyprmux_exe=vim\x07");
assert_eq!(observer.state().executable, Some(Arc::from("vim")));
parser.advance(&mut observer, b"\x1b]133;A\x07");
assert_eq!(observer.state().executable, None);
}
#[test]
fn unknown_osc_codes_are_ignored() {
let (state, events) = drive(b"\x1b]4;1;?\x07\x1b]2;title\x07");
assert_eq!(state, TerminalSemanticState::default());
assert!(events.is_empty());
}
#[test]
fn restore_state_reapplies_without_emitting_events() {
let mut observer = SemanticObserver::default();
let restored = TerminalSemanticState {
cwd: Some(TerminalWorkingDirectory {
host: None,
path: Arc::from(b"/tmp".as_slice()),
source: TerminalWorkingDirectorySource::Osc7,
}),
command_phase: TerminalCommandPhase::Input,
executable: Some(Arc::from("bash")),
};
observer.restore_state(restored.clone());
assert_eq!(observer.state(), restored);
assert!(observer.drain_events().is_empty());
}
#[test]
fn oversized_osc7_payload_is_rejected() {
let mut payload = b"\x1b]7;file:///".to_vec();
payload.extend(std::iter::repeat_n(b'a', MAX_SEMANTIC_VALUE_LEN + 16));
payload.extend_from_slice(b"\x07");
let (state, events) = drive(&payload);
assert!(state.cwd.is_none());
assert!(events.is_empty());
}
}