Skip to main content

zsh/zle/
termquery.rs

1//! Terminal feature probing for ZLE
2//!
3//! Port from zsh/Src/Zle/termquery.c (968 lines)
4//!
5//! Probes the terminal for capabilities using escape sequence queries:
6//! device attributes, color support, bracketed paste, clipboard,
7//! cursor shape, URL encoding, and OSC sequences.
8
9use std::io::{self, Read, Write};
10use std::time::Duration;
11
12/// Terminal capabilities discovered by probing
13#[derive(Debug, Clone, Default)]
14pub struct TermCapabilities {
15    pub truecolor: bool,
16    pub bracketed_paste: bool,
17    pub clipboard_osc52: bool,
18    pub cursor_shape: bool,
19    pub osc7_cwd: bool,
20    pub osc133_prompt: bool,
21    pub sixel_graphics: bool,
22    pub kitty_keyboard: bool,
23    pub synchronized_output: bool,
24    pub unicode_version: Option<String>,
25}
26
27/// Default probe timeout (from termquery.c TIMEOUT)
28const PROBE_TIMEOUT_MS: u64 = 500;
29
30/// Query the terminal for supported features (from termquery.c query_terminal)
31pub fn query_terminal() -> TermCapabilities {
32    let mut caps = TermCapabilities::default();
33
34    // Only probe if stdout is a tty
35    #[cfg(unix)]
36    {
37        if unsafe { libc::isatty(1) } != 1 {
38            return caps;
39        }
40    }
41
42    // Send Device Attributes query (DA1): ESC [ c
43    if let Ok(response) = send_query("\x1b[c", PROBE_TIMEOUT_MS) {
44        parse_device_attributes(&response, &mut caps);
45    }
46
47    // Check COLORTERM for truecolor
48    if let Ok(ct) = std::env::var("COLORTERM") {
49        if ct == "truecolor" || ct == "24bit" {
50            caps.truecolor = true;
51        }
52    }
53
54    // Check for known terminal emulators
55    if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
56        match term_program.as_str() {
57            "iTerm.app" | "WezTerm" | "Alacritty" | "kitty" => {
58                caps.truecolor = true;
59                caps.bracketed_paste = true;
60                caps.osc7_cwd = true;
61            }
62            _ => {}
63        }
64    }
65
66    if std::env::var("KITTY_WINDOW_ID").is_ok() {
67        caps.kitty_keyboard = true;
68        caps.truecolor = true;
69    }
70
71    caps
72}
73
74/// Send an escape sequence query and read the response
75fn send_query(query: &str, timeout_ms: u64) -> io::Result<String> {
76    #[cfg(unix)]
77    {
78        // Set terminal to raw mode for reading response
79        let mut old_termios: libc::termios = unsafe { std::mem::zeroed() };
80        let has_old = unsafe { libc::tcgetattr(0, &mut old_termios) } == 0;
81
82        if has_old {
83            let mut raw = old_termios;
84            raw.c_lflag &= !(libc::ICANON | libc::ECHO);
85            raw.c_cc[libc::VMIN] = 0;
86            raw.c_cc[libc::VTIME] = (timeout_ms / 100).min(255) as u8;
87            unsafe { libc::tcsetattr(0, libc::TCSANOW, &raw) };
88        }
89
90        // Write query
91        let _ = io::stdout().write_all(query.as_bytes());
92        let _ = io::stdout().flush();
93
94        // Read response
95        let mut response = Vec::new();
96        let mut buf = [0u8; 1];
97        let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
98
99        while std::time::Instant::now() < deadline {
100            match io::stdin().read(&mut buf) {
101                Ok(1) => {
102                    response.push(buf[0]);
103                    // Check for terminal response ending characters
104                    if buf[0] == b'c'
105                        || buf[0] == b'n'
106                        || buf[0] == b't'
107                        || buf[0] == b'\\'
108                        || buf[0] == 0x07
109                    {
110                        break;
111                    }
112                }
113                Ok(0) => break,
114                _ => break,
115            }
116        }
117
118        // Restore terminal
119        if has_old {
120            unsafe { libc::tcsetattr(0, libc::TCSANOW, &old_termios) };
121        }
122
123        Ok(String::from_utf8_lossy(&response).to_string())
124    }
125
126    #[cfg(not(unix))]
127    {
128        let _ = (query, timeout_ms);
129        Ok(String::new())
130    }
131}
132
133/// Parse DA1 response (from termquery.c handle_query)
134fn parse_device_attributes(response: &str, caps: &mut TermCapabilities) {
135    // DA1 response format: ESC [ ? Ps ; Ps ; ... c
136    // Common parameter values:
137    // 4 = sixel graphics
138    // 22 = ANSI color
139    // 28 = rectangular editing
140    if response.contains("?") {
141        let params: Vec<&str> = response
142            .trim_start_matches("\x1b[?")
143            .trim_end_matches('c')
144            .split(';')
145            .collect();
146
147        for param in params {
148            match param.trim() {
149                "4" => caps.sixel_graphics = true,
150                _ => {}
151            }
152        }
153    }
154}
155
156/// Probe for bracketed paste support (from termquery.c)
157pub fn probe_bracketed_paste() -> bool {
158    // Most modern terminals support this
159    if let Ok(term) = std::env::var("TERM") {
160        !term.starts_with("dumb") && !term.starts_with("cons")
161    } else {
162        false
163    }
164}
165
166/// Handle paste mode (from termquery.c handle_paste)
167pub fn enable_bracketed_paste() -> String {
168    "\x1b[?2004h".to_string()
169}
170
171pub fn disable_bracketed_paste() -> String {
172    "\x1b[?2004l".to_string()
173}
174
175/// URL encode a string (from termquery.c url_encode)
176pub fn url_encode(s: &str) -> String {
177    let mut result = String::with_capacity(s.len() * 3);
178    for b in s.bytes() {
179        match b {
180            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
181                result.push(b as char);
182            }
183            _ => {
184                result.push_str(&format!("%{:02X}", b));
185            }
186        }
187    }
188    result
189}
190
191/// Get clipboard via OSC 52 (from termquery.c system_clipget)
192pub fn system_clipget() -> Option<String> {
193    // OSC 52: ESC ] 52 ; c ; <base64-data> ST
194    // This is read asynchronously from the terminal
195    None // Requires terminal response handling
196}
197
198/// Set clipboard via OSC 52 (from termquery.c system_clipput)
199pub fn system_clipput(data: &str) -> String {
200    use std::io::Write;
201    let mut buf = Vec::new();
202    {
203        let encoder = base64_encode(data.as_bytes());
204        buf.extend_from_slice(b"\x1b]52;c;");
205        buf.extend_from_slice(encoder.as_bytes());
206        buf.extend_from_slice(b"\x1b\\");
207    }
208    String::from_utf8_lossy(&buf).to_string()
209}
210
211fn base64_encode(data: &[u8]) -> String {
212    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
213    let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
214    for chunk in data.chunks(3) {
215        let b0 = chunk[0] as u32;
216        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
217        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
218        let n = (b0 << 16) | (b1 << 8) | b2;
219        result.push(CHARS[((n >> 18) & 63) as usize] as char);
220        result.push(CHARS[((n >> 12) & 63) as usize] as char);
221        if chunk.len() > 1 {
222            result.push(CHARS[((n >> 6) & 63) as usize] as char);
223        } else {
224            result.push('=');
225        }
226        if chunk.len() > 2 {
227            result.push(CHARS[(n & 63) as usize] as char);
228        } else {
229            result.push('=');
230        }
231    }
232    result
233}
234
235/// Check if extension is enabled (from termquery.c extension_enabled)
236pub fn extension_enabled(name: &str) -> bool {
237    match name {
238        "bracketed-paste" => probe_bracketed_paste(),
239        "truecolor" => std::env::var("COLORTERM")
240            .map(|v| v == "truecolor" || v == "24bit")
241            .unwrap_or(false),
242        "osc7" | "osc133" => std::env::var("TERM_PROGRAM")
243            .map(|v| matches!(v.as_str(), "iTerm.app" | "WezTerm" | "kitty"))
244            .unwrap_or(false),
245        _ => false,
246    }
247}
248
249/// Set cursor shape (from termquery.c zle_set_cursorform)
250pub fn set_cursor_shape(shape: CursorShape) -> String {
251    match shape {
252        CursorShape::Block => "\x1b[2 q".to_string(),
253        CursorShape::Underline => "\x1b[4 q".to_string(),
254        CursorShape::Bar => "\x1b[6 q".to_string(),
255        CursorShape::BlinkingBlock => "\x1b[1 q".to_string(),
256        CursorShape::BlinkingUnderline => "\x1b[3 q".to_string(),
257        CursorShape::BlinkingBar => "\x1b[5 q".to_string(),
258        CursorShape::Default => "\x1b[0 q".to_string(),
259    }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum CursorShape {
264    Default,
265    BlinkingBlock,
266    Block,
267    BlinkingUnderline,
268    Underline,
269    BlinkingBar,
270    Bar,
271}
272
273/// Notify terminal of current working directory (from termquery.c notify_pwd)
274pub fn notify_pwd(path: &str) -> String {
275    // OSC 7: file://hostname/path
276    let hostname = crate::utils::gethostname();
277    format!("\x1b]7;file://{}{}\x1b\\", hostname, url_encode(path))
278}
279
280/// Prompt markers for shell integration (from termquery.c prompt_markers/mark_output)
281pub fn prompt_marker_start() -> &'static str {
282    "\x1b]133;A\x1b\\" // OSC 133;A = prompt start
283}
284
285pub fn prompt_marker_end() -> &'static str {
286    "\x1b]133;B\x1b\\" // OSC 133;B = command start
287}
288
289pub fn output_marker_start() -> &'static str {
290    "\x1b]133;C\x1b\\" // OSC 133;C = command output start
291}
292
293pub fn output_marker_end(exit_code: i32) -> String {
294    format!("\x1b]133;D;{}\x1b\\", exit_code) // OSC 133;D = command end
295}
296
297/// Enable/disable synchronized output (from termquery.c)
298pub fn sync_output_start() -> &'static str {
299    "\x1b[?2026h"
300}
301
302pub fn sync_output_end() -> &'static str {
303    "\x1b[?2026l"
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_url_encode() {
312        assert_eq!(url_encode("/home/user"), "/home/user");
313        assert_eq!(url_encode("/path with spaces"), "/path%20with%20spaces");
314        assert_eq!(url_encode("hello&world"), "hello%26world");
315    }
316
317    #[test]
318    fn test_cursor_shape() {
319        assert_eq!(set_cursor_shape(CursorShape::Bar), "\x1b[6 q");
320        assert_eq!(set_cursor_shape(CursorShape::Block), "\x1b[2 q");
321    }
322
323    #[test]
324    fn test_bracketed_paste() {
325        assert_eq!(enable_bracketed_paste(), "\x1b[?2004h");
326        assert_eq!(disable_bracketed_paste(), "\x1b[?2004l");
327    }
328
329    #[test]
330    fn test_base64_encode() {
331        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
332        assert_eq!(base64_encode(b""), "");
333        assert_eq!(base64_encode(b"a"), "YQ==");
334    }
335
336    #[test]
337    fn test_prompt_markers() {
338        assert!(prompt_marker_start().contains("133;A"));
339        assert!(prompt_marker_end().contains("133;B"));
340    }
341}