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            if param.trim() == "4" {
149                caps.sixel_graphics = true
150            }
151        }
152    }
153}
154
155/// Probe for bracketed paste support (from termquery.c)
156pub fn probe_bracketed_paste() -> bool {
157    // Most modern terminals support this
158    if let Ok(term) = std::env::var("TERM") {
159        !term.starts_with("dumb") && !term.starts_with("cons")
160    } else {
161        false
162    }
163}
164
165/// Handle paste mode (from termquery.c handle_paste)
166pub fn enable_bracketed_paste() -> String {
167    "\x1b[?2004h".to_string()
168}
169
170pub fn disable_bracketed_paste() -> String {
171    "\x1b[?2004l".to_string()
172}
173
174/// URL encode a string (from termquery.c url_encode)
175pub fn url_encode(s: &str) -> String {
176    let mut result = String::with_capacity(s.len() * 3);
177    for b in s.bytes() {
178        match b {
179            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
180                result.push(b as char);
181            }
182            _ => {
183                result.push_str(&format!("%{:02X}", b));
184            }
185        }
186    }
187    result
188}
189
190/// Get clipboard via OSC 52 (from termquery.c system_clipget)
191pub fn system_clipget() -> Option<String> {
192    // OSC 52: ESC ] 52 ; c ; <base64-data> ST
193    // This is read asynchronously from the terminal
194    None // Requires terminal response handling
195}
196
197/// Set clipboard via OSC 52 (from termquery.c system_clipput)
198pub fn system_clipput(data: &str) -> String {
199    use std::io::Write;
200    let mut buf = Vec::new();
201    {
202        let encoder = base64_encode(data.as_bytes());
203        buf.extend_from_slice(b"\x1b]52;c;");
204        buf.extend_from_slice(encoder.as_bytes());
205        buf.extend_from_slice(b"\x1b\\");
206    }
207    String::from_utf8_lossy(&buf).to_string()
208}
209
210fn base64_encode(data: &[u8]) -> String {
211    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
212    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
213    for chunk in data.chunks(3) {
214        let b0 = chunk[0] as u32;
215        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
216        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
217        let n = (b0 << 16) | (b1 << 8) | b2;
218        result.push(CHARS[((n >> 18) & 63) as usize] as char);
219        result.push(CHARS[((n >> 12) & 63) as usize] as char);
220        if chunk.len() > 1 {
221            result.push(CHARS[((n >> 6) & 63) as usize] as char);
222        } else {
223            result.push('=');
224        }
225        if chunk.len() > 2 {
226            result.push(CHARS[(n & 63) as usize] as char);
227        } else {
228            result.push('=');
229        }
230    }
231    result
232}
233
234/// Check if extension is enabled (from termquery.c extension_enabled)
235pub fn extension_enabled(name: &str) -> bool {
236    match name {
237        "bracketed-paste" => probe_bracketed_paste(),
238        "truecolor" => std::env::var("COLORTERM")
239            .map(|v| v == "truecolor" || v == "24bit")
240            .unwrap_or(false),
241        "osc7" | "osc133" => std::env::var("TERM_PROGRAM")
242            .map(|v| matches!(v.as_str(), "iTerm.app" | "WezTerm" | "kitty"))
243            .unwrap_or(false),
244        _ => false,
245    }
246}
247
248/// Set cursor shape (from termquery.c zle_set_cursorform)
249pub fn set_cursor_shape(shape: CursorShape) -> String {
250    match shape {
251        CursorShape::Block => "\x1b[2 q".to_string(),
252        CursorShape::Underline => "\x1b[4 q".to_string(),
253        CursorShape::Bar => "\x1b[6 q".to_string(),
254        CursorShape::BlinkingBlock => "\x1b[1 q".to_string(),
255        CursorShape::BlinkingUnderline => "\x1b[3 q".to_string(),
256        CursorShape::BlinkingBar => "\x1b[5 q".to_string(),
257        CursorShape::Default => "\x1b[0 q".to_string(),
258    }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum CursorShape {
263    Default,
264    BlinkingBlock,
265    Block,
266    BlinkingUnderline,
267    Underline,
268    BlinkingBar,
269    Bar,
270}
271
272/// Notify terminal of current working directory (from termquery.c notify_pwd)
273pub fn notify_pwd(path: &str) -> String {
274    // OSC 7: file://hostname/path
275    let hostname = crate::utils::gethostname();
276    format!("\x1b]7;file://{}{}\x1b\\", hostname, url_encode(path))
277}
278
279/// Prompt markers for shell integration (from termquery.c prompt_markers/mark_output)
280pub fn prompt_marker_start() -> &'static str {
281    "\x1b]133;A\x1b\\" // OSC 133;A = prompt start
282}
283
284pub fn prompt_marker_end() -> &'static str {
285    "\x1b]133;B\x1b\\" // OSC 133;B = command start
286}
287
288pub fn output_marker_start() -> &'static str {
289    "\x1b]133;C\x1b\\" // OSC 133;C = command output start
290}
291
292pub fn output_marker_end(exit_code: i32) -> String {
293    format!("\x1b]133;D;{}\x1b\\", exit_code) // OSC 133;D = command end
294}
295
296/// Enable/disable synchronized output (from termquery.c)
297pub fn sync_output_start() -> &'static str {
298    "\x1b[?2026h"
299}
300
301pub fn sync_output_end() -> &'static str {
302    "\x1b[?2026l"
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_url_encode() {
311        assert_eq!(url_encode("/home/user"), "/home/user");
312        assert_eq!(url_encode("/path with spaces"), "/path%20with%20spaces");
313        assert_eq!(url_encode("hello&world"), "hello%26world");
314    }
315
316    #[test]
317    fn test_cursor_shape() {
318        assert_eq!(set_cursor_shape(CursorShape::Bar), "\x1b[6 q");
319        assert_eq!(set_cursor_shape(CursorShape::Block), "\x1b[2 q");
320    }
321
322    #[test]
323    fn test_bracketed_paste() {
324        assert_eq!(enable_bracketed_paste(), "\x1b[?2004h");
325        assert_eq!(disable_bracketed_paste(), "\x1b[?2004l");
326    }
327
328    #[test]
329    fn test_base64_encode() {
330        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
331        assert_eq!(base64_encode(b""), "");
332        assert_eq!(base64_encode(b"a"), "YQ==");
333    }
334
335    #[test]
336    fn test_prompt_markers() {
337        assert!(prompt_marker_start().contains("133;A"));
338        assert!(prompt_marker_end().contains("133;B"));
339    }
340}