| 1 | //! Terminal panel |
| 2 | //! |
| 3 | //! The main interface for the integrated terminal with multi-session support. |
| 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 | /// A single terminal session (PTY + screen buffer) |
| 18 | pub struct TerminalSession { |
| 19 | /// PTY connection to shell |
| 20 | pty: Option<Pty>, |
| 21 | /// Terminal screen buffer |
| 22 | screen: TerminalScreen, |
| 23 | } |
| 24 | |
| 25 | impl TerminalSession { |
| 26 | /// Create a new terminal session |
| 27 | fn new(width: u16, height: u16) -> Self { |
| 28 | Self { |
| 29 | pty: None, |
| 30 | screen: TerminalScreen::new(width, height), |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | /// Spawn the PTY for this session |
| 35 | fn spawn(&mut self, width: u16, height: u16) -> Result<()> { |
| 36 | let pty = Pty::spawn(width, height)?; |
| 37 | self.pty = Some(pty); |
| 38 | Ok(()) |
| 39 | } |
| 40 | |
| 41 | /// Check if the session's shell is still alive |
| 42 | fn is_alive(&self) -> bool { |
| 43 | self.pty.as_ref().map(|p| p.is_alive()).unwrap_or(false) |
| 44 | } |
| 45 | |
| 46 | /// Poll for output from this session |
| 47 | fn poll(&mut self) -> bool { |
| 48 | let mut had_data = false; |
| 49 | |
| 50 | if let Some(ref mut pty) = self.pty { |
| 51 | if let Some(data) = pty.read() { |
| 52 | self.screen.process(&data); |
| 53 | had_data = true; |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // Send any queued responses back to PTY |
| 58 | let responses = self.screen.drain_responses(); |
| 59 | for response in responses { |
| 60 | if let Some(ref mut pty) = self.pty { |
| 61 | let _ = pty.write(&response); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | had_data |
| 66 | } |
| 67 | |
| 68 | /// Send input to this session |
| 69 | fn send_input(&mut self, data: &[u8]) -> Result<()> { |
| 70 | if let Some(ref mut pty) = self.pty { |
| 71 | pty.write(data)?; |
| 72 | } |
| 73 | Ok(()) |
| 74 | } |
| 75 | |
| 76 | /// Send pasted input, honoring bracketed paste mode when requested by the app. |
| 77 | fn send_paste(&mut self, data: &[u8]) -> Result<()> { |
| 78 | let payload = if self.screen.bracketed_paste_enabled() { |
| 79 | encode_bracketed_paste(data) |
| 80 | } else { |
| 81 | data.to_vec() |
| 82 | }; |
| 83 | self.send_input(&payload) |
| 84 | } |
| 85 | |
| 86 | /// Resize this session |
| 87 | fn resize(&mut self, width: u16, height: u16) { |
| 88 | self.screen.resize(width, height); |
| 89 | if let Some(ref pty) = self.pty { |
| 90 | let _ = pty.resize(width, height); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /// Get the current working directory (from OSC 7) |
| 95 | pub fn cwd(&self) -> Option<&str> { |
| 96 | self.screen.cwd.as_deref() |
| 97 | } |
| 98 | |
| 99 | /// Get the screen buffer |
| 100 | pub fn screen(&self) -> &TerminalScreen { |
| 101 | &self.screen |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /// Integrated terminal panel with multi-session support |
| 106 | pub struct TerminalPanel { |
| 107 | /// All terminal sessions |
| 108 | sessions: Vec<TerminalSession>, |
| 109 | /// Active session index |
| 110 | active_session: usize, |
| 111 | /// Whether the terminal is visible |
| 112 | pub visible: bool, |
| 113 | /// Terminal height in rows |
| 114 | pub height: u16, |
| 115 | /// Total screen height (for percentage calculations) |
| 116 | screen_height: u16, |
| 117 | /// Total screen width |
| 118 | screen_width: u16, |
| 119 | } |
| 120 | |
| 121 | impl TerminalPanel { |
| 122 | /// Create a new terminal panel (not yet spawned) |
| 123 | pub fn new(screen_width: u16, screen_height: u16) -> Self { |
| 124 | let height = (screen_height * DEFAULT_HEIGHT_PERCENT / 100).max(MIN_HEIGHT_ROWS); |
| 125 | Self { |
| 126 | sessions: Vec::new(), |
| 127 | active_session: 0, |
| 128 | visible: false, |
| 129 | height, |
| 130 | screen_height, |
| 131 | screen_width, |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// Get the content height (excluding title bar) |
| 136 | fn content_height(&self) -> u16 { |
| 137 | self.height.saturating_sub(1).max(1) |
| 138 | } |
| 139 | |
| 140 | /// Toggle terminal visibility |
| 141 | pub fn toggle(&mut self) -> Result<()> { |
| 142 | self.visible = !self.visible; |
| 143 | |
| 144 | // Spawn first session on first show |
| 145 | if self.visible && self.sessions.is_empty() { |
| 146 | self.new_session()?; |
| 147 | } |
| 148 | |
| 149 | Ok(()) |
| 150 | } |
| 151 | |
| 152 | /// Create a new terminal session |
| 153 | pub fn new_session(&mut self) -> Result<()> { |
| 154 | let content_height = self.content_height(); |
| 155 | let mut session = TerminalSession::new(self.screen_width, content_height); |
| 156 | session.spawn(self.screen_width, content_height)?; |
| 157 | self.sessions.push(session); |
| 158 | self.active_session = self.sessions.len() - 1; |
| 159 | Ok(()) |
| 160 | } |
| 161 | |
| 162 | /// Close the active session. Returns true if the terminal should be hidden. |
| 163 | pub fn close_active_session(&mut self) -> bool { |
| 164 | if self.sessions.is_empty() { |
| 165 | return true; |
| 166 | } |
| 167 | |
| 168 | self.sessions.remove(self.active_session); |
| 169 | |
| 170 | if self.sessions.is_empty() { |
| 171 | return true; |
| 172 | } |
| 173 | |
| 174 | // Adjust active_session if needed |
| 175 | if self.active_session >= self.sessions.len() { |
| 176 | self.active_session = self.sessions.len() - 1; |
| 177 | } |
| 178 | |
| 179 | false |
| 180 | } |
| 181 | |
| 182 | /// Switch to a specific session by index |
| 183 | pub fn switch_session(&mut self, index: usize) { |
| 184 | if index < self.sessions.len() { |
| 185 | self.active_session = index; |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | /// Switch to the next session |
| 190 | pub fn next_session(&mut self) { |
| 191 | if !self.sessions.is_empty() { |
| 192 | self.active_session = (self.active_session + 1) % self.sessions.len(); |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | /// Switch to the previous session |
| 197 | pub fn prev_session(&mut self) { |
| 198 | if !self.sessions.is_empty() { |
| 199 | self.active_session = if self.active_session == 0 { |
| 200 | self.sessions.len() - 1 |
| 201 | } else { |
| 202 | self.active_session - 1 |
| 203 | }; |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | /// Get the number of sessions |
| 208 | pub fn session_count(&self) -> usize { |
| 209 | self.sessions.len() |
| 210 | } |
| 211 | |
| 212 | /// Get the active session index |
| 213 | pub fn active_session_index(&self) -> usize { |
| 214 | self.active_session |
| 215 | } |
| 216 | |
| 217 | /// Get a reference to all sessions (for rendering tabs) |
| 218 | pub fn sessions(&self) -> &[TerminalSession] { |
| 219 | &self.sessions |
| 220 | } |
| 221 | |
| 222 | /// Get the CWD of the active session |
| 223 | pub fn active_cwd(&self) -> Option<&str> { |
| 224 | self.sessions.get(self.active_session).and_then(|s| s.cwd()) |
| 225 | } |
| 226 | |
| 227 | /// Hide the terminal (ESC pressed) |
| 228 | pub fn hide(&mut self) { |
| 229 | self.visible = false; |
| 230 | } |
| 231 | |
| 232 | /// Send input to the active terminal |
| 233 | pub fn send_input(&mut self, data: &[u8]) -> Result<()> { |
| 234 | if let Some(session) = self.sessions.get_mut(self.active_session) { |
| 235 | session.send_input(data)?; |
| 236 | } |
| 237 | Ok(()) |
| 238 | } |
| 239 | |
| 240 | /// Send pasted input to the active terminal session. |
| 241 | pub fn send_paste(&mut self, data: &[u8]) -> Result<()> { |
| 242 | if let Some(session) = self.sessions.get_mut(self.active_session) { |
| 243 | session.send_paste(data)?; |
| 244 | } |
| 245 | Ok(()) |
| 246 | } |
| 247 | |
| 248 | /// Send a key to the active terminal |
| 249 | pub fn send_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<()> { |
| 250 | use crossterm::event::{KeyCode, KeyModifiers}; |
| 251 | |
| 252 | let data: Vec<u8> = match key.code { |
| 253 | KeyCode::Char(c) => { |
| 254 | if key.modifiers.contains(KeyModifiers::CONTROL) { |
| 255 | // Convert to control character |
| 256 | let ctrl_char = (c.to_ascii_lowercase() as u8).wrapping_sub(b'a').wrapping_add(1); |
| 257 | vec![ctrl_char] |
| 258 | } else if key.modifiers.contains(KeyModifiers::ALT) { |
| 259 | // Alt sends ESC prefix |
| 260 | vec![0x1b, c as u8] |
| 261 | } else { |
| 262 | c.to_string().into_bytes() |
| 263 | } |
| 264 | } |
| 265 | KeyCode::Enter => vec![b'\r'], |
| 266 | KeyCode::Backspace => vec![0x7f], |
| 267 | KeyCode::Tab => vec![b'\t'], |
| 268 | KeyCode::Up => vec![0x1b, b'[', b'A'], |
| 269 | KeyCode::Down => vec![0x1b, b'[', b'B'], |
| 270 | KeyCode::Right => vec![0x1b, b'[', b'C'], |
| 271 | KeyCode::Left => vec![0x1b, b'[', b'D'], |
| 272 | KeyCode::Home => vec![0x1b, b'[', b'H'], |
| 273 | KeyCode::End => vec![0x1b, b'[', b'F'], |
| 274 | KeyCode::PageUp => vec![0x1b, b'[', b'5', b'~'], |
| 275 | KeyCode::PageDown => vec![0x1b, b'[', b'6', b'~'], |
| 276 | KeyCode::Delete => vec![0x1b, b'[', b'3', b'~'], |
| 277 | KeyCode::Insert => vec![0x1b, b'[', b'2', b'~'], |
| 278 | KeyCode::F(n) => { |
| 279 | // F1-F12 escape sequences |
| 280 | match n { |
| 281 | 1 => vec![0x1b, b'O', b'P'], |
| 282 | 2 => vec![0x1b, b'O', b'Q'], |
| 283 | 3 => vec![0x1b, b'O', b'R'], |
| 284 | 4 => vec![0x1b, b'O', b'S'], |
| 285 | 5 => vec![0x1b, b'[', b'1', b'5', b'~'], |
| 286 | 6 => vec![0x1b, b'[', b'1', b'7', b'~'], |
| 287 | 7 => vec![0x1b, b'[', b'1', b'8', b'~'], |
| 288 | 8 => vec![0x1b, b'[', b'1', b'9', b'~'], |
| 289 | 9 => vec![0x1b, b'[', b'2', b'0', b'~'], |
| 290 | 10 => vec![0x1b, b'[', b'2', b'1', b'~'], |
| 291 | 11 => vec![0x1b, b'[', b'2', b'3', b'~'], |
| 292 | 12 => vec![0x1b, b'[', b'2', b'4', b'~'], |
| 293 | _ => vec![], |
| 294 | } |
| 295 | } |
| 296 | _ => vec![], |
| 297 | }; |
| 298 | |
| 299 | if !data.is_empty() { |
| 300 | self.send_input(&data)?; |
| 301 | } |
| 302 | Ok(()) |
| 303 | } |
| 304 | |
| 305 | /// Poll for and process PTY output. Returns true if data was received or terminal state changed. |
| 306 | pub fn poll(&mut self) -> bool { |
| 307 | let mut had_activity = false; |
| 308 | |
| 309 | // Poll all sessions (to keep them responsive) |
| 310 | for session in &mut self.sessions { |
| 311 | if session.poll() { |
| 312 | had_activity = true; |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // Remove dead sessions |
| 317 | let active_before = self.active_session; |
| 318 | self.sessions.retain(|s| s.is_alive()); |
| 319 | |
| 320 | if self.sessions.is_empty() { |
| 321 | self.visible = false; |
| 322 | return true; |
| 323 | } |
| 324 | |
| 325 | // Adjust active_session if sessions were removed |
| 326 | if self.active_session >= self.sessions.len() { |
| 327 | self.active_session = self.sessions.len() - 1; |
| 328 | had_activity = true; |
| 329 | } else if active_before != self.active_session { |
| 330 | had_activity = true; |
| 331 | } |
| 332 | |
| 333 | had_activity |
| 334 | } |
| 335 | |
| 336 | /// Get the active terminal screen for rendering |
| 337 | pub fn screen(&self) -> Option<&TerminalScreen> { |
| 338 | self.sessions.get(self.active_session).map(|s| s.screen()) |
| 339 | } |
| 340 | |
| 341 | /// Get a cell from the active terminal screen |
| 342 | pub fn get_cell(&self, row: usize, col: usize) -> Option<&Cell> { |
| 343 | self.screen()?.cells().get(row).and_then(|r| r.get(col)) |
| 344 | } |
| 345 | |
| 346 | /// Get cursor position from the active session |
| 347 | pub fn cursor_pos(&self) -> (u16, u16) { |
| 348 | self.sessions |
| 349 | .get(self.active_session) |
| 350 | .map(|s| (s.screen.cursor_row, s.screen.cursor_col)) |
| 351 | .unwrap_or((0, 0)) |
| 352 | } |
| 353 | |
| 354 | /// Update screen dimensions |
| 355 | pub fn update_screen_size(&mut self, width: u16, height: u16) { |
| 356 | self.screen_width = width; |
| 357 | self.screen_height = height; |
| 358 | |
| 359 | // Recalculate terminal height (maintain percentage) |
| 360 | let max_height = height * MAX_HEIGHT_PERCENT / 100; |
| 361 | self.height = self.height.min(max_height).max(MIN_HEIGHT_ROWS); |
| 362 | |
| 363 | let content_height = self.content_height(); |
| 364 | |
| 365 | // Resize all sessions |
| 366 | for session in &mut self.sessions { |
| 367 | session.resize(width, content_height); |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | /// Resize terminal height |
| 372 | pub fn resize_height(&mut self, new_height: u16) { |
| 373 | let max_height = self.screen_height * MAX_HEIGHT_PERCENT / 100; |
| 374 | self.height = new_height.min(max_height).max(MIN_HEIGHT_ROWS); |
| 375 | |
| 376 | let content_height = self.content_height(); |
| 377 | |
| 378 | // Resize all sessions |
| 379 | for session in &mut self.sessions { |
| 380 | session.resize(self.screen_width, content_height); |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | /// Get the starting row for rendering (from bottom of screen) |
| 385 | pub fn render_start_row(&self, total_rows: u16) -> u16 { |
| 386 | total_rows.saturating_sub(self.height) |
| 387 | } |
| 388 | |
| 389 | /// Convert terminal Color to crossterm Color |
| 390 | pub fn to_crossterm_color(color: &Color) -> crossterm::style::Color { |
| 391 | use crossterm::style::Color as CtColor; |
| 392 | match color { |
| 393 | Color::Default => CtColor::Reset, |
| 394 | Color::Black => CtColor::Black, |
| 395 | Color::Red => CtColor::DarkRed, |
| 396 | Color::Green => CtColor::DarkGreen, |
| 397 | Color::Yellow => CtColor::DarkYellow, |
| 398 | Color::Blue => CtColor::DarkBlue, |
| 399 | Color::Magenta => CtColor::DarkMagenta, |
| 400 | Color::Cyan => CtColor::DarkCyan, |
| 401 | Color::White => CtColor::Grey, |
| 402 | Color::BrightBlack => CtColor::DarkGrey, |
| 403 | Color::BrightRed => CtColor::Red, |
| 404 | Color::BrightGreen => CtColor::Green, |
| 405 | Color::BrightYellow => CtColor::Yellow, |
| 406 | Color::BrightBlue => CtColor::Blue, |
| 407 | Color::BrightMagenta => CtColor::Magenta, |
| 408 | Color::BrightCyan => CtColor::Cyan, |
| 409 | Color::BrightWhite => CtColor::White, |
| 410 | Color::Indexed(idx) => CtColor::AnsiValue(*idx), |
| 411 | Color::Rgb(r, g, b) => CtColor::Rgb { r: *r, g: *g, b: *b }, |
| 412 | } |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | fn encode_bracketed_paste(data: &[u8]) -> Vec<u8> { |
| 417 | let mut wrapped = Vec::with_capacity(data.len() + 12); |
| 418 | wrapped.extend_from_slice(b"\x1b[200~"); |
| 419 | wrapped.extend_from_slice(data); |
| 420 | wrapped.extend_from_slice(b"\x1b[201~"); |
| 421 | wrapped |
| 422 | } |
| 423 | |
| 424 | #[cfg(test)] |
| 425 | mod tests { |
| 426 | use super::encode_bracketed_paste; |
| 427 | |
| 428 | #[test] |
| 429 | fn bracketed_paste_wraps_raw_bytes() { |
| 430 | assert_eq!( |
| 431 | encode_bracketed_paste(b"line 1\nline 2"), |
| 432 | b"\x1b[200~line 1\nline 2\x1b[201~" |
| 433 | ); |
| 434 | } |
| 435 | } |
| 436 |