Skip to main content

agent_tui/
pty.rs

1//! PTY management using portable-pty
2//!
3//! This module provides PTY creation and management for spawning
4//! and controlling terminal applications.
5
6use crate::sync_utils::mutex_lock_or_recover;
7use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
8use std::io::{Read, Write};
9use std::os::fd::RawFd;
10use std::sync::{Arc, Mutex};
11use thiserror::Error;
12
13#[derive(Error, Debug)]
14pub enum PtyError {
15    #[error("Failed to open PTY: {0}")]
16    OpenFailed(String),
17    #[error("Failed to spawn process: {0}")]
18    SpawnFailed(String),
19    #[error("Failed to write to PTY: {0}")]
20    WriteFailed(String),
21    #[error("Failed to read from PTY: {0}")]
22    ReadFailed(String),
23    #[error("Failed to resize PTY: {0}")]
24    ResizeFailed(String),
25    #[error("Process not running")]
26    NotRunning,
27}
28
29pub struct PtyHandle {
30    master: Box<dyn MasterPty + Send>,
31    child: Box<dyn Child + Send + Sync>,
32    reader: Arc<Mutex<Box<dyn Read + Send>>>,
33    writer: Arc<Mutex<Box<dyn Write + Send>>>,
34    size: PtySize,
35    reader_fd: RawFd,
36}
37
38impl PtyHandle {
39    /// Spawn a new PTY with the given command
40    pub fn spawn(
41        command: &str,
42        args: &[String],
43        cwd: Option<&str>,
44        env: Option<&std::collections::HashMap<String, String>>,
45        cols: u16,
46        rows: u16,
47    ) -> Result<Self, PtyError> {
48        let pty_system = native_pty_system();
49
50        let size = PtySize {
51            rows,
52            cols,
53            pixel_width: 0,
54            pixel_height: 0,
55        };
56
57        let pair = pty_system
58            .openpty(size)
59            .map_err(|e| PtyError::OpenFailed(e.to_string()))?;
60
61        let mut cmd = CommandBuilder::new(command);
62        cmd.args(args);
63
64        if let Some(dir) = cwd {
65            cmd.cwd(dir);
66        }
67
68        if let Some(env_vars) = env {
69            for (key, value) in env_vars {
70                cmd.env(key, value);
71            }
72        }
73
74        // Set TERM for proper terminal emulation
75        cmd.env("TERM", "xterm-256color");
76
77        let child = pair
78            .slave
79            .spawn_command(cmd)
80            .map_err(|e| PtyError::SpawnFailed(e.to_string()))?;
81
82        let reader = pair
83            .master
84            .try_clone_reader()
85            .map_err(|e| PtyError::OpenFailed(e.to_string()))?;
86
87        // Get raw fd for non-blocking poll from the master
88        let reader_fd = pair
89            .master
90            .as_raw_fd()
91            .ok_or_else(|| PtyError::OpenFailed("Failed to get master fd".to_string()))?;
92
93        let writer = pair
94            .master
95            .take_writer()
96            .map_err(|e| PtyError::OpenFailed(e.to_string()))?;
97
98        Ok(Self {
99            master: pair.master,
100            child,
101            reader: Arc::new(Mutex::new(reader)),
102            writer: Arc::new(Mutex::new(writer)),
103            size,
104            reader_fd,
105        })
106    }
107
108    /// Get the process ID
109    pub fn pid(&self) -> Option<u32> {
110        self.child.process_id()
111    }
112
113    /// Check if the process is still running
114    pub fn is_running(&mut self) -> bool {
115        self.child
116            .try_wait()
117            .map(|status| status.is_none())
118            .unwrap_or(false)
119    }
120
121    /// Write data to the PTY
122    pub fn write(&self, data: &[u8]) -> Result<(), PtyError> {
123        let mut writer = mutex_lock_or_recover(&self.writer);
124        writer
125            .write_all(data)
126            .map_err(|e| PtyError::WriteFailed(e.to_string()))?;
127        writer
128            .flush()
129            .map_err(|e| PtyError::WriteFailed(e.to_string()))?;
130        Ok(())
131    }
132
133    /// Write a string to the PTY
134    pub fn write_str(&self, s: &str) -> Result<(), PtyError> {
135        self.write(s.as_bytes())
136    }
137
138    /// Read available data from the PTY (non-blocking)
139    pub fn read(&self, buf: &mut [u8]) -> Result<usize, PtyError> {
140        let mut reader = mutex_lock_or_recover(&self.reader);
141        reader
142            .read(buf)
143            .map_err(|e| PtyError::ReadFailed(e.to_string()))
144    }
145
146    /// Read available data without blocking
147    /// Returns Ok(0) if no data available within timeout
148    pub fn try_read(&self, buf: &mut [u8], timeout_ms: i32) -> Result<usize, PtyError> {
149        // Use poll to check if data is available
150        let mut pollfd = libc::pollfd {
151            fd: self.reader_fd,
152            events: libc::POLLIN,
153            revents: 0,
154        };
155
156        let result = unsafe { libc::poll(&mut pollfd, 1, timeout_ms) };
157
158        if result < 0 {
159            return Err(PtyError::ReadFailed("poll failed".to_string()));
160        }
161
162        if result == 0 {
163            // Timeout, no data available
164            return Ok(0);
165        }
166
167        // Data available, read it
168        let mut reader = mutex_lock_or_recover(&self.reader);
169        reader
170            .read(buf)
171            .map_err(|e| PtyError::ReadFailed(e.to_string()))
172    }
173
174    /// Resize the PTY
175    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<(), PtyError> {
176        self.size = PtySize {
177            rows,
178            cols,
179            pixel_width: 0,
180            pixel_height: 0,
181        };
182        self.master
183            .resize(self.size)
184            .map_err(|e| PtyError::ResizeFailed(e.to_string()))
185    }
186
187    /// Get the current size
188    pub fn size(&self) -> (u16, u16) {
189        (self.size.cols, self.size.rows)
190    }
191
192    /// Kill the process
193    pub fn kill(&mut self) -> Result<(), PtyError> {
194        self.child
195            .kill()
196            .map_err(|e| PtyError::SpawnFailed(e.to_string()))
197    }
198
199    /// Wait for the process to exit
200    pub fn wait(&mut self) -> Result<Option<portable_pty::ExitStatus>, PtyError> {
201        self.child
202            .try_wait()
203            .map_err(|e| PtyError::SpawnFailed(e.to_string()))
204    }
205
206    /// Get a clone of the reader for async operations
207    pub fn reader(&self) -> Arc<Mutex<Box<dyn Read + Send>>> {
208        Arc::clone(&self.reader)
209    }
210
211    /// Get the reader file descriptor for polling
212    pub fn reader_fd(&self) -> RawFd {
213        self.reader_fd
214    }
215}
216
217/// Parse a key string into escape sequences
218pub fn key_to_escape_sequence(key: &str) -> Option<Vec<u8>> {
219    // Handle modifier combinations
220    if key.contains('+') {
221        let parts: Vec<&str> = key.split('+').collect();
222        if parts.len() == 2 {
223            let modifier = parts[0];
224            let base_key = parts[1];
225
226            return match modifier.to_lowercase().as_str() {
227                "ctrl" | "control" => {
228                    // Ctrl+letter produces ASCII 1-26
229                    if base_key.len() == 1 {
230                        let c = base_key.chars().next()?.to_ascii_uppercase();
231                        if c.is_ascii_alphabetic() {
232                            return Some(vec![(c as u8) - b'A' + 1]);
233                        }
234                    }
235                    // Special ctrl combinations
236                    match base_key.to_lowercase().as_str() {
237                        "c" => Some(vec![3]),   // Ctrl+C (SIGINT)
238                        "d" => Some(vec![4]),   // Ctrl+D (EOF)
239                        "z" => Some(vec![26]),  // Ctrl+Z (SIGTSTP)
240                        "\\" => Some(vec![28]), // Ctrl+\ (SIGQUIT)
241                        "[" => Some(vec![27]),  // Ctrl+[ (ESC)
242                        _ => None,
243                    }
244                }
245                "alt" | "meta" => {
246                    // Alt+key sends ESC followed by the key
247                    let base = key_to_escape_sequence(base_key)?;
248                    let mut result = vec![0x1b]; // ESC
249                    result.extend(base);
250                    Some(result)
251                }
252                "shift" => {
253                    // For function keys, shift modifies the escape sequence
254                    match base_key.to_lowercase().as_str() {
255                        "tab" => Some(vec![0x1b, b'[', b'Z']), // Shift+Tab
256                        _ => {
257                            // For letters, just uppercase
258                            if base_key.len() == 1 {
259                                Some(base_key.to_uppercase().as_bytes().to_vec())
260                            } else {
261                                None
262                            }
263                        }
264                    }
265                }
266                _ => None,
267            };
268        }
269    }
270
271    // Handle single keys
272    match key {
273        // Standard keys
274        "Enter" | "Return" => Some(vec![b'\r']),
275        "Tab" => Some(vec![b'\t']),
276        "Escape" | "Esc" => Some(vec![0x1b]),
277        "Backspace" => Some(vec![0x7f]),
278        "Delete" => Some(vec![0x1b, b'[', b'3', b'~']),
279        "Space" => Some(vec![b' ']),
280
281        // Arrow keys
282        "ArrowUp" | "Up" => Some(vec![0x1b, b'[', b'A']),
283        "ArrowDown" | "Down" => Some(vec![0x1b, b'[', b'B']),
284        "ArrowRight" | "Right" => Some(vec![0x1b, b'[', b'C']),
285        "ArrowLeft" | "Left" => Some(vec![0x1b, b'[', b'D']),
286
287        // Navigation keys
288        "Home" => Some(vec![0x1b, b'[', b'H']),
289        "End" => Some(vec![0x1b, b'[', b'F']),
290        "PageUp" => Some(vec![0x1b, b'[', b'5', b'~']),
291        "PageDown" => Some(vec![0x1b, b'[', b'6', b'~']),
292        "Insert" => Some(vec![0x1b, b'[', b'2', b'~']),
293
294        // Function keys
295        "F1" => Some(vec![0x1b, b'O', b'P']),
296        "F2" => Some(vec![0x1b, b'O', b'Q']),
297        "F3" => Some(vec![0x1b, b'O', b'R']),
298        "F4" => Some(vec![0x1b, b'O', b'S']),
299        "F5" => Some(vec![0x1b, b'[', b'1', b'5', b'~']),
300        "F6" => Some(vec![0x1b, b'[', b'1', b'7', b'~']),
301        "F7" => Some(vec![0x1b, b'[', b'1', b'8', b'~']),
302        "F8" => Some(vec![0x1b, b'[', b'1', b'9', b'~']),
303        "F9" => Some(vec![0x1b, b'[', b'2', b'0', b'~']),
304        "F10" => Some(vec![0x1b, b'[', b'2', b'1', b'~']),
305        "F11" => Some(vec![0x1b, b'[', b'2', b'3', b'~']),
306        "F12" => Some(vec![0x1b, b'[', b'2', b'4', b'~']),
307
308        // Single character
309        _ if key.len() == 1 => Some(key.as_bytes().to_vec()),
310
311        _ => None,
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_key_to_escape_sequence() {
321        assert_eq!(key_to_escape_sequence("Enter"), Some(vec![b'\r']));
322        assert_eq!(key_to_escape_sequence("Tab"), Some(vec![b'\t']));
323        assert_eq!(key_to_escape_sequence("Escape"), Some(vec![0x1b]));
324        assert_eq!(
325            key_to_escape_sequence("ArrowUp"),
326            Some(vec![0x1b, b'[', b'A'])
327        );
328        assert_eq!(key_to_escape_sequence("Ctrl+C"), Some(vec![3]));
329        assert_eq!(key_to_escape_sequence("a"), Some(vec![b'a']));
330    }
331}