tenseleyflow/fackr / 4d05e34

Browse files

feat: add multi-session support to terminal panel

Refactor TerminalPanel to support multiple terminal sessions:
- Add TerminalSession struct holding PTY + screen buffer per session
- Change from single PTY to Vec<TerminalSession> with active index
- Add session management methods: new_session(), close_active_session(),
switch_session(), next_session(), prev_session()
- Poll all sessions to keep background shells responsive
- Auto-remove dead sessions and hide terminal when last closes
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
4d05e3457a9ae3435bd4f67643dd9bf16b324522
Parents
04a8281
Tree
7371944

1 changed file

StatusFile+-
M src/terminal/panel.rs 206 59
src/terminal/panel.rsmodified
@@ -1,6 +1,6 @@
11
 //! Terminal panel
22
 //!
3
-//! The main interface for the integrated terminal.
3
+//! The main interface for the integrated terminal with multi-session support.
44
 
55
 use anyhow::Result;
66
 
@@ -14,12 +14,90 @@ const MAX_HEIGHT_PERCENT: u16 = 80;
1414
 /// Minimum terminal height in rows
1515
 const MIN_HEIGHT_ROWS: u16 = 3;
1616
 
17
-/// Integrated terminal panel
18
-pub struct TerminalPanel {
17
+/// A single terminal session (PTY + screen buffer)
18
+pub struct TerminalSession {
1919
     /// PTY connection to shell
2020
     pty: Option<Pty>,
2121
     /// Terminal screen buffer
2222
     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,
23101
     /// Whether the terminal is visible
24102
     pub visible: bool,
25103
     /// Terminal height in rows
@@ -34,11 +112,9 @@ impl TerminalPanel {
34112
     /// Create a new terminal panel (not yet spawned)
35113
     pub fn new(screen_width: u16, screen_height: u16) -> Self {
36114
         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);
39115
         Self {
40
-            pty: None,
41
-            screen: TerminalScreen::new(screen_width, content_height),
116
+            sessions: Vec::new(),
117
+            active_session: 0,
42118
             visible: false,
43119
             height,
44120
             screen_height,
@@ -46,41 +122,112 @@ impl TerminalPanel {
46122
         }
47123
     }
48124
 
125
+    /// Get the content height (excluding title bar)
126
+    fn content_height(&self) -> u16 {
127
+        self.height.saturating_sub(1).max(1)
128
+    }
129
+
49130
     /// Toggle terminal visibility
50131
     pub fn toggle(&mut self) -> Result<()> {
51132
         self.visible = !self.visible;
52133
 
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()?;
56137
         }
57138
 
58139
         Ok(())
59140
     }
60141
 
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;
67149
         Ok(())
68150
     }
69151
 
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
+
70217
     /// Hide the terminal (ESC pressed)
71218
     pub fn hide(&mut self) {
72219
         self.visible = false;
73220
     }
74221
 
75
-    /// Send input to the terminal
222
+    /// Send input to the active terminal
76223
     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)?;
79226
         }
80227
         Ok(())
81228
     }
82229
 
83
-    /// Send a key to the terminal
230
+    /// Send a key to the active terminal
84231
     pub fn send_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<()> {
85232
         use crossterm::event::{KeyCode, KeyModifiers};
86233
 
@@ -137,47 +284,53 @@ impl TerminalPanel {
137284
         Ok(())
138285
     }
139286
 
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.
141288
     pub fn poll(&mut self) -> bool {
142
-        let mut had_data = false;
289
+        let mut had_activity = false;
143290
 
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;
151295
             }
296
+        }
152297
 
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;
157305
         }
158306
 
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;
163313
         }
164314
 
165
-        had_data
315
+        had_activity
166316
     }
167317
 
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())
171321
     }
172322
 
173
-    /// Get a cell from the terminal screen
323
+    /// Get a cell from the active terminal screen
174324
     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))
176326
     }
177327
 
178
-    /// Get cursor position
328
+    /// Get cursor position from the active session
179329
     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))
181334
     }
182335
 
183336
     /// Update screen dimensions
@@ -189,15 +342,11 @@ impl TerminalPanel {
189342
         let max_height = height * MAX_HEIGHT_PERCENT / 100;
190343
         self.height = self.height.min(max_height).max(MIN_HEIGHT_ROWS);
191344
 
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();
197346
 
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);
201350
         }
202351
     }
203352
 
@@ -206,13 +355,11 @@ impl TerminalPanel {
206355
         let max_height = self.screen_height * MAX_HEIGHT_PERCENT / 100;
207356
         self.height = new_height.min(max_height).max(MIN_HEIGHT_ROWS);
208357
 
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();
213359
 
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);
216363
         }
217364
     }
218365