@@ -1,6 +1,6 @@ |
| 1 | 1 | //! Terminal panel |
| 2 | 2 | //! |
| 3 | | -//! The main interface for the integrated terminal. |
| 3 | +//! The main interface for the integrated terminal with multi-session support. |
| 4 | 4 | |
| 5 | 5 | use anyhow::Result; |
| 6 | 6 | |
@@ -14,12 +14,90 @@ const MAX_HEIGHT_PERCENT: u16 = 80; |
| 14 | 14 | /// Minimum terminal height in rows |
| 15 | 15 | const MIN_HEIGHT_ROWS: u16 = 3; |
| 16 | 16 | |
| 17 | | -/// Integrated terminal panel |
| 18 | | -pub struct TerminalPanel { |
| 17 | +/// A single terminal session (PTY + screen buffer) |
| 18 | +pub struct TerminalSession { |
| 19 | 19 | /// PTY connection to shell |
| 20 | 20 | pty: Option<Pty>, |
| 21 | 21 | /// Terminal screen buffer |
| 22 | 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 | + /// Resize this session |
| 77 | + fn resize(&mut self, width: u16, height: u16) { |
| 78 | + self.screen.resize(width, height); |
| 79 | + if let Some(ref pty) = self.pty { |
| 80 | + let _ = pty.resize(width, height); |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + /// Get the current working directory (from OSC 7) |
| 85 | + pub fn cwd(&self) -> Option<&str> { |
| 86 | + self.screen.cwd.as_deref() |
| 87 | + } |
| 88 | + |
| 89 | + /// Get the screen buffer |
| 90 | + pub fn screen(&self) -> &TerminalScreen { |
| 91 | + &self.screen |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +/// Integrated terminal panel with multi-session support |
| 96 | +pub struct TerminalPanel { |
| 97 | + /// All terminal sessions |
| 98 | + sessions: Vec<TerminalSession>, |
| 99 | + /// Active session index |
| 100 | + active_session: usize, |
| 23 | 101 | /// Whether the terminal is visible |
| 24 | 102 | pub visible: bool, |
| 25 | 103 | /// Terminal height in rows |
@@ -34,11 +112,9 @@ impl TerminalPanel { |
| 34 | 112 | /// Create a new terminal panel (not yet spawned) |
| 35 | 113 | pub fn new(screen_width: u16, screen_height: u16) -> Self { |
| 36 | 114 | let height = (screen_height * DEFAULT_HEIGHT_PERCENT / 100).max(MIN_HEIGHT_ROWS); |
| 37 | | - // Content area is height - 1 (title bar takes one row) |
| 38 | | - let content_height = height.saturating_sub(1).max(1); |
| 39 | 115 | Self { |
| 40 | | - pty: None, |
| 41 | | - screen: TerminalScreen::new(screen_width, content_height), |
| 116 | + sessions: Vec::new(), |
| 117 | + active_session: 0, |
| 42 | 118 | visible: false, |
| 43 | 119 | height, |
| 44 | 120 | screen_height, |
@@ -46,41 +122,112 @@ impl TerminalPanel { |
| 46 | 122 | } |
| 47 | 123 | } |
| 48 | 124 | |
| 125 | + /// Get the content height (excluding title bar) |
| 126 | + fn content_height(&self) -> u16 { |
| 127 | + self.height.saturating_sub(1).max(1) |
| 128 | + } |
| 129 | + |
| 49 | 130 | /// Toggle terminal visibility |
| 50 | 131 | pub fn toggle(&mut self) -> Result<()> { |
| 51 | 132 | self.visible = !self.visible; |
| 52 | 133 | |
| 53 | | - // Spawn PTY on first show |
| 54 | | - if self.visible && self.pty.is_none() { |
| 55 | | - self.spawn()?; |
| 134 | + // Spawn first session on first show |
| 135 | + if self.visible && self.sessions.is_empty() { |
| 136 | + self.new_session()?; |
| 56 | 137 | } |
| 57 | 138 | |
| 58 | 139 | Ok(()) |
| 59 | 140 | } |
| 60 | 141 | |
| 61 | | - /// Spawn the PTY process |
| 62 | | - fn spawn(&mut self) -> Result<()> { |
| 63 | | - // PTY gets content height (excluding title bar) |
| 64 | | - let content_height = self.height.saturating_sub(1).max(1); |
| 65 | | - let pty = Pty::spawn(self.screen_width, content_height)?; |
| 66 | | - self.pty = Some(pty); |
| 142 | + /// Create a new terminal session |
| 143 | + pub fn new_session(&mut self) -> Result<()> { |
| 144 | + let content_height = self.content_height(); |
| 145 | + let mut session = TerminalSession::new(self.screen_width, content_height); |
| 146 | + session.spawn(self.screen_width, content_height)?; |
| 147 | + self.sessions.push(session); |
| 148 | + self.active_session = self.sessions.len() - 1; |
| 67 | 149 | Ok(()) |
| 68 | 150 | } |
| 69 | 151 | |
| 152 | + /// Close the active session. Returns true if the terminal should be hidden. |
| 153 | + pub fn close_active_session(&mut self) -> bool { |
| 154 | + if self.sessions.is_empty() { |
| 155 | + return true; |
| 156 | + } |
| 157 | + |
| 158 | + self.sessions.remove(self.active_session); |
| 159 | + |
| 160 | + if self.sessions.is_empty() { |
| 161 | + return true; |
| 162 | + } |
| 163 | + |
| 164 | + // Adjust active_session if needed |
| 165 | + if self.active_session >= self.sessions.len() { |
| 166 | + self.active_session = self.sessions.len() - 1; |
| 167 | + } |
| 168 | + |
| 169 | + false |
| 170 | + } |
| 171 | + |
| 172 | + /// Switch to a specific session by index |
| 173 | + pub fn switch_session(&mut self, index: usize) { |
| 174 | + if index < self.sessions.len() { |
| 175 | + self.active_session = index; |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + /// Switch to the next session |
| 180 | + pub fn next_session(&mut self) { |
| 181 | + if !self.sessions.is_empty() { |
| 182 | + self.active_session = (self.active_session + 1) % self.sessions.len(); |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + /// Switch to the previous session |
| 187 | + pub fn prev_session(&mut self) { |
| 188 | + if !self.sessions.is_empty() { |
| 189 | + self.active_session = if self.active_session == 0 { |
| 190 | + self.sessions.len() - 1 |
| 191 | + } else { |
| 192 | + self.active_session - 1 |
| 193 | + }; |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + /// Get the number of sessions |
| 198 | + pub fn session_count(&self) -> usize { |
| 199 | + self.sessions.len() |
| 200 | + } |
| 201 | + |
| 202 | + /// Get the active session index |
| 203 | + pub fn active_session_index(&self) -> usize { |
| 204 | + self.active_session |
| 205 | + } |
| 206 | + |
| 207 | + /// Get a reference to all sessions (for rendering tabs) |
| 208 | + pub fn sessions(&self) -> &[TerminalSession] { |
| 209 | + &self.sessions |
| 210 | + } |
| 211 | + |
| 212 | + /// Get the CWD of the active session |
| 213 | + pub fn active_cwd(&self) -> Option<&str> { |
| 214 | + self.sessions.get(self.active_session).and_then(|s| s.cwd()) |
| 215 | + } |
| 216 | + |
| 70 | 217 | /// Hide the terminal (ESC pressed) |
| 71 | 218 | pub fn hide(&mut self) { |
| 72 | 219 | self.visible = false; |
| 73 | 220 | } |
| 74 | 221 | |
| 75 | | - /// Send input to the terminal |
| 222 | + /// Send input to the active terminal |
| 76 | 223 | pub fn send_input(&mut self, data: &[u8]) -> Result<()> { |
| 77 | | - if let Some(ref mut pty) = self.pty { |
| 78 | | - pty.write(data)?; |
| 224 | + if let Some(session) = self.sessions.get_mut(self.active_session) { |
| 225 | + session.send_input(data)?; |
| 79 | 226 | } |
| 80 | 227 | Ok(()) |
| 81 | 228 | } |
| 82 | 229 | |
| 83 | | - /// Send a key to the terminal |
| 230 | + /// Send a key to the active terminal |
| 84 | 231 | pub fn send_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<()> { |
| 85 | 232 | use crossterm::event::{KeyCode, KeyModifiers}; |
| 86 | 233 | |
@@ -137,47 +284,53 @@ impl TerminalPanel { |
| 137 | 284 | Ok(()) |
| 138 | 285 | } |
| 139 | 286 | |
| 140 | | - /// Poll for and process PTY output. Returns true if data was received or terminal closed. |
| 287 | + /// Poll for and process PTY output. Returns true if data was received or terminal state changed. |
| 141 | 288 | pub fn poll(&mut self) -> bool { |
| 142 | | - let mut had_data = false; |
| 289 | + let mut had_activity = false; |
| 143 | 290 | |
| 144 | | - if let Some(ref mut pty) = self.pty { |
| 145 | | - // Check if shell has exited |
| 146 | | - if !pty.is_alive() { |
| 147 | | - // Shell exited - close terminal and clean up |
| 148 | | - self.visible = false; |
| 149 | | - self.pty = None; |
| 150 | | - return true; // Trigger render to hide terminal |
| 291 | + // Poll all sessions (to keep them responsive) |
| 292 | + for session in &mut self.sessions { |
| 293 | + if session.poll() { |
| 294 | + had_activity = true; |
| 151 | 295 | } |
| 296 | + } |
| 152 | 297 | |
| 153 | | - if let Some(data) = pty.read() { |
| 154 | | - self.screen.process(&data); |
| 155 | | - had_data = true; |
| 156 | | - } |
| 298 | + // Remove dead sessions |
| 299 | + let active_before = self.active_session; |
| 300 | + self.sessions.retain(|s| s.is_alive()); |
| 301 | + |
| 302 | + if self.sessions.is_empty() { |
| 303 | + self.visible = false; |
| 304 | + return true; |
| 157 | 305 | } |
| 158 | 306 | |
| 159 | | - // Send any queued responses (e.g., device status reports) back to PTY |
| 160 | | - let responses = self.screen.drain_responses(); |
| 161 | | - for response in responses { |
| 162 | | - let _ = self.send_input(&response); |
| 307 | + // Adjust active_session if sessions were removed |
| 308 | + if self.active_session >= self.sessions.len() { |
| 309 | + self.active_session = self.sessions.len() - 1; |
| 310 | + had_activity = true; |
| 311 | + } else if active_before != self.active_session { |
| 312 | + had_activity = true; |
| 163 | 313 | } |
| 164 | 314 | |
| 165 | | - had_data |
| 315 | + had_activity |
| 166 | 316 | } |
| 167 | 317 | |
| 168 | | - /// Get the terminal screen for rendering |
| 169 | | - pub fn screen(&self) -> &TerminalScreen { |
| 170 | | - &self.screen |
| 318 | + /// Get the active terminal screen for rendering |
| 319 | + pub fn screen(&self) -> Option<&TerminalScreen> { |
| 320 | + self.sessions.get(self.active_session).map(|s| s.screen()) |
| 171 | 321 | } |
| 172 | 322 | |
| 173 | | - /// Get a cell from the terminal screen |
| 323 | + /// Get a cell from the active terminal screen |
| 174 | 324 | pub fn get_cell(&self, row: usize, col: usize) -> Option<&Cell> { |
| 175 | | - self.screen.cells().get(row).and_then(|r| r.get(col)) |
| 325 | + self.screen()?.cells().get(row).and_then(|r| r.get(col)) |
| 176 | 326 | } |
| 177 | 327 | |
| 178 | | - /// Get cursor position |
| 328 | + /// Get cursor position from the active session |
| 179 | 329 | pub fn cursor_pos(&self) -> (u16, u16) { |
| 180 | | - (self.screen.cursor_row, self.screen.cursor_col) |
| 330 | + self.sessions |
| 331 | + .get(self.active_session) |
| 332 | + .map(|s| (s.screen.cursor_row, s.screen.cursor_col)) |
| 333 | + .unwrap_or((0, 0)) |
| 181 | 334 | } |
| 182 | 335 | |
| 183 | 336 | /// Update screen dimensions |
@@ -189,15 +342,11 @@ impl TerminalPanel { |
| 189 | 342 | let max_height = height * MAX_HEIGHT_PERCENT / 100; |
| 190 | 343 | self.height = self.height.min(max_height).max(MIN_HEIGHT_ROWS); |
| 191 | 344 | |
| 192 | | - // Content height excludes title bar |
| 193 | | - let content_height = self.height.saturating_sub(1).max(1); |
| 194 | | - |
| 195 | | - // Resize terminal screen |
| 196 | | - self.screen.resize(width, content_height); |
| 345 | + let content_height = self.content_height(); |
| 197 | 346 | |
| 198 | | - // Resize PTY |
| 199 | | - if let Some(ref pty) = self.pty { |
| 200 | | - let _ = pty.resize(width, content_height); |
| 347 | + // Resize all sessions |
| 348 | + for session in &mut self.sessions { |
| 349 | + session.resize(width, content_height); |
| 201 | 350 | } |
| 202 | 351 | } |
| 203 | 352 | |
@@ -206,13 +355,11 @@ impl TerminalPanel { |
| 206 | 355 | let max_height = self.screen_height * MAX_HEIGHT_PERCENT / 100; |
| 207 | 356 | self.height = new_height.min(max_height).max(MIN_HEIGHT_ROWS); |
| 208 | 357 | |
| 209 | | - // Content height excludes title bar |
| 210 | | - let content_height = self.height.saturating_sub(1).max(1); |
| 211 | | - |
| 212 | | - self.screen.resize(self.screen_width, content_height); |
| 358 | + let content_height = self.content_height(); |
| 213 | 359 | |
| 214 | | - if let Some(ref pty) = self.pty { |
| 215 | | - let _ = pty.resize(self.screen_width, content_height); |
| 360 | + // Resize all sessions |
| 361 | + for session in &mut self.sessions { |
| 362 | + session.resize(self.screen_width, content_height); |
| 216 | 363 | } |
| 217 | 364 | } |
| 218 | 365 | |