use bytes::Bytes; #[derive(Debug, Default)] pub(super) struct XtgettcapQueryTracker { state: XtgettcapTrackerState, body: Vec, pending: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct XtgettcapResponse { pub(super) end_offset: usize, pub(super) bytes: Bytes, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum XtgettcapTrackerState { #[default] Ground, Escape, DcsIntro, DcsIntroPlus, DcsBody, DcsEscape, IgnoreOsc, IgnoreOscEscape, IgnoreString, IgnoreStringEscape, OversizedDcs, OversizedDcsEscape, } impl XtgettcapQueryTracker { pub(super) fn observe(&mut self, bytes: &[u8]) { for (index, &byte) in bytes.iter().enumerate() { match self.state { XtgettcapTrackerState::Ground => { if byte == 0x1b { self.state = XtgettcapTrackerState::Escape; } else if byte == 0x90 { self.body.clear(); self.state = XtgettcapTrackerState::DcsIntro; } else if byte == 0x9d { self.state = XtgettcapTrackerState::IgnoreOsc; } else if matches!(byte, 0x98 | 0x9e | 0x9f) { self.state = XtgettcapTrackerState::IgnoreString; } } XtgettcapTrackerState::Escape => match byte { b'P' => { self.body.clear(); self.state = XtgettcapTrackerState::DcsIntro; } b']' => { self.body.clear(); self.state = XtgettcapTrackerState::IgnoreOsc; } b'_' | b'^' | b'X' => { self.body.clear(); self.state = XtgettcapTrackerState::IgnoreString; } 0x1b => self.state = XtgettcapTrackerState::Escape, _ => self.state = XtgettcapTrackerState::Ground, }, XtgettcapTrackerState::DcsIntro => match byte { b'+' => self.state = XtgettcapTrackerState::DcsIntroPlus, 0x1b => self.state = XtgettcapTrackerState::IgnoreStringEscape, 0x9c => self.state = XtgettcapTrackerState::Ground, _ => self.state = XtgettcapTrackerState::IgnoreString, }, XtgettcapTrackerState::DcsIntroPlus => match byte { b'q' => self.state = XtgettcapTrackerState::DcsBody, 0x1b => self.state = XtgettcapTrackerState::IgnoreStringEscape, 0x9c => self.state = XtgettcapTrackerState::Ground, _ => self.state = XtgettcapTrackerState::IgnoreString, }, XtgettcapTrackerState::DcsBody => match byte { 0x1b => self.state = XtgettcapTrackerState::DcsEscape, 0x9c => { self.finalize(index + 1); self.state = XtgettcapTrackerState::Ground; } _ => self.body.push(byte), }, XtgettcapTrackerState::DcsEscape => { if byte == b'\\' { self.finalize(index + 1); self.state = XtgettcapTrackerState::Ground; } else if byte != 0x1b { self.body.clear(); self.state = XtgettcapTrackerState::IgnoreString; } } XtgettcapTrackerState::IgnoreOsc => { if byte == 0x1b { self.state = XtgettcapTrackerState::IgnoreOscEscape; } else if matches!(byte, 0x07 | 0x9c) { self.state = XtgettcapTrackerState::Ground; } } XtgettcapTrackerState::IgnoreOscEscape => { if byte == b'\\' { self.state = XtgettcapTrackerState::Ground; } else if byte != 0x1b { self.state = XtgettcapTrackerState::IgnoreOsc; } } XtgettcapTrackerState::IgnoreString => { if byte == 0x1b { self.state = XtgettcapTrackerState::IgnoreStringEscape; } else if byte == 0x9c { self.state = XtgettcapTrackerState::Ground; } } XtgettcapTrackerState::IgnoreStringEscape => { if byte == b'\\' { self.state = XtgettcapTrackerState::Ground; } else if byte != 0x1b { self.state = XtgettcapTrackerState::IgnoreString; } } XtgettcapTrackerState::OversizedDcs => { if byte == 0x1b { self.state = XtgettcapTrackerState::OversizedDcsEscape; } else if byte == 0x9c { self.state = XtgettcapTrackerState::Ground; } } XtgettcapTrackerState::OversizedDcsEscape => { if byte == b'\\' { self.state = XtgettcapTrackerState::Ground; } else if byte != 0x1b { self.state = XtgettcapTrackerState::OversizedDcs; } } } if self.body.len() > 1024 { self.body.clear(); self.state = XtgettcapTrackerState::OversizedDcs; } } } fn finalize(&mut self, end_offset: usize) { for cap_hex in self.body.split(|byte| *byte == b';') { if let Some(bytes) = xtgettcap_response(cap_hex) { self.pending.push(XtgettcapResponse { end_offset, bytes }); } } self.body.clear(); } pub(super) fn drain_pending(&mut self) -> Vec { std::mem::take(&mut self.pending) } } fn xtgettcap_response(cap_hex: &[u8]) -> Option { if cap_hex.is_empty() || !cap_hex.len().is_multiple_of(2) { return None; } let mut normalized_cap_hex = Vec::with_capacity(cap_hex.len()); for &byte in cap_hex { if !byte.is_ascii_hexdigit() { return None; } normalized_cap_hex.push(byte.to_ascii_uppercase()); } let value = xtgettcap_value(&normalized_cap_hex)?; Some(build_xtgettcap_response(&normalized_cap_hex, value)) } fn xtgettcap_value(cap_hex: &[u8]) -> Option> { // Mirror only the Ghostty terminfo capabilities that this pane path can stand behind. match cap_hex { b"5463" => Some(None), b"524742" => Some(Some(b"8")), b"73657472676266" => Some(Some(b"\\E[38:2:%p1%d:%p2%d:%p3%dm")), b"73657472676262" => Some(Some(b"\\E[48:2:%p1%d:%p2%d:%p3%dm")), b"4D73" => Some(Some(b"\\E]52;%p1%s;%p2%s\\007")), b"5375" => Some(None), b"536D756C78" => Some(Some(b"\\E[4:%p1%dm")), b"536574756C63" => Some(Some( b"\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m", )), _ => None, } } fn build_xtgettcap_response(cap_hex: &[u8], value: Option<&[u8]>) -> Bytes { let mut response = Vec::with_capacity(8 + cap_hex.len() + value.map_or(0, |bytes| bytes.len() * 2)); response.extend_from_slice(b"\x1bP1+r"); response.extend_from_slice(cap_hex); if let Some(value) = value { response.push(b'='); append_upper_hex(value, &mut response); } response.extend_from_slice(b"\x1b\\"); Bytes::from(response) } fn append_upper_hex(bytes: &[u8], output: &mut Vec) { const HEX: &[u8; 16] = b"0123456789ABCDEF"; for &byte in bytes { output.push(HEX[usize::from(byte >> 4)]); output.push(HEX[usize::from(byte & 0x0f)]); } } #[cfg(test)] mod tests { use super::*; fn response_bytes(responses: Vec) -> Vec { responses .into_iter() .map(|response| response.bytes) .collect() } #[test] fn tracker_returns_multiple_capabilities_in_order() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"\x1bP+q5463;524742\x1b\\"); assert_eq!( response_bytes(tracker.drain_pending()), vec![ Bytes::from_static(b"\x1bP1+r5463\x1b\\"), Bytes::from_static(b"\x1bP1+r524742=38\x1b\\"), ] ); } #[test] fn tracker_normalizes_mixed_case_query_keys() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"\x1bP+q4d73\x1b\\"); assert_eq!( response_bytes(tracker.drain_pending()), vec![Bytes::from_static( b"\x1bP1+r4D73=5C455D35323B25703125733B25703225735C303037\x1b\\" )] ); } #[test] fn tracker_ignores_unsupported_capabilities() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"\x1bP+q6E6F7065\x1b\\"); assert!(response_bytes(tracker.drain_pending()).is_empty()); } #[test] fn tracker_returns_underline_style_capability() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"\x1bP+q536D756C78\x1b\\"); assert_eq!( response_bytes(tracker.drain_pending()), vec![Bytes::from_static( b"\x1bP1+r536D756C78=5C455B343A25703125646D\x1b\\" )] ); } #[test] fn tracker_keeps_split_query_until_string_terminator() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"\x1bP+q537"); assert!(response_bytes(tracker.drain_pending()).is_empty()); tracker.observe(b"5\x1b"); assert!(response_bytes(tracker.drain_pending()).is_empty()); tracker.observe(b"\\"); assert_eq!( response_bytes(tracker.drain_pending()), vec![Bytes::from_static(b"\x1bP1+r5375\x1b\\")] ); } #[test] fn tracker_resumes_after_ignored_osc_bel_terminator() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"\x1b]0;title\x07\x1bP+q5463\x1b\\"); assert_eq!( response_bytes(tracker.drain_pending()), vec![Bytes::from_static(b"\x1bP1+r5463\x1b\\")] ); } #[test] fn tracker_accepts_eight_bit_dcs_and_string_terminator() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"\x90+q5463\x9c"); assert_eq!( response_bytes(tracker.drain_pending()), vec![Bytes::from_static(b"\x1bP1+r5463\x1b\\")] ); } #[test] fn tracker_ignores_xtgettcap_bytes_inside_eight_bit_osc() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"\x9dtitle\x1bP+q5463\x9c\x1bP+q5463\x1b\\"); assert_eq!( response_bytes(tracker.drain_pending()), vec![Bytes::from_static(b"\x1bP1+r5463\x1b\\")] ); } #[test] fn tracker_reports_response_end_offsets() { let mut tracker = XtgettcapQueryTracker::default(); tracker.observe(b"before\x1bP+q5463\x1b\\after"); assert_eq!( tracker.drain_pending(), vec![XtgettcapResponse { end_offset: 16, bytes: Bytes::from_static(b"\x1bP1+r5463\x1b\\"), }] ); } }