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 crate::services::authority::TerminalWrapper;
24use portable_pty::{native_pty_system, CommandBuilder, PtySize};
25use std::borrow::Cow;
26use std::collections::HashMap;
27use std::io::{Read, Write};
28use std::sync::atomic::AtomicBool;
29use std::sync::mpsc;
30use std::sync::{Arc, Mutex};
31use std::thread;
32
33pub use fresh_core::TerminalId;
34
35/// Messages sent to terminal I/O thread
36enum TerminalCommand {
37    /// Write data to PTY
38    Write(Vec<u8>),
39    /// Resize the PTY
40    Resize { cols: u16, rows: u16 },
41    /// Shutdown the terminal
42    Shutdown,
43}
44
45/// Handle to a running terminal session
46pub struct TerminalHandle {
47    /// Terminal state (grid, cursor, etc.)
48    pub state: Arc<Mutex<TerminalState>>,
49    /// Command sender to I/O thread
50    command_tx: mpsc::Sender<TerminalCommand>,
51    /// Whether the terminal is still alive
52    alive: Arc<std::sync::atomic::AtomicBool>,
53    /// Current dimensions
54    cols: u16,
55    rows: u16,
56    /// Working directory used for the terminal
57    cwd: Option<std::path::PathBuf>,
58    /// Shell executable used to spawn the terminal
59    shell: String,
60}
61
62impl TerminalHandle {
63    /// Write data to the terminal (sends to PTY)
64    pub fn write(&self, data: &[u8]) {
65        // Receiver may be dropped if terminal exited; nothing to do in that case.
66        #[allow(clippy::let_underscore_must_use)]
67        let _ = self.command_tx.send(TerminalCommand::Write(data.to_vec()));
68    }
69
70    /// Resize the terminal
71    pub fn resize(&mut self, cols: u16, rows: u16) {
72        if cols != self.cols || rows != self.rows {
73            self.cols = cols;
74            self.rows = rows;
75            // Receiver may be dropped if terminal exited; nothing to do in that case.
76            #[allow(clippy::let_underscore_must_use)]
77            let _ = self.command_tx.send(TerminalCommand::Resize { cols, rows });
78            // Also resize the terminal state
79            if let Ok(mut state) = self.state.lock() {
80                state.resize(cols, rows);
81            }
82        }
83    }
84
85    /// Check if the terminal is still running
86    pub fn is_alive(&self) -> bool {
87        self.alive.load(std::sync::atomic::Ordering::Relaxed)
88    }
89
90    /// Shutdown the terminal
91    pub fn shutdown(&self) {
92        // Receiver may be dropped if terminal already exited; nothing to do in that case.
93        #[allow(clippy::let_underscore_must_use)]
94        let _ = self.command_tx.send(TerminalCommand::Shutdown);
95    }
96
97    /// Get current dimensions
98    pub fn size(&self) -> (u16, u16) {
99        (self.cols, self.rows)
100    }
101
102    /// Get the working directory configured for the terminal
103    pub fn cwd(&self) -> Option<std::path::PathBuf> {
104        self.cwd.clone()
105    }
106
107    /// Get the shell executable path used for this terminal
108    pub fn shell(&self) -> &str {
109        &self.shell
110    }
111}
112
113/// Manager for multiple terminal sessions
114pub struct TerminalManager {
115    /// Map from terminal ID to handle
116    terminals: HashMap<TerminalId, TerminalHandle>,
117    /// Next terminal ID
118    next_id: usize,
119    /// Async bridge for sending notifications to main loop
120    async_bridge: Option<AsyncBridge>,
121}
122
123impl TerminalManager {
124    /// Create a new terminal manager
125    pub fn new() -> Self {
126        Self {
127            terminals: HashMap::new(),
128            next_id: 0,
129            async_bridge: None,
130        }
131    }
132
133    /// Set the async bridge for communication with main loop
134    pub fn set_async_bridge(&mut self, bridge: AsyncBridge) {
135        self.async_bridge = Some(bridge);
136    }
137
138    /// Peek at the next terminal ID that would be assigned.
139    pub fn next_terminal_id(&self) -> TerminalId {
140        TerminalId(self.next_id)
141    }
142
143    /// Spawn a new terminal session
144    ///
145    /// # Arguments
146    /// * `cols` - Initial terminal width in columns
147    /// * `rows` - Initial terminal height in rows
148    /// * `cwd` - Optional working directory (defaults to current directory)
149    /// * `log_path` - Optional path for raw PTY log (for session restore)
150    /// * `backing_path` - Optional path for rendered scrollback (incremental streaming)
151    ///
152    /// # Returns
153    /// The terminal ID if successful
154    pub fn spawn(
155        &mut self,
156        cols: u16,
157        rows: u16,
158        cwd: Option<std::path::PathBuf>,
159        log_path: Option<std::path::PathBuf>,
160        backing_path: Option<std::path::PathBuf>,
161        terminal_wrapper: crate::services::authority::TerminalWrapper,
162    ) -> Result<TerminalId, String> {
163        let id = TerminalId(self.next_id);
164        self.next_id += 1;
165
166        // Try to spawn a real PTY-backed terminal first.
167        let handle_result: Result<TerminalHandle, String> = (|| {
168            // Create PTY
169            let pty_system = native_pty_system();
170            let pty_pair = pty_system
171                .openpty(PtySize {
172                    rows,
173                    cols,
174                    pixel_width: 0,
175                    pixel_height: 0,
176                })
177                .map_err(|e| {
178                    #[cfg(windows)]
179                    {
180                        format!(
181                            "Failed to open PTY: {}. Note: Terminal requires Windows 10 version 1809 or later with ConPTY support.",
182                            e
183                        )
184                    }
185                    #[cfg(not(windows))]
186                    {
187                        format!("Failed to open PTY: {}", e)
188                    }
189                })?;
190
191            // The active authority's terminal wrapper drives the shell
192            // command unconditionally — local wraps `detect_shell()` with
193            // no args; container/remote authorities re-parent into
194            // `docker exec -w …`, `ssh …`, etc. `manages_cwd` says
195            // whether the wrapper's args already establish cwd (in which
196            // case `CommandBuilder::cwd()` is skipped).
197            let TerminalWrapper {
198                command: shell,
199                args: cmd_args,
200                manages_cwd: skip_cwd,
201            } = terminal_wrapper;
202            tracing::info!("Spawning terminal with shell: {}", shell);
203
204            let mut cmd = CommandBuilder::new(&shell);
205            for arg in &cmd_args {
206                cmd.arg(arg);
207            }
208            if !skip_cwd {
209                if let Some(ref dir) = cwd {
210                    // Hand the shell a non-verbatim path. Fresh canonicalizes
211                    // working_dir at startup, which on Windows yields
212                    // `\\?\C:\…`. PowerShell can't infer a drive from a
213                    // verbatim path and falls back to the fully-qualified
214                    // provider form, producing prompts like
215                    // `PS Microsoft.PowerShell.Core\FileSystem::\\?\C:\…>`.
216                    cmd.cwd(strip_verbatim_prefix(dir).as_ref());
217                }
218            }
219
220            // Set TERM so programs like less know the terminal capabilities.
221            // The built-in emulator is alacritty-based so xterm-256color is appropriate.
222            cmd.env("TERM", "xterm-256color");
223
224            // On Windows, set additional environment variables that help with ConPTY
225            #[cfg(windows)]
226            {
227                // Ensure PROMPT is set for cmd.exe
228                if shell.to_lowercase().contains("cmd") {
229                    cmd.env("PROMPT", "$P$G");
230                }
231            }
232
233            // Spawn the shell process
234            let mut child = pty_pair
235                .slave
236                .spawn_command(cmd)
237                .map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
238
239            tracing::debug!("Shell process spawned successfully");
240
241            // Create terminal state
242            let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
243
244            // Initialize backing_file_history_end if backing file already exists (session restore)
245            // This ensures enter_terminal_mode doesn't truncate existing history to 0
246            if let Some(ref p) = backing_path {
247                if let Ok(metadata) = std::fs::metadata(p) {
248                    if metadata.len() > 0 {
249                        if let Ok(mut s) = state.lock() {
250                            s.set_backing_file_history_end(metadata.len());
251                        }
252                    }
253                }
254            }
255
256            // Create communication channel
257            let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
258
259            // Alive flag
260            let alive = Arc::new(AtomicBool::new(true));
261            let alive_clone = alive.clone();
262
263            // Get master for I/O
264            let mut master = pty_pair
265                .master
266                .take_writer()
267                .map_err(|e| format!("Failed to get PTY writer: {}", e))?;
268
269            let mut reader = pty_pair
270                .master
271                .try_clone_reader()
272                .map_err(|e| format!("Failed to get PTY reader: {}", e))?;
273
274            // Clone state for reader thread
275            let state_clone = state.clone();
276            let async_bridge = self.async_bridge.clone();
277
278            // Optional raw log writer for full-session capture (for live terminal resume)
279            let mut log_writer = log_path
280                .as_ref()
281                .and_then(|p| {
282                    std::fs::OpenOptions::new()
283                        .create(true)
284                        .append(true)
285                        .open(p)
286                        .ok()
287                })
288                .map(std::io::BufWriter::new);
289
290            // Backing file writer for incremental scrollback streaming
291            // During session restore, the backing file may already contain scrollback content.
292            // We open for append to continue streaming new scrollback after the existing content.
293            // For new terminals, append mode also works (creates file if needed).
294            let mut backing_writer = backing_path
295                .as_ref()
296                .and_then(|p| {
297                    // Check if backing file exists and has content (session restore case)
298                    let existing_has_content =
299                        p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
300
301                    if existing_has_content {
302                        // Session restore: open for append to continue streaming new scrollback
303                        // The existing content is preserved and loaded into buffer separately.
304                        // Note: enter_terminal_mode will truncate when user re-enters terminal.
305                        std::fs::OpenOptions::new()
306                            .create(true)
307                            .append(true)
308                            .open(p)
309                            .ok()
310                    } else {
311                        // New terminal: start fresh with truncate
312                        std::fs::OpenOptions::new()
313                            .create(true)
314                            .write(true)
315                            .truncate(true)
316                            .open(p)
317                            .ok()
318                    }
319                })
320                .map(std::io::BufWriter::new);
321
322            // Spawn reader thread
323            let terminal_id = id;
324            let pty_response_tx = command_tx.clone();
325            thread::spawn(move || {
326                tracing::debug!("Terminal {:?} reader thread started", terminal_id);
327                let mut buf = [0u8; 4096];
328                let mut total_bytes = 0usize;
329                loop {
330                    match reader.read(&mut buf) {
331                        Ok(0) => {
332                            // EOF - process exited
333                            tracing::info!(
334                                "Terminal {:?} EOF after {} total bytes",
335                                terminal_id,
336                                total_bytes
337                            );
338                            break;
339                        }
340                        Ok(n) => {
341                            total_bytes += n;
342                            tracing::debug!(
343                                "Terminal {:?} received {} bytes (total: {})",
344                                terminal_id,
345                                n,
346                                total_bytes
347                            );
348                            // Process output through terminal emulator and stream scrollback
349                            if let Ok(mut state) = state_clone.lock() {
350                                state.process_output(&buf[..n]);
351
352                                // Send any PTY write responses (e.g., DSR cursor position)
353                                // This is critical for Windows ConPTY where PowerShell waits
354                                // for cursor position response before showing the prompt
355                                for response in state.drain_pty_write_queue() {
356                                    tracing::debug!(
357                                        "Terminal {:?} sending PTY response: {:?}",
358                                        terminal_id,
359                                        response
360                                    );
361                                    // Receiver may be dropped if writer thread exited.
362                                    #[allow(clippy::let_underscore_must_use)]
363                                    let _ = pty_response_tx
364                                        .send(TerminalCommand::Write(response.into_bytes()));
365                                }
366
367                                // Incrementally stream new scrollback lines to backing file
368                                if let Some(ref mut writer) = backing_writer {
369                                    match state.flush_new_scrollback(writer) {
370                                        Ok(lines_written) => {
371                                            if lines_written > 0 {
372                                                // Update the history end offset
373                                                if let Ok(pos) = writer.get_ref().metadata() {
374                                                    state.set_backing_file_history_end(pos.len());
375                                                }
376                                                // Best-effort flush; backing file errors handled below.
377                                                #[allow(clippy::let_underscore_must_use)]
378                                                let _ = writer.flush();
379                                            }
380                                        }
381                                        Err(e) => {
382                                            tracing::warn!(
383                                                "Terminal backing file write error: {}",
384                                                e
385                                            );
386                                            backing_writer = None;
387                                        }
388                                    }
389                                }
390                            }
391
392                            // Append raw bytes to log if available (for session restore replay)
393                            if let Some(w) = log_writer.as_mut() {
394                                if let Err(e) = w.write_all(&buf[..n]) {
395                                    tracing::warn!("Terminal log write error: {}", e);
396                                    log_writer = None; // stop logging on error
397                                } else if let Err(e) = w.flush() {
398                                    tracing::warn!("Terminal log flush error: {}", e);
399                                    log_writer = None;
400                                }
401                            }
402
403                            // Notify main loop to redraw (receiver may be dropped during shutdown).
404                            if let Some(ref bridge) = async_bridge {
405                                #[allow(clippy::let_underscore_must_use)]
406                                let _ = bridge.sender().send(
407                                    crate::services::async_bridge::AsyncMessage::TerminalOutput {
408                                        terminal_id,
409                                    },
410                                );
411                            }
412                        }
413                        Err(e) => {
414                            tracing::error!("Terminal read error: {}", e);
415                            break;
416                        }
417                    }
418                }
419                alive_clone.store(false, std::sync::atomic::Ordering::Relaxed);
420                // Best-effort flush of log/backing files during teardown.
421                if let Some(mut w) = log_writer {
422                    #[allow(clippy::let_underscore_must_use)]
423                    let _ = w.flush();
424                }
425                if let Some(mut w) = backing_writer {
426                    #[allow(clippy::let_underscore_must_use)]
427                    let _ = w.flush();
428                }
429                // Notify that terminal exited (receiver may be dropped during shutdown).
430                if let Some(ref bridge) = async_bridge {
431                    #[allow(clippy::let_underscore_must_use)]
432                    let _ = bridge.sender().send(
433                        crate::services::async_bridge::AsyncMessage::TerminalExited { terminal_id },
434                    );
435                }
436            });
437
438            // Spawn writer thread
439            let pty_size_ref = pty_pair.master;
440            thread::spawn(move || {
441                loop {
442                    match command_rx.recv() {
443                        Ok(TerminalCommand::Write(data)) => {
444                            if let Err(e) = master.write_all(&data) {
445                                tracing::error!("Terminal write error: {}", e);
446                                break;
447                            }
448                            // Best-effort flush — PTY write errors are handled above.
449                            #[allow(clippy::let_underscore_must_use)]
450                            let _ = master.flush();
451                        }
452                        Ok(TerminalCommand::Resize { cols, rows }) => {
453                            if let Err(e) = pty_size_ref.resize(PtySize {
454                                rows,
455                                cols,
456                                pixel_width: 0,
457                                pixel_height: 0,
458                            }) {
459                                tracing::warn!("Failed to resize PTY: {}", e);
460                            }
461                        }
462                        Ok(TerminalCommand::Shutdown) | Err(_) => {
463                            break;
464                        }
465                    }
466                }
467                // Best-effort child process cleanup during teardown.
468                #[allow(clippy::let_underscore_must_use)]
469                let _ = child.kill();
470                #[allow(clippy::let_underscore_must_use)]
471                let _ = child.wait();
472            });
473
474            // Create handle
475            Ok(TerminalHandle {
476                state,
477                command_tx,
478                alive,
479                cols,
480                rows,
481                cwd: cwd.clone(),
482                shell,
483            })
484        })();
485
486        let handle = handle_result?;
487
488        self.terminals.insert(id, handle);
489        tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
490
491        Ok(id)
492    }
493
494    /// Get a terminal handle by ID
495    pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
496        self.terminals.get(&id)
497    }
498
499    /// Get a mutable terminal handle by ID
500    pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
501        self.terminals.get_mut(&id)
502    }
503
504    /// Close a terminal
505    pub fn close(&mut self, id: TerminalId) -> bool {
506        if let Some(handle) = self.terminals.remove(&id) {
507            handle.shutdown();
508            true
509        } else {
510            false
511        }
512    }
513
514    /// Get all terminal IDs
515    pub fn terminal_ids(&self) -> Vec<TerminalId> {
516        self.terminals.keys().copied().collect()
517    }
518
519    /// Get count of open terminals
520    pub fn count(&self) -> usize {
521        self.terminals.len()
522    }
523
524    /// Shutdown all terminals
525    pub fn shutdown_all(&mut self) {
526        for (_, handle) in self.terminals.drain() {
527            handle.shutdown();
528        }
529    }
530
531    /// Clean up dead terminals
532    pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
533        let dead: Vec<TerminalId> = self
534            .terminals
535            .iter()
536            .filter(|(_, h)| !h.is_alive())
537            .map(|(id, _)| *id)
538            .collect();
539
540        for id in &dead {
541            self.terminals.remove(id);
542        }
543
544        dead
545    }
546}
547
548impl Default for TerminalManager {
549    fn default() -> Self {
550        Self::new()
551    }
552}
553
554impl Drop for TerminalManager {
555    fn drop(&mut self) {
556        self.shutdown_all();
557    }
558}
559
560/// Convert a Windows verbatim path (`\\?\C:\…` or `\\?\UNC\server\share\…`)
561/// into its non-verbatim equivalent (`C:\…` or `\\server\share\…`).
562///
563/// Returns the input unchanged on non-Windows platforms or for paths that
564/// have no verbatim prefix.
565pub(crate) fn strip_verbatim_prefix(path: &std::path::Path) -> Cow<'_, std::path::Path> {
566    #[cfg(windows)]
567    {
568        use std::path::{Component, Prefix};
569
570        let mut components = path.components();
571        let prefix = match components.next() {
572            Some(Component::Prefix(p)) => p,
573            _ => return Cow::Borrowed(path),
574        };
575
576        let mut rebuilt = std::path::PathBuf::new();
577        match prefix.kind() {
578            Prefix::VerbatimDisk(drive) => {
579                rebuilt.push(format!("{}:\\", drive as char));
580            }
581            Prefix::VerbatimUNC(server, share) => {
582                rebuilt.push(format!(
583                    r"\\{}\{}\",
584                    server.to_string_lossy(),
585                    share.to_string_lossy()
586                ));
587            }
588            _ => return Cow::Borrowed(path),
589        }
590        // Skip the original RootDir (which the rebuilt prefix already includes)
591        // and append the rest of the components.
592        for component in components {
593            if matches!(component, Component::RootDir) {
594                continue;
595            }
596            rebuilt.push(component.as_os_str());
597        }
598        Cow::Owned(rebuilt)
599    }
600    #[cfg(not(windows))]
601    {
602        Cow::Borrowed(path)
603    }
604}
605
606/// Detect the user's shell
607pub fn detect_shell() -> String {
608    // Try $SHELL environment variable first
609    if let Ok(shell) = std::env::var("SHELL") {
610        if !shell.is_empty() {
611            return shell;
612        }
613    }
614
615    // Fall back to platform defaults
616    #[cfg(unix)]
617    {
618        "/bin/sh".to_string()
619    }
620    #[cfg(windows)]
621    {
622        // On Windows, prefer PowerShell for better ConPTY and ANSI escape support
623        // Check for PowerShell Core (pwsh) first, then Windows PowerShell
624        let powershell_paths = [
625            "pwsh.exe",
626            "powershell.exe",
627            r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
628        ];
629
630        for ps in &powershell_paths {
631            if std::path::Path::new(ps).exists() || which_exists(ps) {
632                return ps.to_string();
633            }
634        }
635
636        // Fall back to COMSPEC (cmd.exe)
637        std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
638    }
639}
640
641/// Check if command exists in PATH (Windows)
642#[cfg(windows)]
643fn which_exists(cmd: &str) -> bool {
644    if let Ok(path_var) = std::env::var("PATH") {
645        for path in path_var.split(';') {
646            let full_path = std::path::Path::new(path).join(cmd);
647            if full_path.exists() {
648                return true;
649            }
650        }
651    }
652    false
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_terminal_id_display() {
661        let id = TerminalId(42);
662        assert_eq!(format!("{}", id), "Terminal-42");
663    }
664
665    #[test]
666    fn test_detect_shell() {
667        let shell = detect_shell();
668        assert!(!shell.is_empty());
669    }
670
671    #[cfg(not(windows))]
672    #[test]
673    fn strip_verbatim_prefix_is_noop_on_unix() {
674        use std::path::Path;
675        let p = Path::new("/home/user/project");
676        assert_eq!(strip_verbatim_prefix(p).as_ref(), p);
677    }
678
679    #[cfg(windows)]
680    #[test]
681    fn strip_verbatim_prefix_removes_verbatim_disk() {
682        use std::path::{Path, PathBuf};
683        let verbatim = PathBuf::from(r"\\?\C:\Users\HP\OneDrive\Desktop\PY'PGMS");
684        let stripped = strip_verbatim_prefix(&verbatim);
685        assert_eq!(
686            stripped.as_ref(),
687            Path::new(r"C:\Users\HP\OneDrive\Desktop\PY'PGMS"),
688            "verbatim disk prefix should be replaced with plain drive form"
689        );
690    }
691
692    #[cfg(windows)]
693    #[test]
694    fn strip_verbatim_prefix_removes_verbatim_unc() {
695        use std::path::{Path, PathBuf};
696        let verbatim = PathBuf::from(r"\\?\UNC\server\share\dir\file");
697        let stripped = strip_verbatim_prefix(&verbatim);
698        assert_eq!(
699            stripped.as_ref(),
700            Path::new(r"\\server\share\dir\file"),
701            "verbatim UNC prefix should be replaced with plain UNC form"
702        );
703    }
704
705    #[cfg(windows)]
706    #[test]
707    fn strip_verbatim_prefix_passes_plain_paths_through() {
708        use std::path::{Path, PathBuf};
709        let plain = PathBuf::from(r"C:\Users\HP\project");
710        let result = strip_verbatim_prefix(&plain);
711        assert_eq!(result.as_ref(), Path::new(r"C:\Users\HP\project"));
712    }
713}