Rust · 13839 bytes Raw Blame History
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