Skip to main content

agent_tui/
terminal.rs

1//! Terminal emulation using vt100
2//!
3//! This module wraps the vt100 crate to provide headless terminal
4//! emulation with access to cell styling data for element detection.
5
6use crate::sync_utils::mutex_lock_or_recover;
7use std::sync::{Arc, Mutex};
8use vt100::Parser;
9
10/// Cell style information for element detection
11#[derive(Debug, Clone, Default)]
12pub struct CellStyle {
13    pub bold: bool,
14    pub italic: bool,
15    pub underline: bool,
16    pub inverse: bool,
17    pub fg_color: Option<Color>,
18    pub bg_color: Option<Color>,
19}
20
21#[derive(Debug, Clone)]
22pub enum Color {
23    Default,
24    Indexed(u8),
25    Rgb(u8, u8, u8),
26}
27
28/// A cell in the terminal screen
29#[derive(Debug, Clone)]
30pub struct Cell {
31    pub char: char,
32    pub style: CellStyle,
33}
34
35/// Screen buffer with cell data
36#[derive(Debug, Clone)]
37pub struct ScreenBuffer {
38    pub cells: Vec<Vec<Cell>>,
39    pub cols: u16,
40    pub rows: u16,
41}
42
43/// Cursor position
44#[derive(Debug, Clone)]
45pub struct CursorPosition {
46    pub row: u16,
47    pub col: u16,
48    pub visible: bool,
49}
50
51/// Virtual terminal using vt100
52pub struct VirtualTerminal {
53    parser: Arc<Mutex<Parser>>,
54    cols: u16,
55    rows: u16,
56}
57
58impl VirtualTerminal {
59    /// Create a new virtual terminal with the given dimensions
60    pub fn new(cols: u16, rows: u16) -> Self {
61        let parser = Parser::new(rows, cols, 0);
62        Self {
63            parser: Arc::new(Mutex::new(parser)),
64            cols,
65            rows,
66        }
67    }
68
69    /// Process input data (from PTY)
70    pub fn process(&self, data: &[u8]) {
71        let mut parser = mutex_lock_or_recover(&self.parser);
72        parser.process(data);
73    }
74
75    /// Get the screen as text
76    pub fn screen_text(&self) -> String {
77        let parser = mutex_lock_or_recover(&self.parser);
78        let screen = parser.screen();
79
80        let mut lines = Vec::new();
81        for row in 0..screen.size().0 {
82            let mut line = String::new();
83            for col in 0..screen.size().1 {
84                let cell = screen.cell(row, col);
85                if let Some(cell) = cell {
86                    line.push(cell.contents().chars().next().unwrap_or(' '));
87                } else {
88                    line.push(' ');
89                }
90            }
91            // Trim trailing whitespace but preserve the line
92            let trimmed = line.trim_end();
93            lines.push(trimmed.to_string());
94        }
95
96        // Remove trailing empty lines
97        while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
98            lines.pop();
99        }
100
101        lines.join("\n")
102    }
103
104    /// Get the screen buffer with style information
105    pub fn screen_buffer(&self) -> ScreenBuffer {
106        let parser = mutex_lock_or_recover(&self.parser);
107        let screen = parser.screen();
108
109        let mut cells = Vec::new();
110        for row in 0..screen.size().0 {
111            let mut row_cells = Vec::new();
112            for col in 0..screen.size().1 {
113                let cell = screen.cell(row, col);
114                let (char, style) = if let Some(cell) = cell {
115                    let c = cell.contents().chars().next().unwrap_or(' ');
116                    let s = CellStyle {
117                        bold: cell.bold(),
118                        italic: cell.italic(),
119                        underline: cell.underline(),
120                        inverse: cell.inverse(),
121                        fg_color: convert_color(cell.fgcolor()),
122                        bg_color: convert_color(cell.bgcolor()),
123                    };
124                    (c, s)
125                } else {
126                    (' ', CellStyle::default())
127                };
128                row_cells.push(Cell { char, style });
129            }
130            cells.push(row_cells);
131        }
132
133        ScreenBuffer {
134            cells,
135            cols: self.cols,
136            rows: self.rows,
137        }
138    }
139
140    /// Get cursor position
141    pub fn cursor(&self) -> CursorPosition {
142        let parser = mutex_lock_or_recover(&self.parser);
143        let screen = parser.screen();
144        let (row, col) = screen.cursor_position();
145
146        CursorPosition {
147            row,
148            col,
149            visible: !screen.hide_cursor(),
150        }
151    }
152
153    /// Resize the terminal
154    pub fn resize(&mut self, cols: u16, rows: u16) {
155        let mut parser = mutex_lock_or_recover(&self.parser);
156        parser.set_size(rows, cols);
157        self.cols = cols;
158        self.rows = rows;
159    }
160
161    /// Get the terminal size
162    pub fn size(&self) -> (u16, u16) {
163        (self.cols, self.rows)
164    }
165
166    /// Clear the terminal screen buffer
167    pub fn clear(&mut self) {
168        let rows = self.rows;
169        let cols = self.cols;
170        let mut parser = mutex_lock_or_recover(&self.parser);
171        parser.set_size(rows, cols);
172    }
173
174    /// Check if a cell has inverse style (often indicates focus/selection)
175    pub fn is_cell_inverse(&self, row: u16, col: u16) -> bool {
176        let parser = mutex_lock_or_recover(&self.parser);
177        let screen = parser.screen();
178        screen.cell(row, col).map(|c| c.inverse()).unwrap_or(false)
179    }
180
181    /// Check if a cell is bold
182    pub fn is_cell_bold(&self, row: u16, col: u16) -> bool {
183        let parser = mutex_lock_or_recover(&self.parser);
184        let screen = parser.screen();
185        screen.cell(row, col).map(|c| c.bold()).unwrap_or(false)
186    }
187
188    /// Get the style of a specific cell
189    pub fn cell_style(&self, row: u16, col: u16) -> Option<CellStyle> {
190        let parser = mutex_lock_or_recover(&self.parser);
191        let screen = parser.screen();
192        screen.cell(row, col).map(|cell| CellStyle {
193            bold: cell.bold(),
194            italic: cell.italic(),
195            underline: cell.underline(),
196            inverse: cell.inverse(),
197            fg_color: convert_color(cell.fgcolor()),
198            bg_color: convert_color(cell.bgcolor()),
199        })
200    }
201
202    /// Get a range of cells as styled text
203    pub fn get_styled_range(
204        &self,
205        row: u16,
206        start_col: u16,
207        end_col: u16,
208    ) -> Vec<(char, CellStyle)> {
209        let parser = mutex_lock_or_recover(&self.parser);
210        let screen = parser.screen();
211
212        let mut result = Vec::new();
213        for col in start_col..end_col {
214            if let Some(cell) = screen.cell(row, col) {
215                let c = cell.contents().chars().next().unwrap_or(' ');
216                let style = CellStyle {
217                    bold: cell.bold(),
218                    italic: cell.italic(),
219                    underline: cell.underline(),
220                    inverse: cell.inverse(),
221                    fg_color: convert_color(cell.fgcolor()),
222                    bg_color: convert_color(cell.bgcolor()),
223                };
224                result.push((c, style));
225            } else {
226                result.push((' ', CellStyle::default()));
227            }
228        }
229        result
230    }
231}
232
233fn convert_color(color: vt100::Color) -> Option<Color> {
234    match color {
235        vt100::Color::Default => Some(Color::Default),
236        vt100::Color::Idx(idx) => Some(Color::Indexed(idx)),
237        vt100::Color::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_basic_terminal() {
247        let term = VirtualTerminal::new(80, 24);
248        term.process(b"Hello, World!");
249        let text = term.screen_text();
250        assert!(text.contains("Hello, World!"));
251    }
252
253    #[test]
254    fn test_cursor_position() {
255        let term = VirtualTerminal::new(80, 24);
256        term.process(b"ABC");
257        let cursor = term.cursor();
258        assert_eq!(cursor.col, 3);
259        assert_eq!(cursor.row, 0);
260    }
261
262    #[test]
263    fn test_screen_buffer() {
264        let term = VirtualTerminal::new(80, 24);
265        term.process(b"\x1b[1mBold\x1b[0m Normal");
266        let buffer = term.screen_buffer();
267
268        // First character 'B' should be bold
269        assert!(buffer.cells[0][0].style.bold);
270        assert_eq!(buffer.cells[0][0].char, 'B');
271    }
272}