tenseleyflow/fackr / 1f7ab06

Browse files

feat: add integrated terminal (Phase 1)

Implement basic integrated terminal with:
- Ctrl+` to toggle terminal visibility
- ESC to hide terminal when focused
- PTY spawning using $SHELL environment variable
- VTE-based escape sequence parsing (colors, cursor, SGR)
- Terminal renders at bottom of screen (30% default height)
- Shell process persists when terminal is hidden
- Full input routing (control chars, arrows, function keys)

New modules:
- terminal/pty.rs: PTY spawning and I/O with reader thread
- terminal/screen.rs: Terminal buffer with VTE Perform impl
- terminal/panel.rs: Terminal panel visibility and lifecycle
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
1f7ab0603af8241faa99551d323bbdd5934e6e31
Parents
cc8608b
Tree
c01413c

7 changed files

StatusFile+-
M src/editor/state.rs 40 1
M src/main.rs 1 0
M src/render/screen.rs 93 0
A src/terminal/mod.rs 9 0
A src/terminal/panel.rs 220 0
A src/terminal/pty.rs 107 0
A src/terminal/screen.rs 450 0
src/editor/state.rsmodified
@@ -8,6 +8,7 @@ use crate::buffer::Buffer;
8
 use crate::input::{Key, Modifiers, Mouse, Button};
8
 use crate::input::{Key, Modifiers, Mouse, Button};
9
 use crate::lsp::{CompletionItem, Diagnostic, HoverInfo, Location, ServerManagerPanel};
9
 use crate::lsp::{CompletionItem, Diagnostic, HoverInfo, Location, ServerManagerPanel};
10
 use crate::render::{PaneBounds as RenderPaneBounds, PaneInfo, Screen, TabInfo};
10
 use crate::render::{PaneBounds as RenderPaneBounds, PaneInfo, Screen, TabInfo};
11
+use crate::terminal::TerminalPanel;
11
 use crate::workspace::{PaneDirection, Tab, Workspace};
12
 use crate::workspace::{PaneDirection, Tab, Workspace};
12
 
13
 
13
 use super::{Cursor, Cursors, History, Operation, Position};
14
 use super::{Cursor, Cursors, History, Operation, Position};
@@ -484,6 +485,8 @@ pub struct Editor {
484
     yank_index: Option<usize>,
485
     yank_index: Option<usize>,
485
     /// Length of last yank (for replacing when cycling)
486
     /// Length of last yank (for replacing when cycling)
486
     last_yank_len: usize,
487
     last_yank_len: usize,
488
+    /// Integrated terminal panel
489
+    terminal: TerminalPanel,
487
 }
490
 }
488
 
491
 
489
 impl Editor {
492
 impl Editor {
@@ -515,6 +518,9 @@ impl Editor {
515
         // Check if there are backups to restore
518
         // Check if there are backups to restore
516
         let has_backups = workspace.has_backups();
519
         let has_backups = workspace.has_backups();
517
 
520
 
521
+        // Create terminal panel with screen dimensions
522
+        let terminal = TerminalPanel::new(screen.cols, screen.rows);
523
+
518
         let mut editor = Self {
524
         let mut editor = Self {
519
             workspace,
525
             workspace,
520
             screen,
526
             screen,
@@ -532,6 +538,7 @@ impl Editor {
532
             yank_stack: Vec::with_capacity(32),
538
             yank_stack: Vec::with_capacity(32),
533
             yank_index: None,
539
             yank_index: None,
534
             last_yank_len: 0,
540
             last_yank_len: 0,
541
+            terminal,
535
         };
542
         };
536
 
543
 
537
         // If there are backups, show restore prompt
544
         // If there are backups, show restore prompt
@@ -735,6 +742,7 @@ impl Editor {
735
                     Event::Resize(cols, rows) => {
742
                     Event::Resize(cols, rows) => {
736
                         self.screen.cols = cols;
743
                         self.screen.cols = cols;
737
                         self.screen.rows = rows;
744
                         self.screen.rows = rows;
745
+                        self.terminal.update_screen_size(cols, rows);
738
                     }
746
                     }
739
                     _ => {}
747
                     _ => {}
740
                 }
748
                 }
@@ -748,12 +756,19 @@ impl Editor {
748
                         Event::Resize(cols, rows) => {
756
                         Event::Resize(cols, rows) => {
749
                             self.screen.cols = cols;
757
                             self.screen.cols = cols;
750
                             self.screen.rows = rows;
758
                             self.screen.rows = rows;
759
+                            self.terminal.update_screen_size(cols, rows);
751
                         }
760
                         }
752
                         _ => {}
761
                         _ => {}
753
                     }
762
                     }
754
                 }
763
                 }
755
             }
764
             }
756
 
765
 
766
+            // Poll terminal for output
767
+            if self.terminal.visible {
768
+                self.terminal.poll();
769
+                needs_render = true; // Terminal might have new output
770
+            }
771
+
757
             // Process LSP messages from language servers
772
             // Process LSP messages from language servers
758
             if self.process_lsp_messages() {
773
             if self.process_lsp_messages() {
759
                 needs_render = true;
774
                 needs_render = true;
@@ -1384,7 +1399,26 @@ impl Editor {
1384
 
1399
 
1385
     /// Process a key event, handling ESC as potential Alt prefix
1400
     /// Process a key event, handling ESC as potential Alt prefix
1386
     fn process_key(&mut self, key_event: KeyEvent) -> Result<()> {
1401
     fn process_key(&mut self, key_event: KeyEvent) -> Result<()> {
1387
-        use crossterm::event::KeyCode;
1402
+        use crossterm::event::{KeyCode, KeyModifiers};
1403
+
1404
+        // Ctrl+` toggles terminal (works in both editor and terminal mode)
1405
+        // Backtick is ` (grave accent)
1406
+        if key_event.code == KeyCode::Char('`') && key_event.modifiers.contains(KeyModifiers::CONTROL) {
1407
+            let _ = self.terminal.toggle();
1408
+            return Ok(());
1409
+        }
1410
+
1411
+        // If terminal is visible, route input to terminal
1412
+        if self.terminal.visible {
1413
+            // ESC hides terminal
1414
+            if key_event.code == KeyCode::Esc {
1415
+                self.terminal.hide();
1416
+                return Ok(());
1417
+            }
1418
+            // Send all other keys to terminal
1419
+            let _ = self.terminal.send_key(&key_event);
1420
+            return Ok(());
1421
+        }
1388
 
1422
 
1389
         // Check if this is a bare Escape key (potential Alt prefix)
1423
         // Check if this is a bare Escape key (potential Alt prefix)
1390
         if key_event.code == KeyCode::Esc && key_event.modifiers.is_empty() {
1424
         if key_event.code == KeyCode::Esc && key_event.modifiers.is_empty() {
@@ -1718,6 +1752,11 @@ impl Editor {
1718
                 self.screen.render_server_manager_panel(&self.server_manager)?;
1752
                 self.screen.render_server_manager_panel(&self.server_manager)?;
1719
             }
1753
             }
1720
 
1754
 
1755
+            // Render terminal panel if visible (overlays editor content)
1756
+            if self.terminal.visible {
1757
+                self.screen.render_terminal(&self.terminal)?;
1758
+            }
1759
+
1721
             // Render rename modal if active
1760
             // Render rename modal if active
1722
             if let PromptState::RenameModal { ref original_name, ref new_name, .. } = self.prompt {
1761
             if let PromptState::RenameModal { ref original_name, ref new_name, .. } = self.prompt {
1723
                 self.screen.render_rename_modal(original_name, new_name)?;
1762
                 self.screen.render_rename_modal(original_name, new_name)?;
src/main.rsmodified
@@ -5,6 +5,7 @@ mod input;
5
 mod lsp;
5
 mod lsp;
6
 mod render;
6
 mod render;
7
 mod syntax;
7
 mod syntax;
8
+mod terminal;
8
 mod util;
9
 mod util;
9
 mod workspace;
10
 mod workspace;
10
 
11
 
src/render/screen.rsmodified
@@ -17,6 +17,7 @@ use crate::editor::{Cursors, Position};
17
 use crate::fuss::VisibleItem;
17
 use crate::fuss::VisibleItem;
18
 use crate::lsp::{CompletionItem, Diagnostic, DiagnosticSeverity, HoverInfo, Location, ServerManagerPanel};
18
 use crate::lsp::{CompletionItem, Diagnostic, DiagnosticSeverity, HoverInfo, Location, ServerManagerPanel};
19
 use crate::syntax::{Highlighter, Token};
19
 use crate::syntax::{Highlighter, Token};
20
+use crate::terminal::TerminalPanel;
20
 
21
 
21
 // Editor color scheme (256-color palette)
22
 // Editor color scheme (256-color palette)
22
 const BG_COLOR: Color = Color::AnsiValue(234);           // Off-black editor background
23
 const BG_COLOR: Color = Color::AnsiValue(234);           // Off-black editor background
@@ -3710,4 +3711,96 @@ impl Screen {
3710
 
3711
 
3711
         Ok(())
3712
         Ok(())
3712
     }
3713
     }
3714
+
3715
+    /// Render the integrated terminal panel
3716
+    pub fn render_terminal(&mut self, terminal: &TerminalPanel) -> Result<()> {
3717
+        let start_row = terminal.render_start_row(self.rows);
3718
+        let height = terminal.height;
3719
+
3720
+        // Draw terminal border (top line with title)
3721
+        execute!(
3722
+            self.stdout,
3723
+            MoveTo(0, start_row),
3724
+            SetBackgroundColor(Color::AnsiValue(237)),
3725
+            SetForegroundColor(Color::White),
3726
+        )?;
3727
+
3728
+        // Terminal title bar
3729
+        let title = " Terminal ";
3730
+        let separator = "─".repeat((self.cols as usize).saturating_sub(title.len() + 2) / 2);
3731
+        execute!(
3732
+            self.stdout,
3733
+            Print(&separator),
3734
+            SetAttribute(Attribute::Bold),
3735
+            Print(title),
3736
+            SetAttribute(Attribute::Reset),
3737
+            SetBackgroundColor(Color::AnsiValue(237)),
3738
+            SetForegroundColor(Color::White),
3739
+            Print(&separator),
3740
+        )?;
3741
+
3742
+        // Pad to end of line
3743
+        let printed = separator.chars().count() * 2 + title.len();
3744
+        if printed < self.cols as usize {
3745
+            execute!(self.stdout, Print(" ".repeat(self.cols as usize - printed)))?;
3746
+        }
3747
+
3748
+        // Terminal content area (dark background)
3749
+        execute!(self.stdout, SetBackgroundColor(Color::AnsiValue(232)))?;
3750
+
3751
+        let (cursor_row, cursor_col) = terminal.cursor_pos();
3752
+
3753
+        for row in 0..(height - 1) {
3754
+            execute!(self.stdout, MoveTo(0, start_row + 1 + row))?;
3755
+
3756
+            // Render cells from terminal screen
3757
+            for col in 0..self.cols as usize {
3758
+                if let Some(cell) = terminal.get_cell(row as usize, col) {
3759
+                    // Set colors
3760
+                    let fg = TerminalPanel::to_crossterm_color(&cell.fg);
3761
+                    let bg = TerminalPanel::to_crossterm_color(&cell.bg);
3762
+
3763
+                    if cell.inverse {
3764
+                        execute!(
3765
+                            self.stdout,
3766
+                            SetForegroundColor(if bg == Color::Reset { Color::AnsiValue(232) } else { bg }),
3767
+                            SetBackgroundColor(if fg == Color::Reset { Color::White } else { fg }),
3768
+                        )?;
3769
+                    } else {
3770
+                        execute!(
3771
+                            self.stdout,
3772
+                            SetForegroundColor(if fg == Color::Reset { Color::White } else { fg }),
3773
+                            SetBackgroundColor(if bg == Color::Reset { Color::AnsiValue(232) } else { bg }),
3774
+                        )?;
3775
+                    }
3776
+
3777
+                    if cell.bold {
3778
+                        execute!(self.stdout, SetAttribute(Attribute::Bold))?;
3779
+                    }
3780
+                    if cell.underline {
3781
+                        execute!(self.stdout, SetAttribute(Attribute::Underlined))?;
3782
+                    }
3783
+
3784
+                    execute!(self.stdout, Print(cell.c))?;
3785
+
3786
+                    if cell.bold || cell.underline {
3787
+                        execute!(self.stdout, SetAttribute(Attribute::Reset))?;
3788
+                        execute!(self.stdout, SetBackgroundColor(Color::AnsiValue(232)))?;
3789
+                    }
3790
+                } else {
3791
+                    execute!(self.stdout, Print(' '))?;
3792
+                }
3793
+            }
3794
+        }
3795
+
3796
+        // Position cursor in terminal
3797
+        execute!(
3798
+            self.stdout,
3799
+            MoveTo(cursor_col, start_row + 1 + cursor_row),
3800
+            Show,
3801
+            ResetColor
3802
+        )?;
3803
+
3804
+        Ok(())
3805
+    }
3713
 }
3806
 }
src/terminal/mod.rsadded
@@ -0,0 +1,9 @@
1
+//! Integrated terminal panel
2
+//!
3
+//! Provides an embedded terminal emulator that can be toggled with Ctrl+`
4
+
5
+mod panel;
6
+mod pty;
7
+mod screen;
8
+
9
+pub use panel::TerminalPanel;
src/terminal/panel.rsadded
@@ -0,0 +1,220 @@
1
+//! Terminal panel
2
+//!
3
+//! The main interface for the integrated terminal.
4
+
5
+use anyhow::Result;
6
+
7
+use super::pty::Pty;
8
+use super::screen::{Cell, Color, TerminalScreen};
9
+
10
+/// Default terminal height as percentage of screen
11
+const DEFAULT_HEIGHT_PERCENT: u16 = 30;
12
+/// Maximum terminal height as percentage of screen
13
+const MAX_HEIGHT_PERCENT: u16 = 80;
14
+/// Minimum terminal height in rows
15
+const MIN_HEIGHT_ROWS: u16 = 3;
16
+
17
+/// Integrated terminal panel
18
+pub struct TerminalPanel {
19
+    /// PTY connection to shell
20
+    pty: Option<Pty>,
21
+    /// Terminal screen buffer
22
+    screen: TerminalScreen,
23
+    /// Whether the terminal is visible
24
+    pub visible: bool,
25
+    /// Terminal height in rows
26
+    pub height: u16,
27
+    /// Total screen height (for percentage calculations)
28
+    screen_height: u16,
29
+    /// Total screen width
30
+    screen_width: u16,
31
+}
32
+
33
+impl TerminalPanel {
34
+    /// Create a new terminal panel (not yet spawned)
35
+    pub fn new(screen_width: u16, screen_height: u16) -> Self {
36
+        let height = (screen_height * DEFAULT_HEIGHT_PERCENT / 100).max(MIN_HEIGHT_ROWS);
37
+        Self {
38
+            pty: None,
39
+            screen: TerminalScreen::new(screen_width, height),
40
+            visible: false,
41
+            height,
42
+            screen_height,
43
+            screen_width,
44
+        }
45
+    }
46
+
47
+    /// Toggle terminal visibility
48
+    pub fn toggle(&mut self) -> Result<()> {
49
+        self.visible = !self.visible;
50
+
51
+        // Spawn PTY on first show
52
+        if self.visible && self.pty.is_none() {
53
+            self.spawn()?;
54
+        }
55
+
56
+        Ok(())
57
+    }
58
+
59
+    /// Spawn the PTY process
60
+    fn spawn(&mut self) -> Result<()> {
61
+        let pty = Pty::spawn(self.screen_width, self.height)?;
62
+        self.pty = Some(pty);
63
+        Ok(())
64
+    }
65
+
66
+    /// Hide the terminal (ESC pressed)
67
+    pub fn hide(&mut self) {
68
+        self.visible = false;
69
+    }
70
+
71
+    /// Send input to the terminal
72
+    pub fn send_input(&mut self, data: &[u8]) -> Result<()> {
73
+        if let Some(ref mut pty) = self.pty {
74
+            pty.write(data)?;
75
+        }
76
+        Ok(())
77
+    }
78
+
79
+    /// Send a key to the terminal
80
+    pub fn send_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<()> {
81
+        use crossterm::event::{KeyCode, KeyModifiers};
82
+
83
+        let data: Vec<u8> = match key.code {
84
+            KeyCode::Char(c) => {
85
+                if key.modifiers.contains(KeyModifiers::CONTROL) {
86
+                    // Convert to control character
87
+                    let ctrl_char = (c.to_ascii_lowercase() as u8).wrapping_sub(b'a').wrapping_add(1);
88
+                    vec![ctrl_char]
89
+                } else if key.modifiers.contains(KeyModifiers::ALT) {
90
+                    // Alt sends ESC prefix
91
+                    vec![0x1b, c as u8]
92
+                } else {
93
+                    c.to_string().into_bytes()
94
+                }
95
+            }
96
+            KeyCode::Enter => vec![b'\r'],
97
+            KeyCode::Backspace => vec![0x7f],
98
+            KeyCode::Tab => vec![b'\t'],
99
+            KeyCode::Up => vec![0x1b, b'[', b'A'],
100
+            KeyCode::Down => vec![0x1b, b'[', b'B'],
101
+            KeyCode::Right => vec![0x1b, b'[', b'C'],
102
+            KeyCode::Left => vec![0x1b, b'[', b'D'],
103
+            KeyCode::Home => vec![0x1b, b'[', b'H'],
104
+            KeyCode::End => vec![0x1b, b'[', b'F'],
105
+            KeyCode::PageUp => vec![0x1b, b'[', b'5', b'~'],
106
+            KeyCode::PageDown => vec![0x1b, b'[', b'6', b'~'],
107
+            KeyCode::Delete => vec![0x1b, b'[', b'3', b'~'],
108
+            KeyCode::Insert => vec![0x1b, b'[', b'2', b'~'],
109
+            KeyCode::F(n) => {
110
+                // F1-F12 escape sequences
111
+                match n {
112
+                    1 => vec![0x1b, b'O', b'P'],
113
+                    2 => vec![0x1b, b'O', b'Q'],
114
+                    3 => vec![0x1b, b'O', b'R'],
115
+                    4 => vec![0x1b, b'O', b'S'],
116
+                    5 => vec![0x1b, b'[', b'1', b'5', b'~'],
117
+                    6 => vec![0x1b, b'[', b'1', b'7', b'~'],
118
+                    7 => vec![0x1b, b'[', b'1', b'8', b'~'],
119
+                    8 => vec![0x1b, b'[', b'1', b'9', b'~'],
120
+                    9 => vec![0x1b, b'[', b'2', b'0', b'~'],
121
+                    10 => vec![0x1b, b'[', b'2', b'1', b'~'],
122
+                    11 => vec![0x1b, b'[', b'2', b'3', b'~'],
123
+                    12 => vec![0x1b, b'[', b'2', b'4', b'~'],
124
+                    _ => vec![],
125
+                }
126
+            }
127
+            _ => vec![],
128
+        };
129
+
130
+        if !data.is_empty() {
131
+            self.send_input(&data)?;
132
+        }
133
+        Ok(())
134
+    }
135
+
136
+    /// Poll for and process PTY output
137
+    pub fn poll(&mut self) {
138
+        if let Some(ref mut pty) = self.pty {
139
+            if let Some(data) = pty.read() {
140
+                self.screen.process(&data);
141
+            }
142
+        }
143
+    }
144
+
145
+    /// Get the terminal screen for rendering
146
+    pub fn screen(&self) -> &TerminalScreen {
147
+        &self.screen
148
+    }
149
+
150
+    /// Get a cell from the terminal screen
151
+    pub fn get_cell(&self, row: usize, col: usize) -> Option<&Cell> {
152
+        self.screen.cells().get(row).and_then(|r| r.get(col))
153
+    }
154
+
155
+    /// Get cursor position
156
+    pub fn cursor_pos(&self) -> (u16, u16) {
157
+        (self.screen.cursor_row, self.screen.cursor_col)
158
+    }
159
+
160
+    /// Update screen dimensions
161
+    pub fn update_screen_size(&mut self, width: u16, height: u16) {
162
+        self.screen_width = width;
163
+        self.screen_height = height;
164
+
165
+        // Recalculate terminal height (maintain percentage)
166
+        let max_height = height * MAX_HEIGHT_PERCENT / 100;
167
+        self.height = self.height.min(max_height).max(MIN_HEIGHT_ROWS);
168
+
169
+        // Resize terminal screen
170
+        self.screen.resize(width, self.height);
171
+
172
+        // Resize PTY
173
+        if let Some(ref pty) = self.pty {
174
+            let _ = pty.resize(width, self.height);
175
+        }
176
+    }
177
+
178
+    /// Resize terminal height
179
+    pub fn resize_height(&mut self, new_height: u16) {
180
+        let max_height = self.screen_height * MAX_HEIGHT_PERCENT / 100;
181
+        self.height = new_height.min(max_height).max(MIN_HEIGHT_ROWS);
182
+
183
+        self.screen.resize(self.screen_width, self.height);
184
+
185
+        if let Some(ref pty) = self.pty {
186
+            let _ = pty.resize(self.screen_width, self.height);
187
+        }
188
+    }
189
+
190
+    /// Get the starting row for rendering (from bottom of screen)
191
+    pub fn render_start_row(&self, total_rows: u16) -> u16 {
192
+        total_rows.saturating_sub(self.height)
193
+    }
194
+
195
+    /// Convert terminal Color to crossterm Color
196
+    pub fn to_crossterm_color(color: &Color) -> crossterm::style::Color {
197
+        use crossterm::style::Color as CtColor;
198
+        match color {
199
+            Color::Default => CtColor::Reset,
200
+            Color::Black => CtColor::Black,
201
+            Color::Red => CtColor::DarkRed,
202
+            Color::Green => CtColor::DarkGreen,
203
+            Color::Yellow => CtColor::DarkYellow,
204
+            Color::Blue => CtColor::DarkBlue,
205
+            Color::Magenta => CtColor::DarkMagenta,
206
+            Color::Cyan => CtColor::DarkCyan,
207
+            Color::White => CtColor::Grey,
208
+            Color::BrightBlack => CtColor::DarkGrey,
209
+            Color::BrightRed => CtColor::Red,
210
+            Color::BrightGreen => CtColor::Green,
211
+            Color::BrightYellow => CtColor::Yellow,
212
+            Color::BrightBlue => CtColor::Blue,
213
+            Color::BrightMagenta => CtColor::Magenta,
214
+            Color::BrightCyan => CtColor::Cyan,
215
+            Color::BrightWhite => CtColor::White,
216
+            Color::Indexed(idx) => CtColor::AnsiValue(*idx),
217
+            Color::Rgb(r, g, b) => CtColor::Rgb { r: *r, g: *g, b: *b },
218
+        }
219
+    }
220
+}
src/terminal/pty.rsadded
@@ -0,0 +1,107 @@
1
+//! PTY (pseudo-terminal) management
2
+//!
3
+//! Handles spawning the shell process and I/O with it.
4
+
5
+use anyhow::Result;
6
+use portable_pty::{native_pty_system, CommandBuilder, PtyPair, PtySize};
7
+use std::io::{Read, Write};
8
+use std::sync::mpsc::{self, Receiver, Sender};
9
+use std::thread;
10
+
11
+/// Manages a PTY connection to a shell process
12
+pub struct Pty {
13
+    pair: PtyPair,
14
+    writer: Box<dyn Write + Send>,
15
+    output_rx: Receiver<Vec<u8>>,
16
+    _output_thread: thread::JoinHandle<()>,
17
+}
18
+
19
+impl Pty {
20
+    /// Spawn a new PTY with the user's shell
21
+    pub fn spawn(cols: u16, rows: u16) -> Result<Self> {
22
+        let pty_system = native_pty_system();
23
+
24
+        let pair = pty_system.openpty(PtySize {
25
+            rows,
26
+            cols,
27
+            pixel_width: 0,
28
+            pixel_height: 0,
29
+        })?;
30
+
31
+        // Get the user's shell from $SHELL, fallback to /bin/sh
32
+        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
33
+
34
+        let mut cmd = CommandBuilder::new(&shell);
35
+        // Start shell as login shell
36
+        cmd.arg("-l");
37
+
38
+        // Set working directory to current directory
39
+        if let Ok(cwd) = std::env::current_dir() {
40
+            cmd.cwd(cwd);
41
+        }
42
+
43
+        // Spawn the shell
44
+        let _child = pair.slave.spawn_command(cmd)?;
45
+
46
+        // Get writer for sending input to the PTY
47
+        let writer = pair.master.take_writer()?;
48
+
49
+        // Set up a thread to read output from the PTY
50
+        let mut reader = pair.master.try_clone_reader()?;
51
+        let (output_tx, output_rx): (Sender<Vec<u8>>, Receiver<Vec<u8>>) = mpsc::channel();
52
+
53
+        let output_thread = thread::spawn(move || {
54
+            let mut buf = [0u8; 4096];
55
+            loop {
56
+                match reader.read(&mut buf) {
57
+                    Ok(0) => break, // EOF
58
+                    Ok(n) => {
59
+                        if output_tx.send(buf[..n].to_vec()).is_err() {
60
+                            break; // Receiver dropped
61
+                        }
62
+                    }
63
+                    Err(_) => break,
64
+                }
65
+            }
66
+        });
67
+
68
+        Ok(Self {
69
+            pair,
70
+            writer,
71
+            output_rx,
72
+            _output_thread: output_thread,
73
+        })
74
+    }
75
+
76
+    /// Send input bytes to the PTY
77
+    pub fn write(&mut self, data: &[u8]) -> Result<()> {
78
+        self.writer.write_all(data)?;
79
+        self.writer.flush()?;
80
+        Ok(())
81
+    }
82
+
83
+    /// Read any available output from the PTY (non-blocking)
84
+    pub fn read(&mut self) -> Option<Vec<u8>> {
85
+        // Collect all available output
86
+        let mut output = Vec::new();
87
+        while let Ok(data) = self.output_rx.try_recv() {
88
+            output.extend(data);
89
+        }
90
+        if output.is_empty() {
91
+            None
92
+        } else {
93
+            Some(output)
94
+        }
95
+    }
96
+
97
+    /// Resize the PTY
98
+    pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
99
+        self.pair.master.resize(PtySize {
100
+            rows,
101
+            cols,
102
+            pixel_width: 0,
103
+            pixel_height: 0,
104
+        })?;
105
+        Ok(())
106
+    }
107
+}
src/terminal/screen.rsadded
@@ -0,0 +1,450 @@
1
+//! Terminal screen buffer
2
+//!
3
+//! Manages the grid of cells that make up the terminal display.
4
+//! Uses VTE for parsing escape sequences.
5
+
6
+use vte::{Params, Parser, Perform};
7
+
8
+/// A single cell in the terminal grid
9
+#[derive(Clone, Debug)]
10
+pub struct Cell {
11
+    pub c: char,
12
+    pub fg: Color,
13
+    pub bg: Color,
14
+    pub bold: bool,
15
+    pub underline: bool,
16
+    pub inverse: bool,
17
+}
18
+
19
+impl Default for Cell {
20
+    fn default() -> Self {
21
+        Self {
22
+            c: ' ',
23
+            fg: Color::Default,
24
+            bg: Color::Default,
25
+            bold: false,
26
+            underline: false,
27
+            inverse: false,
28
+        }
29
+    }
30
+}
31
+
32
+/// Terminal colors
33
+#[derive(Clone, Copy, Debug, PartialEq)]
34
+pub enum Color {
35
+    Default,
36
+    Black,
37
+    Red,
38
+    Green,
39
+    Yellow,
40
+    Blue,
41
+    Magenta,
42
+    Cyan,
43
+    White,
44
+    BrightBlack,
45
+    BrightRed,
46
+    BrightGreen,
47
+    BrightYellow,
48
+    BrightBlue,
49
+    BrightMagenta,
50
+    BrightCyan,
51
+    BrightWhite,
52
+    Indexed(u8),
53
+    Rgb(u8, u8, u8),
54
+}
55
+
56
+/// Terminal screen state
57
+pub struct TerminalScreen {
58
+    /// Grid of cells (row-major)
59
+    cells: Vec<Vec<Cell>>,
60
+    /// Number of columns
61
+    pub cols: u16,
62
+    /// Number of rows
63
+    pub rows: u16,
64
+    /// Cursor position (0-indexed)
65
+    pub cursor_row: u16,
66
+    pub cursor_col: u16,
67
+    /// Current text attributes
68
+    current_fg: Color,
69
+    current_bg: Color,
70
+    current_bold: bool,
71
+    current_underline: bool,
72
+    current_inverse: bool,
73
+    /// VTE parser
74
+    parser: Parser,
75
+    /// Scrollback buffer
76
+    scrollback: Vec<Vec<Cell>>,
77
+    /// Max scrollback lines
78
+    max_scrollback: usize,
79
+    /// Scroll offset (0 = at bottom)
80
+    pub scroll_offset: usize,
81
+}
82
+
83
+impl TerminalScreen {
84
+    pub fn new(cols: u16, rows: u16) -> Self {
85
+        let cells = vec![vec![Cell::default(); cols as usize]; rows as usize];
86
+        Self {
87
+            cells,
88
+            cols,
89
+            rows,
90
+            cursor_row: 0,
91
+            cursor_col: 0,
92
+            current_fg: Color::Default,
93
+            current_bg: Color::Default,
94
+            current_bold: false,
95
+            current_underline: false,
96
+            current_inverse: false,
97
+            parser: Parser::new(),
98
+            scrollback: Vec::new(),
99
+            max_scrollback: 10000,
100
+            scroll_offset: 0,
101
+        }
102
+    }
103
+
104
+    /// Process raw bytes from the PTY
105
+    pub fn process(&mut self, data: &[u8]) {
106
+        // Take parser out temporarily to avoid borrow conflict
107
+        let mut parser = std::mem::take(&mut self.parser);
108
+        for byte in data {
109
+            parser.advance(self, *byte);
110
+        }
111
+        self.parser = parser;
112
+    }
113
+
114
+    /// Get a reference to the cell grid
115
+    pub fn cells(&self) -> &Vec<Vec<Cell>> {
116
+        &self.cells
117
+    }
118
+
119
+    /// Get a row from scrollback or current screen
120
+    pub fn get_row(&self, row: usize) -> Option<&Vec<Cell>> {
121
+        if self.scroll_offset > 0 {
122
+            let scrollback_row = self.scrollback.len().saturating_sub(self.scroll_offset) + row;
123
+            if scrollback_row < self.scrollback.len() {
124
+                self.scrollback.get(scrollback_row)
125
+            } else {
126
+                let screen_row = scrollback_row - self.scrollback.len();
127
+                self.cells.get(screen_row)
128
+            }
129
+        } else {
130
+            self.cells.get(row)
131
+        }
132
+    }
133
+
134
+    /// Resize the terminal
135
+    pub fn resize(&mut self, cols: u16, rows: u16) {
136
+        // Create new cell grid
137
+        let mut new_cells = vec![vec![Cell::default(); cols as usize]; rows as usize];
138
+
139
+        // Copy existing content
140
+        for (r, row) in self.cells.iter().enumerate() {
141
+            if r >= rows as usize {
142
+                break;
143
+            }
144
+            for (c, cell) in row.iter().enumerate() {
145
+                if c >= cols as usize {
146
+                    break;
147
+                }
148
+                new_cells[r][c] = cell.clone();
149
+            }
150
+        }
151
+
152
+        self.cells = new_cells;
153
+        self.cols = cols;
154
+        self.rows = rows;
155
+
156
+        // Ensure cursor is within bounds
157
+        self.cursor_row = self.cursor_row.min(rows.saturating_sub(1));
158
+        self.cursor_col = self.cursor_col.min(cols.saturating_sub(1));
159
+    }
160
+
161
+    /// Scroll the screen up by one line
162
+    fn scroll_up(&mut self) {
163
+        if !self.cells.is_empty() {
164
+            // Move top row to scrollback
165
+            let top_row = self.cells.remove(0);
166
+            self.scrollback.push(top_row);
167
+
168
+            // Trim scrollback if too large
169
+            if self.scrollback.len() > self.max_scrollback {
170
+                self.scrollback.remove(0);
171
+            }
172
+
173
+            // Add new empty row at bottom
174
+            self.cells.push(vec![Cell::default(); self.cols as usize]);
175
+        }
176
+    }
177
+
178
+    /// Clear the screen
179
+    fn clear_screen(&mut self) {
180
+        for row in &mut self.cells {
181
+            for cell in row {
182
+                *cell = Cell::default();
183
+            }
184
+        }
185
+    }
186
+
187
+    /// Clear from cursor to end of line
188
+    fn clear_to_eol(&mut self) {
189
+        if let Some(row) = self.cells.get_mut(self.cursor_row as usize) {
190
+            for cell in row.iter_mut().skip(self.cursor_col as usize) {
191
+                *cell = Cell::default();
192
+            }
193
+        }
194
+    }
195
+
196
+    /// Clear from cursor to end of screen
197
+    fn clear_to_eos(&mut self) {
198
+        self.clear_to_eol();
199
+        for row in self.cells.iter_mut().skip(self.cursor_row as usize + 1) {
200
+            for cell in row {
201
+                *cell = Cell::default();
202
+            }
203
+        }
204
+    }
205
+
206
+    /// Put a character at the cursor position
207
+    fn put_char(&mut self, c: char) {
208
+        if self.cursor_row < self.rows && self.cursor_col < self.cols {
209
+            if let Some(row) = self.cells.get_mut(self.cursor_row as usize) {
210
+                if let Some(cell) = row.get_mut(self.cursor_col as usize) {
211
+                    cell.c = c;
212
+                    cell.fg = self.current_fg;
213
+                    cell.bg = self.current_bg;
214
+                    cell.bold = self.current_bold;
215
+                    cell.underline = self.current_underline;
216
+                    cell.inverse = self.current_inverse;
217
+                }
218
+            }
219
+        }
220
+    }
221
+}
222
+
223
+/// VTE Perform implementation for processing escape sequences
224
+impl Perform for TerminalScreen {
225
+    fn print(&mut self, c: char) {
226
+        self.put_char(c);
227
+        self.cursor_col += 1;
228
+
229
+        // Handle line wrap
230
+        if self.cursor_col >= self.cols {
231
+            self.cursor_col = 0;
232
+            self.cursor_row += 1;
233
+            if self.cursor_row >= self.rows {
234
+                self.scroll_up();
235
+                self.cursor_row = self.rows - 1;
236
+            }
237
+        }
238
+    }
239
+
240
+    fn execute(&mut self, byte: u8) {
241
+        match byte {
242
+            // Backspace
243
+            0x08 => {
244
+                self.cursor_col = self.cursor_col.saturating_sub(1);
245
+            }
246
+            // Tab
247
+            0x09 => {
248
+                self.cursor_col = ((self.cursor_col / 8) + 1) * 8;
249
+                if self.cursor_col >= self.cols {
250
+                    self.cursor_col = self.cols - 1;
251
+                }
252
+            }
253
+            // Line feed
254
+            0x0A => {
255
+                self.cursor_row += 1;
256
+                if self.cursor_row >= self.rows {
257
+                    self.scroll_up();
258
+                    self.cursor_row = self.rows - 1;
259
+                }
260
+            }
261
+            // Carriage return
262
+            0x0D => {
263
+                self.cursor_col = 0;
264
+            }
265
+            _ => {}
266
+        }
267
+    }
268
+
269
+    fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) {}
270
+
271
+    fn put(&mut self, _byte: u8) {}
272
+
273
+    fn unhook(&mut self) {}
274
+
275
+    fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {}
276
+
277
+    fn csi_dispatch(&mut self, params: &Params, _intermediates: &[u8], _ignore: bool, action: char) {
278
+        let params: Vec<u16> = params.iter().map(|p| p.first().copied().unwrap_or(0) as u16).collect();
279
+
280
+        match action {
281
+            // Cursor Up
282
+            'A' => {
283
+                let n = params.first().copied().unwrap_or(1).max(1);
284
+                self.cursor_row = self.cursor_row.saturating_sub(n);
285
+            }
286
+            // Cursor Down
287
+            'B' => {
288
+                let n = params.first().copied().unwrap_or(1).max(1);
289
+                self.cursor_row = (self.cursor_row + n).min(self.rows - 1);
290
+            }
291
+            // Cursor Forward
292
+            'C' => {
293
+                let n = params.first().copied().unwrap_or(1).max(1);
294
+                self.cursor_col = (self.cursor_col + n).min(self.cols - 1);
295
+            }
296
+            // Cursor Back
297
+            'D' => {
298
+                let n = params.first().copied().unwrap_or(1).max(1);
299
+                self.cursor_col = self.cursor_col.saturating_sub(n);
300
+            }
301
+            // Cursor Position (CUP)
302
+            'H' | 'f' => {
303
+                let row = params.first().copied().unwrap_or(1).max(1) - 1;
304
+                let col = params.get(1).copied().unwrap_or(1).max(1) - 1;
305
+                self.cursor_row = row.min(self.rows - 1);
306
+                self.cursor_col = col.min(self.cols - 1);
307
+            }
308
+            // Erase in Display
309
+            'J' => {
310
+                let mode = params.first().copied().unwrap_or(0);
311
+                match mode {
312
+                    0 => self.clear_to_eos(),
313
+                    1 => {} // Clear from start to cursor (TODO)
314
+                    2 | 3 => self.clear_screen(),
315
+                    _ => {}
316
+                }
317
+            }
318
+            // Erase in Line
319
+            'K' => {
320
+                let mode = params.first().copied().unwrap_or(0);
321
+                match mode {
322
+                    0 => self.clear_to_eol(),
323
+                    1 => {} // Clear from start of line to cursor (TODO)
324
+                    2 => {
325
+                        // Clear entire line
326
+                        if let Some(row) = self.cells.get_mut(self.cursor_row as usize) {
327
+                            for cell in row {
328
+                                *cell = Cell::default();
329
+                            }
330
+                        }
331
+                    }
332
+                    _ => {}
333
+                }
334
+            }
335
+            // Select Graphic Rendition (SGR) - colors and attributes
336
+            'm' => {
337
+                if params.is_empty() {
338
+                    // Reset all attributes
339
+                    self.current_fg = Color::Default;
340
+                    self.current_bg = Color::Default;
341
+                    self.current_bold = false;
342
+                    self.current_underline = false;
343
+                    self.current_inverse = false;
344
+                    return;
345
+                }
346
+
347
+                let mut iter = params.iter().peekable();
348
+                while let Some(&param) = iter.next() {
349
+                    match param {
350
+                        0 => {
351
+                            self.current_fg = Color::Default;
352
+                            self.current_bg = Color::Default;
353
+                            self.current_bold = false;
354
+                            self.current_underline = false;
355
+                            self.current_inverse = false;
356
+                        }
357
+                        1 => self.current_bold = true,
358
+                        4 => self.current_underline = true,
359
+                        7 => self.current_inverse = true,
360
+                        22 => self.current_bold = false,
361
+                        24 => self.current_underline = false,
362
+                        27 => self.current_inverse = false,
363
+                        // Foreground colors
364
+                        30 => self.current_fg = Color::Black,
365
+                        31 => self.current_fg = Color::Red,
366
+                        32 => self.current_fg = Color::Green,
367
+                        33 => self.current_fg = Color::Yellow,
368
+                        34 => self.current_fg = Color::Blue,
369
+                        35 => self.current_fg = Color::Magenta,
370
+                        36 => self.current_fg = Color::Cyan,
371
+                        37 => self.current_fg = Color::White,
372
+                        38 => {
373
+                            // Extended foreground color
374
+                            if let Some(&mode) = iter.next() {
375
+                                match mode {
376
+                                    5 => {
377
+                                        // 256-color mode
378
+                                        if let Some(&idx) = iter.next() {
379
+                                            self.current_fg = Color::Indexed(idx as u8);
380
+                                        }
381
+                                    }
382
+                                    2 => {
383
+                                        // RGB mode
384
+                                        let r = iter.next().copied().unwrap_or(0) as u8;
385
+                                        let g = iter.next().copied().unwrap_or(0) as u8;
386
+                                        let b = iter.next().copied().unwrap_or(0) as u8;
387
+                                        self.current_fg = Color::Rgb(r, g, b);
388
+                                    }
389
+                                    _ => {}
390
+                                }
391
+                            }
392
+                        }
393
+                        39 => self.current_fg = Color::Default,
394
+                        // Background colors
395
+                        40 => self.current_bg = Color::Black,
396
+                        41 => self.current_bg = Color::Red,
397
+                        42 => self.current_bg = Color::Green,
398
+                        43 => self.current_bg = Color::Yellow,
399
+                        44 => self.current_bg = Color::Blue,
400
+                        45 => self.current_bg = Color::Magenta,
401
+                        46 => self.current_bg = Color::Cyan,
402
+                        47 => self.current_bg = Color::White,
403
+                        48 => {
404
+                            // Extended background color
405
+                            if let Some(&mode) = iter.next() {
406
+                                match mode {
407
+                                    5 => {
408
+                                        if let Some(&idx) = iter.next() {
409
+                                            self.current_bg = Color::Indexed(idx as u8);
410
+                                        }
411
+                                    }
412
+                                    2 => {
413
+                                        let r = iter.next().copied().unwrap_or(0) as u8;
414
+                                        let g = iter.next().copied().unwrap_or(0) as u8;
415
+                                        let b = iter.next().copied().unwrap_or(0) as u8;
416
+                                        self.current_bg = Color::Rgb(r, g, b);
417
+                                    }
418
+                                    _ => {}
419
+                                }
420
+                            }
421
+                        }
422
+                        49 => self.current_bg = Color::Default,
423
+                        // Bright foreground colors
424
+                        90 => self.current_fg = Color::BrightBlack,
425
+                        91 => self.current_fg = Color::BrightRed,
426
+                        92 => self.current_fg = Color::BrightGreen,
427
+                        93 => self.current_fg = Color::BrightYellow,
428
+                        94 => self.current_fg = Color::BrightBlue,
429
+                        95 => self.current_fg = Color::BrightMagenta,
430
+                        96 => self.current_fg = Color::BrightCyan,
431
+                        97 => self.current_fg = Color::BrightWhite,
432
+                        // Bright background colors
433
+                        100 => self.current_bg = Color::BrightBlack,
434
+                        101 => self.current_bg = Color::BrightRed,
435
+                        102 => self.current_bg = Color::BrightGreen,
436
+                        103 => self.current_bg = Color::BrightYellow,
437
+                        104 => self.current_bg = Color::BrightBlue,
438
+                        105 => self.current_bg = Color::BrightMagenta,
439
+                        106 => self.current_bg = Color::BrightCyan,
440
+                        107 => self.current_bg = Color::BrightWhite,
441
+                        _ => {}
442
+                    }
443
+                }
444
+            }
445
+            _ => {}
446
+        }
447
+    }
448
+
449
+    fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {}
450
+}