Files
ghostty-rs/crates/ghostty-vt/src/osc/parsers/osc9.rs
T
ddidderr b2b248e003 feat(vt): port Ghostty's VT parsing layer
Ports the parsing half of ZIG/src/terminal into the ghostty-vt crate:
- Parser state machine with a compile-time generated parse table and
  the UTF-8 decoder
- OSC parser incl. all sub-parsers (titles, colors, clipboard, hyperlinks,
  semantic prompts/OSC 133, OSC 9 / ConEmu, iTerm2, kitty clipboard,
  color and text-sizing protocols, context signals)
- CSI, SGR, modes table with save/restore, charsets, ansi, device status,
  mouse shapes, APC, DCS (incl. XTGETTCAP), tmux control-mode parser
- color palette / X11 color names, kitty color and graphics command parser

Deviations from Zig (documented in docs/rewrite/notes/ghostty-vt-W*.md):
parser actions own their payloads instead of borrowing parser buffers;
OSC strings are Vec<u8>; non-exhaustive Zig enums gain an Other(raw)
variant; tmux regexes replaced by equivalent hand-written matchers.

Known follow-up: semantic_prompt.rs carries local copies of the printf-q
and percent decoders that belong in ghostty-os.

Test Plan:
- cargo test -p ghostty-vt: 395 passed, 1 ignored (skipped in Zig too)
- cargo clippy -p ghostty-vt --all-targets -- -D warnings: clean
2026-10-11 21:52:41 +02:00

558 lines
15 KiB
Rust

//! OSC 9 (iTerm2 notification / ConEmu extensions)
//! (ZIG/src/terminal/osc/parsers/osc9.zig).
use super::semantic_prompt;
use crate::osc::{Command, ConemuChangeTabTitle, Parser, ProgressReport, ProgressState};
/// Parse a base-10 unsigned integer from raw bytes (Zig `parseUnsigned`).
fn parse_unsigned<T: std::str::FromStr>(bytes: &[u8]) -> Option<T> {
std::str::from_utf8(bytes).ok()?.parse().ok()
}
/// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension.
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
// Zig prologue: writer must exist. (The NUL-terminator capacity check is
// folded into `data()`.)
let data = parser.data()?;
// Check first to see if this is a ConEmu OSC
// https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
if let Some(cmd) = conemu(&data) {
return Some(cmd);
}
// If it's not a ConEmu OSC, it's an iTerm2 notification
Some(Command::ShowDesktopNotification {
title: Vec::new(),
body: data,
})
}
/// Returns None where the Zig code does `break :conemu`.
fn conemu(data: &[u8]) -> Option<Command> {
if data.is_empty() {
return None;
}
let msg = |skip: usize| data[skip..].to_vec();
match data[0] {
// Check for OSC 9;1 9;10 9;11 9;12
b'1' => {
if data.len() < 2 {
return None;
}
match data[1] {
// OSC 9;1 sleep
b';' => Some(Command::ConemuSleep {
duration_ms: match parse_unsigned::<u16>(&data[2..]) {
Some(num) => num.min(10_000),
None => 100,
},
}),
// OSC 9;10 xterm keyboard and output emulation
b'0' => {
if data.len() == 2 {
return Some(Command::ConemuXtermEmulation {
keyboard: Some(true),
output: Some(true),
});
}
if data.len() < 4 {
return None;
}
if data[2] != b';' {
return None;
}
let (keyboard, output) = match data[3] {
b'0' => (Some(false), Some(false)),
b'1' => (Some(true), Some(true)),
b'2' => (None, Some(false)),
b'3' => (None, Some(true)),
_ => return None,
};
Some(Command::ConemuXtermEmulation { keyboard, output })
}
// OSC 9;11 comment
b'1' => {
if data.len() < 3 {
return None;
}
if data[2] != b';' {
return None;
}
Some(Command::ConemuComment(msg(3)))
}
// OSC 9;12 mark prompt start
b'2' => Some(Command::SemanticPrompt(semantic_prompt::Command::init(
semantic_prompt::Action::FreshLineNewPrompt,
))),
_ => None,
}
}
// OSC 9;2 show message box
b'2' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuShowMessageBox(msg(2)))
}
// OSC 9;3 change tab title
b'3' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
if data.len() == 2 {
return Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset));
}
Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(
msg(2),
)))
}
// OSC 9;4 progress report
b'4' => {
if data.len() < 2 || data[1] != b';' || data.len() < 3 {
return None;
}
let mut report = match data[2] {
b'0' => ProgressReport {
state: ProgressState::Remove,
progress: None,
},
b'1' => ProgressReport {
state: ProgressState::Set,
progress: Some(0),
},
b'2' => ProgressReport {
state: ProgressState::Error,
progress: None,
},
b'3' => ProgressReport {
state: ProgressState::Indeterminate,
progress: None,
},
b'4' => ProgressReport {
state: ProgressState::Pause,
progress: None,
},
_ => return None,
};
match report.state {
ProgressState::Remove | ProgressState::Indeterminate => {}
ProgressState::Set | ProgressState::Error | ProgressState::Pause => {
if data.len() >= 4 && data[3] == b';' {
// parse the progress value
report.progress =
parse_unsigned::<usize>(&data[4..]).map(|v| v.min(100) as u8);
}
}
}
Some(Command::ConemuProgressReport(report))
}
// OSC 9;5 wait for input
b'5' => Some(Command::ConemuWaitInput),
// OSC 9;6 guimacro
b'6' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuGuimacro(msg(2)))
}
// OSC 9;7 run process
b'7' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuRunProcess(msg(2)))
}
// OSC 9;8 output environment variable
b'8' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuOutputEnvironmentVariable(msg(2)))
}
// OSC 9;9 current working directory
b'9' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ReportPwd { value: msg(2) })
}
_ => None,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn run(input: &str) -> Command {
let mut p = Parser::init(false);
for ch in input.bytes() {
p.next(ch);
}
p.end(Some(0x1b)).unwrap()
}
fn notif(input: &str, body: &str) {
match run(input) {
Command::ShowDesktopNotification { title, body: b } => {
assert_eq!(title, b"");
assert_eq!(b, body.as_bytes());
}
c => panic!("unexpected {c:?}"),
}
}
fn progress(input: &str, state: ProgressState, progress: Option<u8>) {
assert_eq!(
run(input),
Command::ConemuProgressReport(ProgressReport { state, progress })
);
}
fn xterm(input: &str, keyboard: Option<bool>, output: Option<bool>) {
assert_eq!(
run(input),
Command::ConemuXtermEmulation { keyboard, output }
);
}
#[test]
fn osc_9_show_desktop_notification() {
notif("9;Hello world", "Hello world");
}
#[test]
fn osc_9_show_single_character_desktop_notification() {
notif("9;H", "H");
}
#[test]
fn osc_9_1_conemu_sleep() {
assert_eq!(run("9;1;420"), Command::ConemuSleep { duration_ms: 420 });
}
#[test]
fn osc_9_1_conemu_sleep_with_no_value_default_to_100ms() {
assert_eq!(run("9;1;"), Command::ConemuSleep { duration_ms: 100 });
}
#[test]
fn osc_9_1_conemu_sleep_cannot_exceed_10000ms() {
assert_eq!(
run("9;1;12345"),
Command::ConemuSleep { duration_ms: 10000 }
);
}
#[test]
fn osc_9_1_conemu_sleep_invalid_input() {
assert_eq!(run("9;1;foo"), Command::ConemuSleep { duration_ms: 100 });
}
#[test]
fn osc_9_1_conemu_sleep_desktop_notification_1() {
notif("9;1", "1");
}
#[test]
fn osc_9_1_conemu_sleep_desktop_notification_2() {
notif("9;1a", "1a");
}
#[test]
fn osc_9_2_conemu_message_box() {
assert_eq!(
run("9;2;hello world"),
Command::ConemuShowMessageBox(b"hello world".to_vec())
);
}
#[test]
fn osc_9_2_conemu_message_box_invalid_input() {
notif("9;2", "2");
}
#[test]
fn osc_9_2_conemu_message_box_empty_message() {
assert_eq!(run("9;2;"), Command::ConemuShowMessageBox(Vec::new()));
}
#[test]
fn osc_9_2_conemu_message_box_spaces_only_message() {
assert_eq!(
run("9;2; "),
Command::ConemuShowMessageBox(b" ".to_vec())
);
}
#[test]
fn osc_9_2_message_box_desktop_notification_1() {
notif("9;2", "2");
}
#[test]
fn osc_9_2_message_box_desktop_notification_2() {
notif("9;2a", "2a");
}
#[test]
fn osc_9_3_conemu_change_tab_title() {
assert_eq!(
run("9;3;foo bar"),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b"foo bar".to_vec()))
);
}
#[test]
fn osc_9_3_conemu_change_tab_title_reset() {
assert_eq!(
run("9;3;"),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset)
);
}
#[test]
fn osc_9_3_conemu_change_tab_title_spaces_only() {
assert_eq!(
run("9;3; "),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b" ".to_vec()))
);
}
#[test]
fn osc_9_3_change_tab_title_desktop_notification_1() {
notif("9;3", "3");
}
#[test]
fn osc_9_3_message_box_desktop_notification_2() {
notif("9;3a", "3a");
}
#[test]
fn osc_9_4_conemu_progress_set() {
progress("9;4;1;100", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_set_overflow() {
progress("9;4;1;900", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_set_single_digit() {
progress("9;4;1;9", ProgressState::Set, Some(9));
}
#[test]
fn osc_9_4_conemu_progress_set_double_digit() {
progress("9;4;1;94", ProgressState::Set, Some(94));
}
#[test]
fn osc_9_4_conemu_progress_set_extra_semicolon_ignored() {
progress("9;4;1;100", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_remove_with_no_progress() {
progress("9;4;0;", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_with_double_semicolon() {
progress("9;4;0;;", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_ignores_progress() {
progress("9;4;0;100", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_extra_semicolon() {
match run("9;4;0;100;") {
Command::ConemuProgressReport(r) => assert_eq!(r.state, ProgressState::Remove),
c => panic!("unexpected {c:?}"),
}
}
#[test]
fn osc_9_4_conemu_progress_error() {
progress("9;4;2", ProgressState::Error, None);
}
#[test]
fn osc_9_4_conemu_progress_error_with_progress() {
progress("9;4;2;100", ProgressState::Error, Some(100));
}
#[test]
fn osc_9_4_progress_pause() {
progress("9;4;4", ProgressState::Pause, None);
}
#[test]
fn osc_9_4_conemu_progress_pause_with_progress() {
progress("9;4;4;100", ProgressState::Pause, Some(100));
}
#[test]
fn osc_9_4_progress_desktop_notification_1() {
notif("9;4", "4");
}
#[test]
fn osc_9_4_progress_desktop_notification_2() {
notif("9;4;", "4;");
}
#[test]
fn osc_9_4_progress_desktop_notification_3() {
notif("9;4;5", "4;5");
}
#[test]
fn osc_9_4_progress_desktop_notification_4() {
notif("9;4;5a", "4;5a");
}
#[test]
fn osc_9_5_conemu_wait_input() {
assert_eq!(run("9;5"), Command::ConemuWaitInput);
}
#[test]
fn osc_9_5_conemu_wait_ignores_trailing_characters() {
assert_eq!(run("9;5;foo"), Command::ConemuWaitInput);
}
#[test]
fn osc_9_6_conemu_guimacro_1() {
assert_eq!(run("9;6;a"), Command::ConemuGuimacro(b"a".to_vec()));
}
#[test]
fn osc_9_6_conemu_guimacro_2() {
assert_eq!(run("9;6;ab"), Command::ConemuGuimacro(b"ab".to_vec()));
}
#[test]
fn osc_9_6_conemu_guimacro_3_incomplete_desktop_notification() {
notif("9;6", "6");
}
#[test]
fn osc_9_7_conemu_run_process_1() {
assert_eq!(run("9;7;ab"), Command::ConemuRunProcess(b"ab".to_vec()));
}
#[test]
fn osc_9_7_conemu_run_process_2() {
assert_eq!(run("9;7;"), Command::ConemuRunProcess(Vec::new()));
}
#[test]
fn osc_9_7_conemu_run_process_incomplete_desktop_notification() {
notif("9;7", "7");
}
#[test]
fn osc_9_8_conemu_output_environment_variable_1() {
assert_eq!(
run("9;8;ab"),
Command::ConemuOutputEnvironmentVariable(b"ab".to_vec())
);
}
#[test]
fn osc_9_8_conemu_output_environment_variable_2() {
assert_eq!(
run("9;8;"),
Command::ConemuOutputEnvironmentVariable(Vec::new())
);
}
#[test]
fn osc_9_8_conemu_output_environment_variable_incomplete_desktop_notification() {
notif("9;8", "8");
}
#[test]
fn osc_9_9_conemu_set_current_working_directory() {
assert_eq!(
run("9;9;ab"),
Command::ReportPwd {
value: b"ab".to_vec()
}
);
}
#[test]
fn osc_9_9_conemu_set_current_working_directory_incomplete_desktop_notification() {
notif("9;9", "9");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_1() {
xterm("9;10", Some(true), Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_2() {
xterm("9;10;0", Some(false), Some(false));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_3() {
xterm("9;10;1", Some(true), Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_4() {
xterm("9;10;2", None, Some(false));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_5() {
xterm("9;10;3", None, Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_6() {
notif("9;10;4", "10;4");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_7() {
notif("9;10;", "10;");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_8() {
notif("9;10;abc", "10;abc");
}
#[test]
fn osc_9_11_conemu_comment() {
assert_eq!(run("9;11;ab"), Command::ConemuComment(b"ab".to_vec()));
}
#[test]
fn osc_9_11_conemu_comment_incomplete_desktop_notification() {
notif("9;11", "11");
}
#[test]
fn osc_9_12_conemu_mark_prompt_start_1() {
assert!(matches!(run("9;12"), Command::SemanticPrompt(_)));
}
#[test]
fn osc_9_12_conemu_mark_prompt_start_2() {
assert!(matches!(run("9;12;abc"), Command::SemanticPrompt(_)));
}
}