aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 03:30:31 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 03:32:40 +0300
commit6b61ab6a3a37216549d59a4c929c9d6abc68ac36 (patch)
treef37b4e2050c7d17c8554d26392c0817165043e44 /src/pane
parent1257c1e5cbb6487448ab10f2b6d153af7059e65e (diff)
fix: answer xtgettcap queries in panes
refs #393
Diffstat (limited to 'src/pane')
-rw-r--r--src/pane/osc.rs37
-rw-r--r--src/pane/terminal.rs314
-rw-r--r--src/pane/xtgettcap.rs329
3 files changed, 650 insertions, 30 deletions
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 1512f336..37407ade 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -29,6 +29,12 @@ pub(super) enum DefaultColorEvent {
PaletteQuery(u8),
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) struct DefaultColorTrackedEvent {
+ pub(super) end_offset: usize,
+ pub(super) event: DefaultColorEvent,
+}
+
#[derive(Debug, Default)]
pub(super) struct DefaultColorOscTracker {
state: DefaultColorOscTrackerState,
@@ -145,12 +151,12 @@ fn is_default_color_set_osc(body: &[u8]) -> bool {
pub(super) struct DefaultColorEventTracker {
state: DefaultColorOscTrackerState,
body: Vec<u8>,
- pending: Vec<DefaultColorEvent>,
+ pending: Vec<DefaultColorTrackedEvent>,
}
impl DefaultColorEventTracker {
pub(super) fn observe(&mut self, bytes: &[u8]) {
- for &byte in bytes {
+ for (index, &byte) in bytes.iter().enumerate() {
match self.state {
DefaultColorOscTrackerState::Ground => {
if byte == 0x1b {
@@ -172,7 +178,7 @@ impl DefaultColorEventTracker {
}
DefaultColorOscTrackerState::OscBody => match byte {
0x07 => {
- self.finalize();
+ self.finalize(index + 1);
self.state = DefaultColorOscTrackerState::Ground;
}
0x1b => self.state = DefaultColorOscTrackerState::OscEscape,
@@ -180,7 +186,7 @@ impl DefaultColorEventTracker {
},
DefaultColorOscTrackerState::OscEscape => {
if byte == b'\\' {
- self.finalize();
+ self.finalize(index + 1);
self.state = DefaultColorOscTrackerState::Ground;
} else {
self.body.push(0x1b);
@@ -223,14 +229,15 @@ impl DefaultColorEventTracker {
}
}
- fn finalize(&mut self) {
+ fn finalize(&mut self, end_offset: usize) {
if let Some(event) = parse_default_color_event(&self.body) {
- self.pending.push(event);
+ self.pending
+ .push(DefaultColorTrackedEvent { end_offset, event });
}
self.body.clear();
}
- pub(super) fn drain_pending(&mut self) -> Vec<DefaultColorEvent> {
+ pub(super) fn drain_pending(&mut self) -> Vec<DefaultColorTrackedEvent> {
std::mem::take(&mut self.pending)
}
}
@@ -571,6 +578,12 @@ mod tests {
}
}
+ fn tracked_default_color_events(
+ events: Vec<DefaultColorTrackedEvent>,
+ ) -> Vec<DefaultColorEvent> {
+ events.into_iter().map(|event| event.event).collect()
+ }
+
#[test]
fn default_color_tracker_detects_split_osc_11_sequences() {
let mut tracker = DefaultColorOscTracker::default();
@@ -596,7 +609,7 @@ mod tests {
);
assert_eq!(
- tracker.drain_pending(),
+ tracked_default_color_events(tracker.drain_pending()),
vec![
DefaultColorEvent::Query(DefaultColorQuery::Foreground),
DefaultColorEvent::Query(DefaultColorQuery::Background),
@@ -618,7 +631,7 @@ mod tests {
tracker.observe(b"\\");
assert_eq!(
- tracker.drain_pending(),
+ tracked_default_color_events(tracker.drain_pending()),
vec![DefaultColorEvent::Query(DefaultColorQuery::Background)]
);
}
@@ -634,7 +647,7 @@ mod tests {
tracker.observe(b"\\");
assert_eq!(
- tracker.drain_pending(),
+ tracked_default_color_events(tracker.drain_pending()),
vec![DefaultColorEvent::PaletteQuery(255)]
);
}
@@ -651,7 +664,7 @@ mod tests {
tracker.observe(b"\x1b]4;0;?\x07");
assert_eq!(
- tracker.drain_pending(),
+ tracked_default_color_events(tracker.drain_pending()),
vec![DefaultColorEvent::PaletteQuery(0)]
);
}
@@ -680,7 +693,7 @@ mod tests {
tracker.observe(b"\x1b]11;?\x07");
assert_eq!(
- tracker.drain_pending(),
+ tracked_default_color_events(tracker.drain_pending()),
vec![DefaultColorEvent::Query(DefaultColorQuery::Background)]
);
}
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 75b4a8a5..be1200e6 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -24,8 +24,9 @@ use super::{
contains_scrollback_clear_sequence, current_transient_default_color_owner,
maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed,
write_host_terminal_theme, DefaultColorEvent, DefaultColorEventTracker,
- DefaultColorOscTracker, DefaultColorQuery, Osc52Forwarder,
+ DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder,
},
+ xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
};
const DEFAULT_DETECTION_ROWS: usize = 24;
@@ -122,6 +123,7 @@ pub(crate) struct GhosttyPaneCore {
pub child_default_foreground_changed: bool,
pub child_default_background_changed: bool,
pub osc52_forwarder: Osc52Forwarder,
+ pub xtgettcap_query_tracker: XtgettcapQueryTracker,
}
pub(crate) struct PaneTerminal {
@@ -333,6 +335,7 @@ impl GhosttyPaneTerminal {
child_default_foreground_changed: false,
child_default_background_changed: false,
osc52_forwarder: Osc52Forwarder::default(),
+ xtgettcap_query_tracker: XtgettcapQueryTracker::default(),
}),
key_encoder: Mutex::new(key_encoder),
pending_pty_responses,
@@ -445,11 +448,19 @@ impl GhosttyPaneTerminal {
let mut terminal_responses = Vec::new();
core.default_color_event_tracker
.observe(filtered_bytes.as_ref());
- respond_to_default_color_events(&mut core, &mut terminal_responses);
+ core.xtgettcap_query_tracker
+ .observe(filtered_bytes.as_ref());
+ let default_color_events = core.default_color_event_tracker.drain_pending();
+ let xtgettcap_responses = core.xtgettcap_query_tracker.drain_pending();
let write_started = crate::render_prof::timer();
- core.terminal.write(filtered_bytes.as_ref());
+ self.write_pty_bytes_with_ordered_responses(
+ &mut core,
+ filtered_bytes.as_ref(),
+ default_color_events,
+ xtgettcap_responses,
+ &mut terminal_responses,
+ );
crate::render_prof::duration_since("pty.ghostty_write", write_started);
- terminal_responses.extend(self.drain_pending_pty_responses());
let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled()
&& contains_kitty_graphics_sequence(filtered_bytes.as_ref());
@@ -483,6 +494,51 @@ impl GhosttyPaneTerminal {
}
}
+ fn write_pty_bytes_with_ordered_responses(
+ &self,
+ core: &mut GhosttyPaneCore,
+ bytes: &[u8],
+ default_color_events: Vec<DefaultColorTrackedEvent>,
+ xtgettcap_responses: Vec<XtgettcapResponse>,
+ terminal_responses: &mut Vec<Bytes>,
+ ) {
+ let mut events = Vec::with_capacity(default_color_events.len() + xtgettcap_responses.len());
+ events.extend(
+ default_color_events
+ .into_iter()
+ .map(OrderedPtyResponseEvent::DefaultColor),
+ );
+ events.extend(
+ xtgettcap_responses
+ .into_iter()
+ .map(OrderedPtyResponseEvent::Xtgettcap),
+ );
+ events.sort_by_key(OrderedPtyResponseEvent::end_offset);
+
+ let mut written = 0;
+ for event in events {
+ let end_offset = event.end_offset().min(bytes.len());
+ if end_offset > written {
+ core.terminal.write(&bytes[written..end_offset]);
+ terminal_responses.extend(self.drain_pending_pty_responses());
+ written = end_offset;
+ }
+ match event {
+ OrderedPtyResponseEvent::DefaultColor(event) => {
+ respond_to_default_color_event(core, terminal_responses, event.event);
+ }
+ OrderedPtyResponseEvent::Xtgettcap(response) => {
+ terminal_responses.push(response.bytes);
+ }
+ }
+ }
+
+ if written < bytes.len() {
+ core.terminal.write(&bytes[written..]);
+ terminal_responses.extend(self.drain_pending_pty_responses());
+ }
+ }
+
fn drain_pending_pty_responses(&self) -> Vec<Bytes> {
self.pending_pty_responses
.lock()
@@ -1577,7 +1633,10 @@ fn ghostty_cell_style(
std::mem::swap(&mut fg, &mut bg);
}
- let style = ghostty_default_style(fg, bg);
+ let mut style = ghostty_default_style(fg, bg);
+ if let Some(underline_color) = basic.style.underline_color.map(ghostty_cell_color) {
+ style = style.underline_color(underline_color);
+ }
let mut modifiers = Modifier::empty();
if basic.style.bold {
modifiers |= Modifier::BOLD;
@@ -1600,25 +1659,39 @@ fn ghostty_cell_style(
style.add_modifier(modifiers)
}
-fn respond_to_default_color_events(
+#[derive(Debug)]
+enum OrderedPtyResponseEvent {
+ DefaultColor(DefaultColorTrackedEvent),
+ Xtgettcap(XtgettcapResponse),
+}
+
+impl OrderedPtyResponseEvent {
+ fn end_offset(&self) -> usize {
+ match self {
+ Self::DefaultColor(event) => event.end_offset,
+ Self::Xtgettcap(response) => response.end_offset,
+ }
+ }
+}
+
+fn respond_to_default_color_event(
core: &mut GhosttyPaneCore,
terminal_responses: &mut Vec<Bytes>,
+ event: DefaultColorEvent,
) {
- for event in core.default_color_event_tracker.drain_pending() {
- match event {
- DefaultColorEvent::Query(query) => {
- if let Some(response) = default_color_query_response(query, core) {
- terminal_responses.push(response);
- }
+ match event {
+ DefaultColorEvent::Query(query) => {
+ if let Some(response) = default_color_query_response(query, core) {
+ terminal_responses.push(response);
}
- DefaultColorEvent::PaletteQuery(index) => {
- if let Some(response) = palette_color_query_response(index, core) {
- terminal_responses.push(response);
- }
+ }
+ DefaultColorEvent::PaletteQuery(index) => {
+ if let Some(response) = palette_color_query_response(index, core) {
+ terminal_responses.push(response);
}
- DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true),
- DefaultColorEvent::Reset(query) => mark_child_default_color_changed(core, query, false),
}
+ DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true),
+ DefaultColorEvent::Reset(query) => mark_child_default_color_changed(core, query, false),
}
}
@@ -1808,6 +1881,24 @@ mod tests {
Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\"))
}
+ fn expected_xtgettcap_response(cap_hex: &str, value: Option<&[u8]>) -> Bytes {
+ let mut response = format!("\x1bP1+r{cap_hex}").into_bytes();
+ 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<u8>) {
+ const HEX: &[u8; 16] = b"0123456789ABCDEF";
+ for &byte in bytes {
+ output.push(HEX[usize::from(byte >> 4)]);
+ output.push(HEX[usize::from(byte & 0x0f)]);
+ }
+ }
+
#[test]
fn decscusr_cursor_shape_preserves_blinking_variants() {
assert_eq!(
@@ -2692,6 +2783,193 @@ mod tests {
}
#[test]
+ fn process_pty_bytes_returns_xtgettcap_truecolor_query_responses_without_queuing_input() {
+ let (tx, mut rx) = mpsc::channel(8);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let result = pane.process_pty_bytes(
+ pane_id,
+ 0,
+ b"\x1bP+q5463;524742;73657472676266;73657472676262\x1b\\",
+ &tx,
+ );
+
+ assert_eq!(
+ result.terminal_responses,
+ vec![
+ expected_xtgettcap_response("5463", None),
+ expected_xtgettcap_response("524742", Some(b"8")),
+ expected_xtgettcap_response("73657472676266", Some(b"\\E[38:2:%p1%d:%p2%d:%p3%dm")),
+ expected_xtgettcap_response("73657472676262", Some(b"\\E[48:2:%p1%d:%p2%d:%p3%dm")),
+ ]
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_returns_split_xtgettcap_query_response() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q4", &tx);
+ assert!(result.terminal_responses.is_empty());
+ assert!(rx.try_recv().is_err());
+ let result = pane.process_pty_bytes(pane_id, 0, b"D73\x1b", &tx);
+ assert!(result.terminal_responses.is_empty());
+ assert!(rx.try_recv().is_err());
+ let result = pane.process_pty_bytes(pane_id, 0, b"\\", &tx);
+
+ assert_eq!(
+ result.terminal_responses,
+ vec![expected_xtgettcap_response(
+ "4D73",
+ Some(b"\\E]52;%p1%s;%p2%s\\007")
+ )]
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_orders_device_attribute_reply_before_following_xtgettcap_reply() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[c\x1bP+q5463\x1b\\", &tx);
+
+ assert_eq!(result.terminal_responses.len(), 2);
+ assert!(String::from_utf8_lossy(&result.terminal_responses[0]).contains('c'));
+ assert_eq!(
+ result.terminal_responses[1],
+ expected_xtgettcap_response("5463", None)
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_orders_xtgettcap_reply_before_following_device_attribute_reply() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5463\x1b\\\x1b[c", &tx);
+
+ assert_eq!(result.terminal_responses.len(), 2);
+ assert_eq!(
+ result.terminal_responses[0],
+ expected_xtgettcap_response("5463", None)
+ );
+ assert!(String::from_utf8_lossy(&result.terminal_responses[1]).contains('c'));
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_orders_xtgettcap_reply_before_following_default_color_reply() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+ pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
+ foreground: None,
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x00,
+ g: 0x2b,
+ b: 0x36,
+ }),
+ });
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5463\x1b\\\x1b]11;?\x07", &tx);
+
+ assert_eq!(
+ result.terminal_responses,
+ vec![
+ expected_xtgettcap_response("5463", None),
+ Bytes::from_static(b"\x1b]11;rgb:0000/2b2b/3636\x1b\\"),
+ ]
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_recovers_xtgettcap_after_osc_bel_terminator() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]0;title\x07\x1bP+q5463\x1b\\", &tx);
+
+ assert_eq!(
+ result.terminal_responses,
+ vec![expected_xtgettcap_response("5463", None)]
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_ignores_unknown_and_unsupported_xtgettcap_queries() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let result =
+ pane.process_pty_bytes(pane_id, 0, b"\x1bP+q6E6F7065;536D756C78;4D7\x1b\\", &tx);
+
+ assert!(result.terminal_responses.is_empty());
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn process_pty_bytes_returns_underline_color_xtgettcap_query_responses() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5375;536574756C63\x1b\\", &tx);
+
+ assert_eq!(
+ result.terminal_responses,
+ vec![
+ expected_xtgettcap_response("5375", None),
+ expected_xtgettcap_response(
+ "536574756C63",
+ Some(b"\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m")
+ ),
+ ]
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn render_preserves_underline_color() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+ {
+ let mut core = pane.core.lock().unwrap();
+ core.terminal.write(b"\x1b[4m\x1b[58:2::17:34:51mU");
+ }
+
+ let backend = ratatui::backend::TestBackend::new(20, 5);
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
+ .unwrap();
+
+ let style = terminal.backend().buffer()[(0, 0)].style();
+ assert!(style.add_modifier.contains(Modifier::UNDERLINED));
+ assert_eq!(style.underline_color, Some(Color::Rgb(17, 34, 51)));
+ }
+
+ #[test]
fn process_pty_bytes_orders_default_color_reply_before_following_device_attribute_reply() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
diff --git a/src/pane/xtgettcap.rs b/src/pane/xtgettcap.rs
new file mode 100644
index 00000000..f0a91df5
--- /dev/null
+++ b/src/pane/xtgettcap.rs
@@ -0,0 +1,329 @@
+use bytes::Bytes;
+
+#[derive(Debug, Default)]
+pub(super) struct XtgettcapQueryTracker {
+ state: XtgettcapTrackerState,
+ body: Vec<u8>,
+ pending: Vec<XtgettcapResponse>,
+}
+
+#[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<XtgettcapResponse> {
+ std::mem::take(&mut self.pending)
+ }
+}
+
+fn xtgettcap_response(cap_hex: &[u8]) -> Option<Bytes> {
+ 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<Option<&'static [u8]>> {
+ // Mirror only the Ghostty terminfo capabilities that this pane path can
+ // stand behind. Smulx is intentionally absent until underline shapes render.
+ 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"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<u8>) {
+ 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<XtgettcapResponse>) -> Vec<Bytes> {
+ 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+q536D756C78;6E6F7065\x1b\\");
+
+ assert!(response_bytes(tracker.drain_pending()).is_empty());
+ }
+
+ #[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\\"),
+ }]
+ );
+ }
+}