use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Notification {
SessionsChanged,
SessionChanged { id: String, name: String },
SessionRenamed { id: String, name: String },
SessionClosed { id: String },
SessionWindowChanged { session: String, window: String },
WindowAdd { id: String },
WindowClose { id: String },
WindowRenamed { id: String, name: String },
Output { pane: String, data: Vec<u8> },
Exit,
CommandBegin { id: u32 },
CommandEnd { id: u32 },
CommandError { id: u32 },
CommandOutput { id: u32, line: String },
Unknown(String),
}
#[derive(Debug, Default)]
pub struct ControlParser {
current_command: Option<u32>,
}
impl ControlParser {
pub fn new() -> Self {
Self::default()
}
pub fn feed(&mut self, line: &str) -> Option<Notification> {
if let Some(id) = self.current_command {
if let Some(rest) = line.strip_prefix("%end ") {
if let Some(end_id) = parse_cmd_id(rest) {
if end_id == id {
self.current_command = None;
return Some(Notification::CommandEnd { id });
}
}
}
if let Some(rest) = line.strip_prefix("%error ") {
if let Some(err_id) = parse_cmd_id(rest) {
if err_id == id {
self.current_command = None;
return Some(Notification::CommandError { id });
}
}
}
return Some(Notification::CommandOutput {
id,
line: line.to_string(),
});
}
if line.is_empty() {
return None;
}
if !line.starts_with('%') {
return Some(Notification::Unknown(line.to_string()));
}
if let Some(rest) = line.strip_prefix("%begin ") {
if let Some(id) = parse_cmd_id(rest) {
self.current_command = Some(id);
return Some(Notification::CommandBegin { id });
}
return Some(Notification::Unknown(line.to_string()));
}
match line {
"%sessions-changed" => return Some(Notification::SessionsChanged),
"%exit" => return Some(Notification::Exit),
_ => {}
}
if let Some(rest) = line.strip_prefix("%session-changed ") {
if let Some((id, name)) = split_once_space(rest) {
return Some(Notification::SessionChanged {
id: id.to_string(),
name: name.to_string(),
});
}
}
if let Some(rest) = line.strip_prefix("%session-renamed ") {
if let Some((id, name)) = split_once_space(rest) {
return Some(Notification::SessionRenamed {
id: id.to_string(),
name: name.to_string(),
});
}
}
if let Some(rest) = line.strip_prefix("%session-closed ") {
return Some(Notification::SessionClosed {
id: rest.trim().to_string(),
});
}
if let Some(rest) = line.strip_prefix("%session-window-changed ") {
if let Some((session, window)) = split_once_space(rest) {
return Some(Notification::SessionWindowChanged {
session: session.to_string(),
window: window.to_string(),
});
}
}
if let Some(rest) = line.strip_prefix("%window-add ") {
return Some(Notification::WindowAdd {
id: rest.trim().to_string(),
});
}
if let Some(rest) = line.strip_prefix("%window-close ") {
return Some(Notification::WindowClose {
id: rest.trim().to_string(),
});
}
if let Some(rest) = line.strip_prefix("%window-renamed ") {
if let Some((id, name)) = split_once_space(rest) {
return Some(Notification::WindowRenamed {
id: id.to_string(),
name: name.to_string(),
});
}
}
if let Some(rest) = line.strip_prefix("%output ") {
if let Some((pane, data)) = split_once_space(rest) {
return Some(Notification::Output {
pane: pane.to_string(),
data: unescape_output(data),
});
}
}
Some(Notification::Unknown(line.to_string()))
}
}
fn parse_cmd_id(rest: &str) -> Option<u32> {
let mut parts = rest.split_whitespace();
let _timestamp = parts.next()?;
let id_str = parts.next()?;
u32::from_str(id_str).ok()
}
fn split_once_space(s: &str) -> Option<(&str, &str)> {
s.split_once(' ')
}
pub fn unescape_output(s: &str) -> Vec<u8> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 3 < bytes.len() {
let b1 = bytes[i + 1];
let b2 = bytes[i + 2];
let b3 = bytes[i + 3];
if b1.is_ascii_digit() && b2.is_ascii_digit() && b3.is_ascii_digit() {
let v = ((b1 - b'0') as u32) * 64 + ((b2 - b'0') as u32) * 8 + ((b3 - b'0') as u32);
if v <= 0xff {
out.push(v as u8);
i += 4;
continue;
}
}
}
out.push(bytes[i]);
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_all(input: &str) -> Vec<Notification> {
let mut p = ControlParser::new();
input.lines().filter_map(|l| p.feed(l)).collect()
}
#[test]
fn parses_sessions_changed() {
assert_eq!(
parse_all("%sessions-changed"),
vec![Notification::SessionsChanged]
);
}
#[test]
fn parses_session_changed_with_id_and_name() {
assert_eq!(
parse_all("%session-changed $3 work"),
vec![Notification::SessionChanged {
id: "$3".into(),
name: "work".into(),
}]
);
}
#[test]
fn parses_session_renamed() {
assert_eq!(
parse_all("%session-renamed $0 new-name"),
vec![Notification::SessionRenamed {
id: "$0".into(),
name: "new-name".into(),
}]
);
}
#[test]
fn parses_session_closed() {
assert_eq!(
parse_all("%session-closed $4"),
vec![Notification::SessionClosed { id: "$4".into() }]
);
}
#[test]
fn parses_session_window_changed() {
assert_eq!(
parse_all("%session-window-changed $0 @1"),
vec![Notification::SessionWindowChanged {
session: "$0".into(),
window: "@1".into(),
}]
);
}
#[test]
fn parses_window_add_and_close() {
let got = parse_all("%window-add @7\n%window-close @7");
assert_eq!(
got,
vec![
Notification::WindowAdd { id: "@7".into() },
Notification::WindowClose { id: "@7".into() },
]
);
}
#[test]
fn parses_exit() {
assert_eq!(parse_all("%exit"), vec![Notification::Exit]);
}
#[test]
fn unknown_notification_preserved_verbatim() {
let got = parse_all("%something-brand-new $1 @2");
assert_eq!(
got,
vec![Notification::Unknown("%something-brand-new $1 @2".into())]
);
}
#[test]
fn begin_end_command_block_emits_begin_output_end() {
let input = "%begin 1775949678 397 0\nresponse line\n%end 1775949678 397 0";
let got = parse_all(input);
assert_eq!(
got,
vec![
Notification::CommandBegin { id: 397 },
Notification::CommandOutput {
id: 397,
line: "response line".into(),
},
Notification::CommandEnd { id: 397 },
]
);
}
#[test]
fn empty_command_block_still_brackets_cleanly() {
let input = "%begin 1 100 0\n%end 1 100 0";
let got = parse_all(input);
assert_eq!(
got,
vec![
Notification::CommandBegin { id: 100 },
Notification::CommandEnd { id: 100 },
]
);
}
#[test]
fn error_block_bracketed_as_command_error() {
let input = "%begin 1 200 0\nerr: whatever\n%error 1 200 0";
let got = parse_all(input);
assert_eq!(
got,
vec![
Notification::CommandBegin { id: 200 },
Notification::CommandOutput {
id: 200,
line: "err: whatever".into(),
},
Notification::CommandError { id: 200 },
]
);
}
#[test]
fn notification_between_commands_stays_outside_block() {
let input = "\
%begin 1 1 0
%end 1 1 0
%sessions-changed
%begin 2 2 0
%end 2 2 0";
let got = parse_all(input);
assert_eq!(
got,
vec![
Notification::CommandBegin { id: 1 },
Notification::CommandEnd { id: 1 },
Notification::SessionsChanged,
Notification::CommandBegin { id: 2 },
Notification::CommandEnd { id: 2 },
]
);
}
#[test]
fn output_unescapes_octal_control_chars() {
let input = "%output %0 \\033[1mA";
let got = parse_all(input);
let expected_bytes = vec![0x1b, b'[', b'1', b'm', b'A'];
assert_eq!(
got,
vec![Notification::Output {
pane: "%0".into(),
data: expected_bytes,
}]
);
}
#[test]
fn output_preserves_literal_text() {
let input = "%output %3 hello world";
let got = parse_all(input);
assert_eq!(
got,
vec![Notification::Output {
pane: "%3".into(),
data: b"hello world".to_vec(),
}]
);
}
#[test]
fn output_handles_mix_of_literals_and_escapes() {
let input = "%output %2 foo\\033[31mbar";
let got = parse_all(input);
let mut expected = b"foo".to_vec();
expected.extend([0x1b, b'[', b'3', b'1', b'm']);
expected.extend(b"bar");
assert_eq!(
got,
vec![Notification::Output {
pane: "%2".into(),
data: expected,
}]
);
}
#[test]
fn unescape_output_leaves_invalid_escapes_literal() {
assert_eq!(unescape_output("a\\9b"), b"a\\9b".to_vec());
}
#[test]
fn unescape_output_handles_trailing_backslash() {
assert_eq!(unescape_output("abc\\"), b"abc\\".to_vec());
}
#[test]
fn blank_lines_outside_commands_are_ignored() {
let input = "%sessions-changed\n\n%exit";
let got = parse_all(input);
assert_eq!(got, vec![Notification::SessionsChanged, Notification::Exit]);
}
#[test]
fn blank_lines_inside_command_block_preserved() {
let input = "%begin 1 42 0\n\n%end 1 42 0";
let got = parse_all(input);
assert_eq!(
got,
vec![
Notification::CommandBegin { id: 42 },
Notification::CommandOutput {
id: 42,
line: "".into(),
},
Notification::CommandEnd { id: 42 },
]
);
}
#[test]
fn real_tmux_transcript_parses_end_to_end() {
let transcript = "\
%begin 1775949678 397 0
%end 1775949678 397 0
%window-add @0
%sessions-changed
%session-changed $0 probe
%begin 1775949678 403 1
%end 1775949678 403 1
%begin 1775949678 404 1
probe: 2 windows (created Sun Apr 12 00:21:18 2026) (attached)
%end 1775949678 404 1
%session-window-changed $0 @1
%window-add @1
%output %0 hi
%exit";
let got = parse_all(transcript);
assert!(matches!(got[0], Notification::CommandBegin { id: 397 }));
assert!(matches!(got[1], Notification::CommandEnd { id: 397 }));
assert_eq!(got[2], Notification::WindowAdd { id: "@0".into() });
assert_eq!(got[3], Notification::SessionsChanged);
assert_eq!(
got[4],
Notification::SessionChanged {
id: "$0".into(),
name: "probe".into(),
}
);
let has_output = got.iter().any(|n| {
matches!(
n,
Notification::Output { pane, data }
if pane == "%0" && data == b"hi"
)
});
assert!(has_output, "expected Output notification in transcript");
assert_eq!(got.last(), Some(&Notification::Exit));
}
}