@@ -1,6 +1,6 @@ |
| 1 | //! Terminal panel | 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 | use anyhow::Result; | 5 | use anyhow::Result; |
| 6 | | 6 | |
@@ -14,12 +14,90 @@ const MAX_HEIGHT_PERCENT: u16 = 80; |
| 14 | /// Minimum terminal height in rows | 14 | /// Minimum terminal height in rows |
| 15 | const MIN_HEIGHT_ROWS: u16 = 3; | 15 | const MIN_HEIGHT_ROWS: u16 = 3; |
| 16 | | 16 | |
| 17 | -/// Integrated terminal panel | 17 | +/// A single terminal session (PTY + screen buffer) |
| 18 | -pub struct TerminalPanel { | 18 | +pub struct TerminalSession { |
| 19 | /// PTY connection to shell | 19 | /// PTY connection to shell |
| 20 | pty: Option<Pty>, | 20 | pty: Option<Pty>, |
| 21 | /// Terminal screen buffer | 21 | /// Terminal screen buffer |
| 22 | screen: TerminalScreen, | 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 | /// Whether the terminal is visible | 101 | /// Whether the terminal is visible |
| 24 | pub visible: bool, | 102 | pub visible: bool, |
| 25 | /// Terminal height in rows | 103 | /// Terminal height in rows |
@@ -34,11 +112,9 @@ impl TerminalPanel { |
| 34 | /// Create a new terminal panel (not yet spawned) | 112 | /// Create a new terminal panel (not yet spawned) |
| 35 | pub fn new(screen_width: u16, screen_height: u16) -> Self { | 113 | pub fn new(screen_width: u16, screen_height: u16) -> Self { |
| 36 | let height = (screen_height * DEFAULT_HEIGHT_PERCENT / 100).max(MIN_HEIGHT_ROWS); | 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 | Self { | 115 | Self { |
| 40 | - pty: None, | 116 | + sessions: Vec::new(), |
| 41 | - screen: TerminalScreen::new(screen_width, content_height), | 117 | + active_session: 0, |
| 42 | visible: false, | 118 | visible: false, |
| 43 | height, | 119 | height, |
| 44 | screen_height, | 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 | /// Toggle terminal visibility | 130 | /// Toggle terminal visibility |
| 50 | pub fn toggle(&mut self) -> Result<()> { | 131 | pub fn toggle(&mut self) -> Result<()> { |
| 51 | self.visible = !self.visible; | 132 | self.visible = !self.visible; |
| 52 | | 133 | |
| 53 | - // Spawn PTY on first show | 134 | + // Spawn first session on first show |
| 54 | - if self.visible && self.pty.is_none() { | 135 | + if self.visible && self.sessions.is_empty() { |
| 55 | - self.spawn()?; | 136 | + self.new_session()?; |
| 56 | } | 137 | } |
| 57 | | 138 | |
| 58 | Ok(()) | 139 | Ok(()) |
| 59 | } | 140 | } |
| 60 | | 141 | |
| 61 | - /// Spawn the PTY process | 142 | + /// Create a new terminal session |
| 62 | - fn spawn(&mut self) -> Result<()> { | 143 | + pub fn new_session(&mut self) -> Result<()> { |
| 63 | - // PTY gets content height (excluding title bar) | 144 | + let content_height = self.content_height(); |
| 64 | - let content_height = self.height.saturating_sub(1).max(1); | 145 | + let mut session = TerminalSession::new(self.screen_width, content_height); |
| 65 | - let pty = Pty::spawn(self.screen_width, content_height)?; | 146 | + session.spawn(self.screen_width, content_height)?; |
| 66 | - self.pty = Some(pty); | 147 | + self.sessions.push(session); |
| | 148 | + self.active_session = self.sessions.len() - 1; |
| 67 | Ok(()) | 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 | /// Hide the terminal (ESC pressed) | 217 | /// Hide the terminal (ESC pressed) |
| 71 | pub fn hide(&mut self) { | 218 | pub fn hide(&mut self) { |
| 72 | self.visible = false; | 219 | self.visible = false; |
| 73 | } | 220 | } |
| 74 | | 221 | |
| 75 | - /// Send input to the terminal | 222 | + /// Send input to the active terminal |
| 76 | pub fn send_input(&mut self, data: &[u8]) -> Result<()> { | 223 | pub fn send_input(&mut self, data: &[u8]) -> Result<()> { |
| 77 | - if let Some(ref mut pty) = self.pty { | 224 | + if let Some(session) = self.sessions.get_mut(self.active_session) { |
| 78 | - pty.write(data)?; | 225 | + session.send_input(data)?; |
| 79 | } | 226 | } |
| 80 | Ok(()) | 227 | Ok(()) |
| 81 | } | 228 | } |
| 82 | | 229 | |
| 83 | - /// Send a key to the terminal | 230 | + /// Send a key to the active terminal |
| 84 | pub fn send_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<()> { | 231 | pub fn send_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<()> { |
| 85 | use crossterm::event::{KeyCode, KeyModifiers}; | 232 | use crossterm::event::{KeyCode, KeyModifiers}; |
| 86 | | 233 | |
@@ -137,47 +284,53 @@ impl TerminalPanel { |
| 137 | Ok(()) | 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 | pub fn poll(&mut self) -> bool { | 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 { | 291 | + // Poll all sessions (to keep them responsive) |
| 145 | - // Check if shell has exited | 292 | + for session in &mut self.sessions { |
| 146 | - if !pty.is_alive() { | 293 | + if session.poll() { |
| 147 | - // Shell exited - close terminal and clean up | 294 | + had_activity = true; |
| 148 | - self.visible = false; | | |
| 149 | - self.pty = None; | | |
| 150 | - return true; // Trigger render to hide terminal | | |
| 151 | } | 295 | } |
| | 296 | + } |
| 152 | | 297 | |
| 153 | - if let Some(data) = pty.read() { | 298 | + // Remove dead sessions |
| 154 | - self.screen.process(&data); | 299 | + let active_before = self.active_session; |
| 155 | - had_data = true; | 300 | + self.sessions.retain(|s| s.is_alive()); |
| 156 | - } | 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 | 307 | + // Adjust active_session if sessions were removed |
| 160 | - let responses = self.screen.drain_responses(); | 308 | + if self.active_session >= self.sessions.len() { |
| 161 | - for response in responses { | 309 | + self.active_session = self.sessions.len() - 1; |
| 162 | - let _ = self.send_input(&response); | 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 | 318 | + /// Get the active terminal screen for rendering |
| 169 | - pub fn screen(&self) -> &TerminalScreen { | 319 | + pub fn screen(&self) -> Option<&TerminalScreen> { |
| 170 | - &self.screen | 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 | pub fn get_cell(&self, row: usize, col: usize) -> Option<&Cell> { | 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 | pub fn cursor_pos(&self) -> (u16, u16) { | 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 | /// Update screen dimensions | 336 | /// Update screen dimensions |
@@ -189,15 +342,11 @@ impl TerminalPanel { |
| 189 | let max_height = height * MAX_HEIGHT_PERCENT / 100; | 342 | let max_height = height * MAX_HEIGHT_PERCENT / 100; |
| 190 | self.height = self.height.min(max_height).max(MIN_HEIGHT_ROWS); | 343 | self.height = self.height.min(max_height).max(MIN_HEIGHT_ROWS); |
| 191 | | 344 | |
| 192 | - // Content height excludes title bar | 345 | + let content_height = self.content_height(); |
| 193 | - let content_height = self.height.saturating_sub(1).max(1); | | |
| 194 | - | | |
| 195 | - // Resize terminal screen | | |
| 196 | - self.screen.resize(width, content_height); | | |
| 197 | | 346 | |
| 198 | - // Resize PTY | 347 | + // Resize all sessions |
| 199 | - if let Some(ref pty) = self.pty { | 348 | + for session in &mut self.sessions { |
| 200 | - let _ = pty.resize(width, content_height); | 349 | + session.resize(width, content_height); |
| 201 | } | 350 | } |
| 202 | } | 351 | } |
| 203 | | 352 | |
@@ -206,13 +355,11 @@ impl TerminalPanel { |
| 206 | let max_height = self.screen_height * MAX_HEIGHT_PERCENT / 100; | 355 | let max_height = self.screen_height * MAX_HEIGHT_PERCENT / 100; |
| 207 | self.height = new_height.min(max_height).max(MIN_HEIGHT_ROWS); | 356 | self.height = new_height.min(max_height).max(MIN_HEIGHT_ROWS); |
| 208 | | 357 | |
| 209 | - // Content height excludes title bar | 358 | + let content_height = self.content_height(); |
| 210 | - let content_height = self.height.saturating_sub(1).max(1); | | |
| 211 | - | | |
| 212 | - self.screen.resize(self.screen_width, content_height); | | |
| 213 | | 359 | |
| 214 | - if let Some(ref pty) = self.pty { | 360 | + // Resize all sessions |
| 215 | - let _ = pty.resize(self.screen_width, content_height); | 361 | + for session in &mut self.sessions { |
| | 362 | + session.resize(self.screen_width, content_height); |
| 216 | } | 363 | } |
| 217 | } | 364 | } |
| 218 | | 365 | |