//! Runtime XTVERSION probe (`CSI > 0 q` → `DCS > | text ST`), run when //! env-based brand detection yields Unknown (SSH, plain xterm) or a //! headfully-validated allowlisted brand (see [`gate_allows_probe`]). //! //! Fire-and-forget, parser-integrated model (as in helix and similar TUIs): //! the query is written once at startup with no timed read; the reply is //! recognized and swallowed by the event loop's `XtversionFilter` whenever //! it arrives. //! //! Safety invariants: //! - Query write must happen after `enable_raw_mode()` and before the //! `EventStream` filter is constructed. //! - Accepted residuals: SSH *from* JediTerm still probes (its env marker //! doesn't cross SSH) and leaks the query there; a reply whose first //! event arrives only after the filter's 5s arm window types as //! Alt+Shift+P + literal text; on a silent terminal with a fully idle //! session the `OnceLock` stays unset (`record_no_reply` only runs from //! the filter, which only runs on input) — `detected()` is None either //! way, so both consumers are unaffected. use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; /// Startup probe outcome. #[derive(Debug)] enum ProbeResult { Skipped, NoReply, Identified(String), } /// Unset while the query is in flight (or never sent). static XTVERSION: OnceLock = OnceLock::new(); /// True once the query bytes were written to the terminal. static QUERY_SENT: AtomicBool = AtomicBool::new(false); /// XTVERSION query alone — no DA1 sentinel: nothing waits on reply /// ordering here, and a stale unsolicited DA1 reply could mis-answer a /// future crossterm DA1-waiting probe. #[cfg(unix)] const QUERY: &[u8] = b"\x1b[>0q"; /// Returns the terminal's self-reported name/version, if the terminal /// answered (e.g. `"kitty 0.35.2"`, `"foot(1.22.0)"`). pub fn detected() -> Option<&'static str> { match XTVERSION.get() { Some(ProbeResult::Identified(v)) => Some(v), _ => None, } } /// True when the query was sent and no reply has been recorded yet — the /// event loop arms its response filter on this. pub fn reply_pending() -> bool { QUERY_SENT.load(Ordering::Relaxed) && XTVERSION.get().is_none() } /// Record the DCS payload recognized by the event-loop filter. pub fn record_reply(payload: &str) { let result = match sanitize_payload(payload) { Some(v) => ProbeResult::Identified(v), None => ProbeResult::NoReply, }; tracing::info!(?result, "XTVERSION probe"); let _ = XTVERSION.set(result); } /// Record that the filter disarmed without seeing a reply. Only invoked /// from the filter on input, so a fully idle session can leave the /// `OnceLock` unset (benign — see module doc). pub fn record_no_reply() { if XTVERSION.set(ProbeResult::NoReply).is_ok() { tracing::info!("XTVERSION probe: no reply"); } } /// Send the XTVERSION query once at startup (fire-and-forget); no-ops when /// the gate rejects the brand/multiplexer or stdin is not a TTY. pub fn probe_at_startup() { use std::io::IsTerminal; if XTVERSION.get().is_some() || QUERY_SENT.load(Ordering::Relaxed) { return; } let ctx = super::terminal_context(); if !gate_allows_probe(ctx) || !std::io::stdin().is_terminal() { let _ = XTVERSION.set(ProbeResult::Skipped); return; } send_query(); } /// Crush-style brand allowlist: Unknown plus brands headfully validated as /// clean XTVERSION responders (version fidelity is the payoff there). /// CSI-intercepting multiplexers skip — the innermost layer answers as /// itself, which the `multiplexer` field already records. Transparent muxes /// (e.g. cmux) need no special case. fn gate_allows_probe(ctx: &super::TerminalContext) -> bool { use super::TerminalName::*; matches!( ctx.brand, Unknown | Kitty | WezTerm | Ghostty | Iterm2 | Rio ) && !ctx.multiplexer.intercepts_csi_queries() } #[cfg(unix)] fn send_query() { if super::probe::write_query(QUERY) { QUERY_SENT.store(true, Ordering::Relaxed); } else { // Brand-Unknown TTY whose query can't reach the terminal is a // feedback-triage signal worth tracing. tracing::debug!("XTVERSION probe skipped: query write failed or output is not a TTY"); let _ = XTVERSION.set(ProbeResult::Skipped); } } #[cfg(not(unix))] fn send_query() { // ConPTY does not implement XTVERSION. let _ = XTVERSION.set(ProbeResult::Skipped); } /// Strip controls and trim; `None` for an empty payload. fn sanitize_payload(payload: &str) -> Option { let cleaned: String = payload.chars().filter(|c| !c.is_control()).collect(); let cleaned = cleaned.trim().to_owned(); if cleaned.is_empty() { None } else { Some(cleaned) } } #[cfg(test)] mod tests { use super::*; #[test] fn sanitize_plain_payload() { assert_eq!( sanitize_payload("kitty 0.35.2").as_deref(), Some("kitty 0.35.2") ); assert_eq!( sanitize_payload("XTerm(388)").as_deref(), Some("XTerm(388)") ); } #[test] fn sanitize_strips_controls_and_whitespace() { assert_eq!( sanitize_payload(" We\x01zTerm 2.0 ").as_deref(), Some("WezTerm 2.0") ); } #[test] fn sanitize_empty_is_none() { assert_eq!(sanitize_payload(""), None); assert_eq!(sanitize_payload(" \x07 "), None); } #[test] fn gate_allows_unknown_and_allowlisted_brands() { use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName}; let ctx = |brand, multiplexer| TerminalContext { brand, multiplexer, ..Default::default() }; for brand in [ TerminalName::Unknown, TerminalName::Kitty, TerminalName::WezTerm, TerminalName::Ghostty, TerminalName::Iterm2, TerminalName::Rio, ] { assert!( gate_allows_probe(&ctx(brand, MultiplexerKind::Undetected)), "{brand:?} should be probed" ); // Transparent mux (cmux) does not intercept CSI; probe still runs. assert!( gate_allows_probe(&ctx(brand, MultiplexerKind::Cmux)), "{brand:?} under cmux should still be probed" ); // CSI-intercepting multiplexers override the brand allowlist. assert!( !gate_allows_probe(&ctx(brand, MultiplexerKind::Tmux)), "{brand:?} under tmux should be skipped" ); } // JediTerm renders the query as garbage and must never be probed. assert!(!gate_allows_probe(&ctx( TerminalName::JetBrains, MultiplexerKind::Undetected ))); } // Sets the process-global OnceLock — safe under nextest's // process-per-test isolation. #[test] fn diagnostics_snapshot_includes_recorded_reply() { record_reply("PtyHarnessTerm 9.9"); let t = crate::terminal::terminal_context().diagnostics_snapshot(); assert_eq!(t.xtversion, "PtyHarnessTerm 9.9"); } }