Skip to main content

fresh/services/terminal/
manager.rs

1//! Terminal Manager - manages multiple terminal sessions
2//!
3//! This module provides a manager for terminal sessions that:
4//! - Spawns PTY processes with proper shell detection
5//! - Manages multiple concurrent terminals
6//! - Routes input/output between the editor and terminal processes
7//! - Handles terminal resize events
8//!
9//! # Role in Incremental Streaming Architecture
10//!
11//! The manager owns the PTY read loop which is the entry point for incremental
12//! scrollback streaming. See `super` module docs for the full architecture overview.
13//!
14//! ## PTY Read Loop
15//!
16//! The read loop in `spawn()` performs incremental streaming: for each PTY read,
17//! it calls `process_output()` to update the terminal grid, then `flush_new_scrollback()`
18//! to append any new scrollback lines to the backing file. This ensures scrollback is
19//! written incrementally as lines scroll off screen, avoiding O(n) work on mode switches.
20
21use super::term::TerminalState;
22use crate::services::async_bridge::AsyncBridge;
23use portable_pty::{native_pty_system, CommandBuilder, PtySize};
24use std::collections::HashMap;
25use std::io::{Read, Write};
26use std::sync::atomic::AtomicBool;
27use std::sync::mpsc;
28use std::sync::{Arc, Mutex};
29use std::thread;
30
31pub use fresh_core::TerminalId;
32
33/// Messages sent to terminal I/O thread
34enum TerminalCommand {
35    /// Write data to PTY
36    Write(Vec<u8>),
37    /// Resize the PTY
38    Resize { cols: u16, rows: u16 },
39    /// Shutdown the terminal
40    Shutdown,
41}
42
43/// Handle to a running terminal session
44pub struct TerminalHandle {
45    /// Terminal state (grid, cursor, etc.)
46    pub state: Arc<Mutex<TerminalState>>,
47    /// Command sender to I/O thread
48    command_tx: mpsc::Sender<TerminalCommand>,
49    /// Whether the terminal is still alive
50    alive: Arc<std::sync::atomic::AtomicBool>,
51    /// Current dimensions
52    cols: u16,
53    rows: u16,
54    /// Working directory used for the terminal
55    cwd: Option<std::path::PathBuf>,
56    /// Shell executable used to spawn the terminal
57    shell: String,
58}
59
60impl TerminalHandle {
61    /// Write data to the terminal (sends to PTY)
62    pub fn write(&self, data: &[u8]) {
63        // Receiver may be dropped if terminal exited; nothing to do in that case.
64        #[allow(clippy::let_underscore_must_use)]
65        let _ = self.command_tx.send(TerminalCommand::Write(data.to_vec()));
66    }
67
68    /// Resize the terminal
69    pub fn resize(&mut self, cols: u16, rows: u16) {
70        if cols != self.cols || rows != self.rows {
71            self.cols = cols;
72            self.rows = rows;
73            // Receiver may be dropped if terminal exited; nothing to do in that case.
74            #[allow(clippy::let_underscore_must_use)]
75            let _ = self.command_tx.send(TerminalCommand::Resize { cols, rows });
76            // Also resize the terminal state
77            if let Ok(mut state) = self.state.lock() {
78                state.resize(cols, rows);
79            }
80        }
81    }
82
83    /// Check if the terminal is still running
84    pub fn is_alive(&self) -> bool {
85        self.alive.load(std::sync::atomic::Ordering::Relaxed)
86    }
87
88    /// Shutdown the terminal
89    pub fn shutdown(&self) {
90        // Receiver may be dropped if terminal already exited; nothing to do in that case.
91        #[allow(clippy::let_underscore_must_use)]
92        let _ = self.command_tx.send(TerminalCommand::Shutdown);
93    }
94
95    /// Get current dimensions
96    pub fn size(&self) -> (u16, u16) {
97        (self.cols, self.rows)
98    }
99
100    /// Get the working directory configured for the terminal
101    pub fn cwd(&self) -> Option<std::path::PathBuf> {
102        self.cwd.clone()
103    }
104
105    /// Get the shell executable path used for this terminal
106    pub fn shell(&self) -> &str {
107        &self.shell
108    }
109}
110
111/// Manager for multiple terminal sessions
112pub struct TerminalManager {
113    /// Map from terminal ID to handle
114    terminals: HashMap<TerminalId, TerminalHandle>,
115    /// Next terminal ID
116    next_id: usize,
117    /// Async bridge for sending notifications to main loop
118    async_bridge: Option<AsyncBridge>,
119}
120
121impl TerminalManager {
122    /// Create a new terminal manager
123    pub fn new() -> Self {
124        Self {
125            terminals: HashMap::new(),
126            next_id: 0,
127            async_bridge: None,
128        }
129    }
130
131    /// Set the async bridge for communication with main loop
132    pub fn set_async_bridge(&mut self, bridge: AsyncBridge) {
133        self.async_bridge = Some(bridge);
134    }
135
136    /// Peek at the next terminal ID that would be assigned.
137    pub fn next_terminal_id(&self) -> TerminalId {
138        TerminalId(self.next_id)
139    }
140
141    /// Spawn a new terminal session
142    ///
143    /// # Arguments
144    /// * `cols` - Initial terminal width in columns
145    /// * `rows` - Initial terminal height in rows
146    /// * `cwd` - Optional working directory (defaults to current directory)
147    /// * `log_path` - Optional path for raw PTY log (for session restore)
148    /// * `backing_path` - Optional path for rendered scrollback (incremental streaming)
149    ///
150    /// # Returns
151    /// The terminal ID if successful
152    pub fn spawn(
153        &mut self,
154        cols: u16,
155        rows: u16,
156        cwd: Option<std::path::PathBuf>,
157        log_path: Option<std::path::PathBuf>,
158        backing_path: Option<std::path::PathBuf>,
159    ) -> Result<TerminalId, String> {
160        let id = TerminalId(self.next_id);
161        self.next_id += 1;
162
163        // Try to spawn a real PTY-backed terminal first.
164        let handle_result: Result<TerminalHandle, String> = (|| {
165            // Create PTY
166            let pty_system = native_pty_system();
167            let pty_pair = pty_system
168                .openpty(PtySize {
169                    rows,
170                    cols,
171                    pixel_width: 0,
172                    pixel_height: 0,
173                })
174                .map_err(|e| {
175                    #[cfg(windows)]
176                    {
177                        format!(
178                            "Failed to open PTY: {}. Note: Terminal requires Windows 10 version 1809 or later with ConPTY support.",
179                            e
180                        )
181                    }
182                    #[cfg(not(windows))]
183                    {
184                        format!("Failed to open PTY: {}", e)
185                    }
186                })?;
187
188            // Detect shell
189            let shell = detect_shell();
190            tracing::info!("Spawning terminal with shell: {}", shell);
191
192            // Build command
193            let mut cmd = CommandBuilder::new(&shell);
194            if let Some(ref dir) = cwd {
195                cmd.cwd(dir);
196            }
197
198            // Set TERM so programs like less know the terminal capabilities.
199            // The built-in emulator is alacritty-based so xterm-256color is appropriate.
200            cmd.env("TERM", "xterm-256color");
201
202            // On Windows, set additional environment variables that help with ConPTY
203            #[cfg(windows)]
204            {
205                // Ensure PROMPT is set for cmd.exe
206                if shell.to_lowercase().contains("cmd") {
207                    cmd.env("PROMPT", "$P$G");
208                }
209            }
210
211            // Spawn the shell process
212            let mut child = pty_pair
213                .slave
214                .spawn_command(cmd)
215                .map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
216
217            tracing::debug!("Shell process spawned successfully");
218
219            // Create terminal state
220            let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
221
222            // Initialize backing_file_history_end if backing file already exists (session restore)
223            // This ensures enter_terminal_mode doesn't truncate existing history to 0
224            if let Some(ref p) = backing_path {
225                if let Ok(metadata) = std::fs::metadata(p) {
226                    if metadata.len() > 0 {
227                        if let Ok(mut s) = state.lock() {
228                            s.set_backing_file_history_end(metadata.len());
229                        }
230                    }
231                }
232            }
233
234            // Create communication channel
235            let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
236
237            // Alive flag
238            let alive = Arc::new(AtomicBool::new(true));
239            let alive_clone = alive.clone();
240
241            // Get master for I/O
242            let mut master = pty_pair
243                .master
244                .take_writer()
245                .map_err(|e| format!("Failed to get PTY writer: {}", e))?;
246
247            let mut reader = pty_pair
248                .master
249                .try_clone_reader()
250                .map_err(|e| format!("Failed to get PTY reader: {}", e))?;
251
252            // Clone state for reader thread
253            let state_clone = state.clone();
254            let async_bridge = self.async_bridge.clone();
255
256            // Optional raw log writer for full-session capture (for live terminal resume)
257            let mut log_writer = log_path
258                .as_ref()
259                .and_then(|p| {
260                    std::fs::OpenOptions::new()
261                        .create(true)
262                        .append(true)
263                        .open(p)
264                        .ok()
265                })
266                .map(std::io::BufWriter::new);
267
268            // Backing file writer for incremental scrollback streaming
269            // During session restore, the backing file may already contain scrollback content.
270            // We open for append to continue streaming new scrollback after the existing content.
271            // For new terminals, append mode also works (creates file if needed).
272            let mut backing_writer = backing_path
273                .as_ref()
274                .and_then(|p| {
275                    // Check if backing file exists and has content (session restore case)
276                    let existing_has_content =
277                        p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
278
279                    if existing_has_content {
280                        // Session restore: open for append to continue streaming new scrollback
281                        // The existing content is preserved and loaded into buffer separately.
282                        // Note: enter_terminal_mode will truncate when user re-enters terminal.
283                        std::fs::OpenOptions::new()
284                            .create(true)
285                            .append(true)
286                            .open(p)
287                            .ok()
288                    } else {
289                        // New terminal: start fresh with truncate
290                        std::fs::OpenOptions::new()
291                            .create(true)
292                            .write(true)
293                            .truncate(true)
294                            .open(p)
295                            .ok()
296                    }
297                })
298                .map(std::io::BufWriter::new);
299
300            // Spawn reader thread
301            let terminal_id = id;
302            let pty_response_tx = command_tx.clone();
303            thread::spawn(move || {
304                tracing::debug!("Terminal {:?} reader thread started", terminal_id);
305                let mut buf = [0u8; 4096];
306                let mut total_bytes = 0usize;
307                loop {
308                    match reader.read(&mut buf) {
309                        Ok(0) => {
310                            // EOF - process exited
311                            tracing::info!(
312                                "Terminal {:?} EOF after {} total bytes",
313                                terminal_id,
314                                total_bytes
315                            );
316                            break;
317                        }
318                        Ok(n) => {
319                            total_bytes += n;
320                            tracing::debug!(
321                                "Terminal {:?} received {} bytes (total: {})",
322                                terminal_id,
323                                n,
324                                total_bytes
325                            );
326                            // Process output through terminal emulator and stream scrollback
327                            if let Ok(mut state) = state_clone.lock() {
328                                state.process_output(&buf[..n]);
329
330                                // Send any PTY write responses (e.g., DSR cursor position)
331                                // This is critical for Windows ConPTY where PowerShell waits
332                                // for cursor position response before showing the prompt
333                                for response in state.drain_pty_write_queue() {
334                                    tracing::debug!(
335                                        "Terminal {:?} sending PTY response: {:?}",
336                                        terminal_id,
337                                        response
338                                    );
339                                    // Receiver may be dropped if writer thread exited.
340                                    #[allow(clippy::let_underscore_must_use)]
341                                    let _ = pty_response_tx
342                                        .send(TerminalCommand::Write(response.into_bytes()));
343                                }
344
345                                // Incrementally stream new scrollback lines to backing file
346                                if let Some(ref mut writer) = backing_writer {
347                                    match state.flush_new_scrollback(writer) {
348                                        Ok(lines_written) => {
349                                            if lines_written > 0 {
350                                                // Update the history end offset
351                                                if let Ok(pos) = writer.get_ref().metadata() {
352                                                    state.set_backing_file_history_end(pos.len());
353                                                }
354                                                // Best-effort flush; backing file errors handled below.
355                                                #[allow(clippy::let_underscore_must_use)]
356                                                let _ = writer.flush();
357                                            }
358                                        }
359                                        Err(e) => {
360                                            tracing::warn!(
361                                                "Terminal backing file write error: {}",
362                                                e
363                                            );
364                                            backing_writer = None;
365                                        }
366                                    }
367                                }
368                            }
369
370                            // Append raw bytes to log if available (for session restore replay)
371                            if let Some(w) = log_writer.as_mut() {
372                                if let Err(e) = w.write_all(&buf[..n]) {
373                                    tracing::warn!("Terminal log write error: {}", e);
374                                    log_writer = None; // stop logging on error
375                                } else if let Err(e) = w.flush() {
376                                    tracing::warn!("Terminal log flush error: {}", e);
377                                    log_writer = None;
378                                }
379                            }
380
381                            // Notify main loop to redraw (receiver may be dropped during shutdown).
382                            if let Some(ref bridge) = async_bridge {
383                                #[allow(clippy::let_underscore_must_use)]
384                                let _ = bridge.sender().send(
385                                    crate::services::async_bridge::AsyncMessage::TerminalOutput {
386                                        terminal_id,
387                                    },
388                                );
389                            }
390                        }
391                        Err(e) => {
392                            tracing::error!("Terminal read error: {}", e);
393                            break;
394                        }
395                    }
396                }
397                alive_clone.store(false, std::sync::atomic::Ordering::Relaxed);
398                // Best-effort flush of log/backing files during teardown.
399                if let Some(mut w) = log_writer {
400                    #[allow(clippy::let_underscore_must_use)]
401                    let _ = w.flush();
402                }
403                if let Some(mut w) = backing_writer {
404                    #[allow(clippy::let_underscore_must_use)]
405                    let _ = w.flush();
406                }
407                // Notify that terminal exited (receiver may be dropped during shutdown).
408                if let Some(ref bridge) = async_bridge {
409                    #[allow(clippy::let_underscore_must_use)]
410                    let _ = bridge.sender().send(
411                        crate::services::async_bridge::AsyncMessage::TerminalExited { terminal_id },
412                    );
413                }
414            });
415
416            // Spawn writer thread
417            let pty_size_ref = pty_pair.master;
418            thread::spawn(move || {
419                loop {
420                    match command_rx.recv() {
421                        Ok(TerminalCommand::Write(data)) => {
422                            if let Err(e) = master.write_all(&data) {
423                                tracing::error!("Terminal write error: {}", e);
424                                break;
425                            }
426                            // Best-effort flush — PTY write errors are handled above.
427                            #[allow(clippy::let_underscore_must_use)]
428                            let _ = master.flush();
429                        }
430                        Ok(TerminalCommand::Resize { cols, rows }) => {
431                            if let Err(e) = pty_size_ref.resize(PtySize {
432                                rows,
433                                cols,
434                                pixel_width: 0,
435                                pixel_height: 0,
436                            }) {
437                                tracing::warn!("Failed to resize PTY: {}", e);
438                            }
439                        }
440                        Ok(TerminalCommand::Shutdown) | Err(_) => {
441                            break;
442                        }
443                    }
444                }
445                // Best-effort child process cleanup during teardown.
446                #[allow(clippy::let_underscore_must_use)]
447                let _ = child.kill();
448                #[allow(clippy::let_underscore_must_use)]
449                let _ = child.wait();
450            });
451
452            // Create handle
453            Ok(TerminalHandle {
454                state,
455                command_tx,
456                alive,
457                cols,
458                rows,
459                cwd: cwd.clone(),
460                shell,
461            })
462        })();
463
464        let handle = handle_result?;
465
466        self.terminals.insert(id, handle);
467        tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
468
469        Ok(id)
470    }
471
472    /// Get a terminal handle by ID
473    pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
474        self.terminals.get(&id)
475    }
476
477    /// Get a mutable terminal handle by ID
478    pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
479        self.terminals.get_mut(&id)
480    }
481
482    /// Close a terminal
483    pub fn close(&mut self, id: TerminalId) -> bool {
484        if let Some(handle) = self.terminals.remove(&id) {
485            handle.shutdown();
486            true
487        } else {
488            false
489        }
490    }
491
492    /// Get all terminal IDs
493    pub fn terminal_ids(&self) -> Vec<TerminalId> {
494        self.terminals.keys().copied().collect()
495    }
496
497    /// Get count of open terminals
498    pub fn count(&self) -> usize {
499        self.terminals.len()
500    }
501
502    /// Shutdown all terminals
503    pub fn shutdown_all(&mut self) {
504        for (_, handle) in self.terminals.drain() {
505            handle.shutdown();
506        }
507    }
508
509    /// Clean up dead terminals
510    pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
511        let dead: Vec<TerminalId> = self
512            .terminals
513            .iter()
514            .filter(|(_, h)| !h.is_alive())
515            .map(|(id, _)| *id)
516            .collect();
517
518        for id in &dead {
519            self.terminals.remove(id);
520        }
521
522        dead
523    }
524}
525
526impl Default for TerminalManager {
527    fn default() -> Self {
528        Self::new()
529    }
530}
531
532impl Drop for TerminalManager {
533    fn drop(&mut self) {
534        self.shutdown_all();
535    }
536}
537
538/// Detect the user's shell
539pub fn detect_shell() -> String {
540    // Try $SHELL environment variable first
541    if let Ok(shell) = std::env::var("SHELL") {
542        if !shell.is_empty() {
543            return shell;
544        }
545    }
546
547    // Fall back to platform defaults
548    #[cfg(unix)]
549    {
550        "/bin/sh".to_string()
551    }
552    #[cfg(windows)]
553    {
554        // On Windows, prefer PowerShell for better ConPTY and ANSI escape support
555        // Check for PowerShell Core (pwsh) first, then Windows PowerShell
556        let powershell_paths = [
557            "pwsh.exe",
558            "powershell.exe",
559            r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
560        ];
561
562        for ps in &powershell_paths {
563            if std::path::Path::new(ps).exists() || which_exists(ps) {
564                return ps.to_string();
565            }
566        }
567
568        // Fall back to COMSPEC (cmd.exe)
569        std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
570    }
571}
572
573/// Check if command exists in PATH (Windows)
574#[cfg(windows)]
575fn which_exists(cmd: &str) -> bool {
576    if let Ok(path_var) = std::env::var("PATH") {
577        for path in path_var.split(';') {
578            let full_path = std::path::Path::new(path).join(cmd);
579            if full_path.exists() {
580                return true;
581            }
582        }
583    }
584    false
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_terminal_id_display() {
593        let id = TerminalId(42);
594        assert_eq!(format!("{}", id), "Terminal-42");
595    }
596
597    #[test]
598    fn test_detect_shell() {
599        let shell = detect_shell();
600        assert!(!shell.is_empty());
601    }
602}