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    /// PID of the shell child process at the head of the pty's
61    /// session. `kill(-pid, signal)` (note the negation) signals
62    /// the entire process group, which catches subprocesses the
63    /// shell or agent forked. `None` on Windows or when
64    /// portable_pty couldn't report the pid.
65    pid: Option<u32>,
66    /// PTY master file descriptor, captured at spawn. Used to read the
67    /// terminal's foreground process group via `tcgetpgrp` for tmux-style
68    /// tab auto-naming. `None` on Windows or when the platform doesn't
69    /// expose it. Only read on Linux (the only `/proc`-backed target).
70    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
71    master_fd: Option<i32>,
72}
73
74impl TerminalHandle {
75    /// Write data to the terminal (sends to PTY)
76    pub fn write(&self, data: &[u8]) {
77        // Receiver may be dropped if terminal exited; nothing to do in that case.
78        #[allow(clippy::let_underscore_must_use)]
79        let _ = self.command_tx.send(TerminalCommand::Write(data.to_vec()));
80    }
81
82    /// Resize the terminal
83    pub fn resize(&mut self, cols: u16, rows: u16) {
84        if cols != self.cols || rows != self.rows {
85            self.cols = cols;
86            self.rows = rows;
87            // Receiver may be dropped if terminal exited; nothing to do in that case.
88            #[allow(clippy::let_underscore_must_use)]
89            let _ = self.command_tx.send(TerminalCommand::Resize { cols, rows });
90            // Also resize the terminal state
91            if let Ok(mut state) = self.state.lock() {
92                state.resize(cols, rows);
93            }
94        }
95    }
96
97    /// Check if the terminal is still running
98    pub fn is_alive(&self) -> bool {
99        self.alive.load(std::sync::atomic::Ordering::Relaxed)
100    }
101
102    /// Shutdown the terminal
103    pub fn shutdown(&self) {
104        // Receiver may be dropped if terminal already exited; nothing to do in that case.
105        #[allow(clippy::let_underscore_must_use)]
106        let _ = self.command_tx.send(TerminalCommand::Shutdown);
107    }
108
109    /// Pid of the shell at the head of the pty session, when
110    /// portable_pty was able to report it. Returns `None` on
111    /// platforms / configurations that don't expose a pid.
112    pub fn pid(&self) -> Option<u32> {
113        self.pid
114    }
115
116    /// Name of the command currently in the foreground of this terminal,
117    /// e.g. `"bash"` at the prompt or `"python3"` while a REPL runs.
118    ///
119    /// Derived from the PTY's foreground process *group* (`tcgetpgrp` on
120    /// the master fd) rather than the shell pid, so it tracks whatever the
121    /// user is actually interacting with — the same signal tmux uses for
122    /// `#{pane_current_command}`. This is how a tab can read `python3`
123    /// even though `python3` never emits an OSC title sequence.
124    ///
125    /// Only implemented on Linux (via `/proc/<pgid>/comm`); returns `None`
126    /// elsewhere so callers fall back to the OSC title or default name.
127    pub fn foreground_process_name(&self) -> Option<String> {
128        #[cfg(target_os = "linux")]
129        {
130            let fd = self.master_fd?;
131            // SAFETY: `fd` is the PTY master, kept open by the writer
132            // thread for the terminal's lifetime. `tcgetpgrp` only reads.
133            let pgid = unsafe { libc::tcgetpgrp(fd) };
134            if pgid <= 0 {
135                return None;
136            }
137            // Local OS introspection of a local fd. The `FileSystem` trait
138            // abstracts the *editing* filesystem (possibly remote); it does
139            // not apply to reading this host's `/proc`.
140            let comm = std::fs::read_to_string(format!("/proc/{pgid}/comm")).ok()?;
141            let name = comm.trim();
142            if name.is_empty() {
143                None
144            } else {
145                Some(name.to_string())
146            }
147        }
148        #[cfg(not(target_os = "linux"))]
149        {
150            None
151        }
152    }
153
154    /// Send `signal` to the terminal's process group. Returns
155    /// `Ok(false)` when the terminal has no recorded pid
156    /// (Windows, or platforms where portable_pty didn't report
157    /// one) — caller can fall back to `shutdown()` (SIGKILL via
158    /// child_killer). The shell is always its own session
159    /// leader inside a pty, so `kill(-pid, …)` reaches the
160    /// shell *and* any subprocesses it forked.
161    ///
162    /// Recognised signal names: `"SIGTERM"`, `"SIGKILL"`,
163    /// `"SIGINT"`, `"SIGHUP"`. Unknown names return an Err
164    /// instead of dropping silently.
165    #[cfg(unix)]
166    pub fn signal(&self, signal_name: &str) -> Result<bool, String> {
167        let Some(pid) = self.pid else {
168            return Ok(false);
169        };
170        let sig = match signal_name {
171            "SIGTERM" => libc::SIGTERM,
172            "SIGKILL" => libc::SIGKILL,
173            "SIGINT" => libc::SIGINT,
174            "SIGHUP" => libc::SIGHUP,
175            other => return Err(format!("unsupported signal: {}", other)),
176        };
177        // `kill(-pid, sig)` targets the process group whose
178        // leader is `pid`. The pty puts the spawned shell at
179        // the head of its own session, so this catches
180        // sub-processes the shell or agent forked.
181        let rc = unsafe { libc::kill(-(pid as i32), sig) };
182        if rc == 0 {
183            Ok(true)
184        } else {
185            let err = std::io::Error::last_os_error();
186            // ESRCH = no such process group. Treat as
187            // "nothing to signal" rather than an error so the
188            // caller's stop flow stays idempotent.
189            if err.raw_os_error() == Some(libc::ESRCH) {
190                Ok(false)
191            } else {
192                Err(format!("kill(-{}, {}): {}", pid, signal_name, err))
193            }
194        }
195    }
196
197    /// Windows fallback: no real signal semantics. SIGKILL is
198    /// modelled as the existing `shutdown()` (which calls the
199    /// pty child killer); other signals are unsupported and
200    /// return Ok(false).
201    #[cfg(windows)]
202    pub fn signal(&self, signal_name: &str) -> Result<bool, String> {
203        if signal_name == "SIGKILL" {
204            self.shutdown();
205            return Ok(true);
206        }
207        Ok(false)
208    }
209
210    /// Get current dimensions
211    pub fn size(&self) -> (u16, u16) {
212        (self.cols, self.rows)
213    }
214
215    /// Get the working directory configured for the terminal
216    pub fn cwd(&self) -> Option<std::path::PathBuf> {
217        self.cwd.clone()
218    }
219
220    /// Get the shell executable path used for this terminal
221    pub fn shell(&self) -> &str {
222        &self.shell
223    }
224}
225
226/// Manager for multiple terminal sessions
227pub struct TerminalManager {
228    /// The window that owns this manager. Terminal IDs are only unique
229    /// within a single manager (each starts numbering at 0), so output
230    /// messages are tagged with `(window_id, terminal_id)` — see
231    /// [`fresh_core::WindowTerminalId`] — to stay unambiguous once they
232    /// leave this window's context (e.g. on the async bus).
233    window_id: fresh_core::WindowId,
234    /// Map from terminal ID to handle
235    terminals: HashMap<TerminalId, TerminalHandle>,
236    /// Next terminal ID
237    next_id: usize,
238    /// Async bridge for sending notifications to main loop
239    async_bridge: Option<AsyncBridge>,
240}
241
242impl TerminalManager {
243    /// Create a new terminal manager owned by `window_id`. The owner is
244    /// required (not defaulted) so output can never be attributed to the
245    /// wrong window: every terminal this manager spawns is tagged with
246    /// it.
247    pub fn new(window_id: fresh_core::WindowId) -> Self {
248        Self {
249            window_id,
250            terminals: HashMap::new(),
251            next_id: 0,
252            async_bridge: None,
253        }
254    }
255
256    /// The window that owns this manager.
257    pub fn window_id(&self) -> fresh_core::WindowId {
258        self.window_id
259    }
260
261    /// Set the async bridge for communication with main loop
262    pub fn set_async_bridge(&mut self, bridge: AsyncBridge) {
263        self.async_bridge = Some(bridge);
264    }
265
266    /// Peek at the next terminal ID that would be assigned.
267    pub fn next_terminal_id(&self) -> TerminalId {
268        TerminalId(self.next_id)
269    }
270
271    /// Spawn a new terminal session
272    ///
273    /// # Arguments
274    /// * `cols` - Initial terminal width in columns
275    /// * `rows` - Initial terminal height in rows
276    /// * `cwd` - Optional working directory (defaults to current directory)
277    /// * `log_path` - Optional path for raw PTY log (for session restore)
278    /// * `backing_path` - Optional path for rendered scrollback (incremental streaming)
279    ///
280    /// # Returns
281    /// The terminal ID if successful
282    pub fn spawn(
283        &mut self,
284        cols: u16,
285        rows: u16,
286        cwd: Option<std::path::PathBuf>,
287        log_path: Option<std::path::PathBuf>,
288        backing_path: Option<std::path::PathBuf>,
289        terminal_wrapper: crate::services::authority::TerminalWrapper,
290    ) -> Result<TerminalId, String> {
291        let id = TerminalId(self.next_id);
292        self.next_id += 1;
293
294        // Try to spawn a real PTY-backed terminal first.
295        let handle_result: Result<TerminalHandle, String> = (|| {
296            // Create PTY
297            let pty_system = native_pty_system();
298            let pty_pair = pty_system
299                .openpty(PtySize {
300                    rows,
301                    cols,
302                    pixel_width: 0,
303                    pixel_height: 0,
304                })
305                .map_err(|e| {
306                    #[cfg(windows)]
307                    {
308                        format!(
309                            "Failed to open PTY: {}. Note: Terminal requires Windows 10 version 1809 or later with ConPTY support.",
310                            e
311                        )
312                    }
313                    #[cfg(not(windows))]
314                    {
315                        format!("Failed to open PTY: {}", e)
316                    }
317                })?;
318
319            // The active authority's terminal wrapper drives the shell
320            // command unconditionally — local wraps `detect_shell()` with
321            // no args; container/remote authorities re-parent into
322            // `docker exec -w …`, `ssh …`, etc. `manages_cwd` says
323            // whether the wrapper's args already establish cwd (in which
324            // case `CommandBuilder::cwd()` is skipped).
325            let TerminalWrapper {
326                command: shell,
327                args: cmd_args,
328                manages_cwd: skip_cwd,
329            } = terminal_wrapper;
330            tracing::info!("Spawning terminal with shell: {}", shell);
331
332            let mut cmd = CommandBuilder::new(&shell);
333            for arg in &cmd_args {
334                cmd.arg(arg);
335            }
336            if !skip_cwd {
337                if let Some(ref dir) = cwd {
338                    // Hand the shell a non-verbatim path. Fresh canonicalizes
339                    // working_dir at startup, which on Windows yields
340                    // `\\?\C:\…`. PowerShell can't infer a drive from a
341                    // verbatim path and falls back to the fully-qualified
342                    // provider form, producing prompts like
343                    // `PS Microsoft.PowerShell.Core\FileSystem::\\?\C:\…>`.
344                    cmd.cwd(strip_verbatim_prefix(dir).as_ref());
345                }
346            }
347
348            // Set TERM so programs like less know the terminal capabilities.
349            // The built-in emulator is alacritty-based so xterm-256color is appropriate.
350            cmd.env("TERM", "xterm-256color");
351
352            // Advertise this editor's local control socket to local child
353            // shells via FRESH_SESSION, so a `fresh` launched from inside
354            // this embedded terminal forwards file/dir opens back to us
355            // instead of starting a second editor. Only for the local host
356            // shell: `manages_cwd` (== `skip_cwd`) marks docker/ssh-style
357            // wrappers, whose inner `fresh` runs on another host where this
358            // unix socket isn't reachable. (A nested `fresh` that can't reach
359            // the socket falls back to running inline, so this gate is an
360            // optimization, not a correctness requirement.)
361            if !skip_cwd {
362                if let Some(session_id) = crate::server::local_control::local_session_id() {
363                    cmd.env("FRESH_SESSION", session_id);
364                }
365            }
366
367            // On Windows, set additional environment variables that help with ConPTY
368            #[cfg(windows)]
369            {
370                // Ensure PROMPT is set for cmd.exe
371                if shell.to_lowercase().contains("cmd") {
372                    cmd.env("PROMPT", "$P$G");
373                }
374            }
375
376            // Spawn the shell process
377            let mut child = pty_pair
378                .slave
379                .spawn_command(cmd)
380                .map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
381
382            tracing::debug!("Shell process spawned successfully");
383
384            // Capture the child's pid before it moves into the
385            // wait-thread. The pty puts the shell at the head of
386            // its own session, so `kill(-pid, signal)` signals
387            // every process the shell has spawned.
388            let child_pid = child.process_id();
389
390            // Pull a separate killer handle so the writer thread
391            // can request termination on `Shutdown` without owning
392            // `child` itself. `child` moves into the dedicated
393            // wait-thread below, where its exit status is captured
394            // and propagated through `AsyncMessage::TerminalExited`.
395            let mut child_killer = child.clone_killer();
396
397            // Create terminal state
398            let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
399
400            // Initialize backing_file_history_end if backing file already exists (session restore)
401            // This ensures enter_terminal_mode doesn't truncate existing history to 0
402            if let Some(ref p) = backing_path {
403                if let Ok(metadata) = std::fs::metadata(p) {
404                    if metadata.len() > 0 {
405                        if let Ok(mut s) = state.lock() {
406                            s.set_backing_file_history_end(metadata.len());
407                        }
408                    }
409                }
410            }
411
412            // Create communication channel
413            let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
414
415            // Alive flag
416            let alive = Arc::new(AtomicBool::new(true));
417            let alive_clone = alive.clone();
418
419            // Get master for I/O
420            let mut master = pty_pair
421                .master
422                .take_writer()
423                .map_err(|e| format!("Failed to get PTY writer: {}", e))?;
424
425            let mut reader = pty_pair
426                .master
427                .try_clone_reader()
428                .map_err(|e| format!("Failed to get PTY reader: {}", e))?;
429
430            // Clone state for reader thread
431            let state_clone = state.clone();
432            let async_bridge = self.async_bridge.clone();
433
434            // Optional raw log writer for full-session capture (for live terminal resume)
435            let mut log_writer = log_path
436                .as_ref()
437                .and_then(|p| {
438                    std::fs::OpenOptions::new()
439                        .create(true)
440                        .append(true)
441                        .open(p)
442                        .ok()
443                })
444                .map(std::io::BufWriter::new);
445
446            // Backing file writer for incremental scrollback streaming
447            // During session restore, the backing file may already contain scrollback content.
448            // We open for append to continue streaming new scrollback after the existing content.
449            // For new terminals, append mode also works (creates file if needed).
450            let mut backing_writer = backing_path
451                .as_ref()
452                .and_then(|p| {
453                    // Check if backing file exists and has content (session restore case)
454                    let existing_has_content =
455                        p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
456
457                    if existing_has_content {
458                        // Session restore: open for append to continue streaming new scrollback
459                        // The existing content is preserved and loaded into buffer separately.
460                        // Note: enter_terminal_mode will truncate when user re-enters terminal.
461                        std::fs::OpenOptions::new()
462                            .create(true)
463                            .append(true)
464                            .open(p)
465                            .ok()
466                    } else {
467                        // New terminal: start fresh with truncate
468                        std::fs::OpenOptions::new()
469                            .create(true)
470                            .write(true)
471                            .truncate(true)
472                            .open(p)
473                            .ok()
474                    }
475                })
476                .map(std::io::BufWriter::new);
477
478            // Spawn reader thread
479            let terminal_id = id;
480            // Tag output/exit with the owning window so the main loop
481            // never has to guess which session a `Terminal-N` belongs to
482            // (ids collide across windows). `Copy`, so both threads below
483            // capture it independently.
484            let wt_id = fresh_core::WindowTerminalId::new(self.window_id, terminal_id);
485            let pty_response_tx = command_tx.clone();
486            thread::spawn(move || {
487                tracing::debug!("Terminal {:?} reader thread started", terminal_id);
488                let mut buf = [0u8; 4096];
489                let mut total_bytes = 0usize;
490                loop {
491                    match reader.read(&mut buf) {
492                        Ok(0) => {
493                            // EOF - process exited
494                            tracing::info!(
495                                "Terminal {:?} EOF after {} total bytes",
496                                terminal_id,
497                                total_bytes
498                            );
499                            break;
500                        }
501                        Ok(n) => {
502                            total_bytes += n;
503                            tracing::debug!(
504                                "Terminal {:?} received {} bytes (total: {})",
505                                terminal_id,
506                                n,
507                                total_bytes
508                            );
509                            // Process output through terminal emulator and stream scrollback
510                            if let Ok(mut state) = state_clone.lock() {
511                                state.process_output(&buf[..n]);
512
513                                // Send any PTY write responses (e.g., DSR cursor position)
514                                // This is critical for Windows ConPTY where PowerShell waits
515                                // for cursor position response before showing the prompt
516                                for response in state.drain_pty_write_queue() {
517                                    tracing::debug!(
518                                        "Terminal {:?} sending PTY response: {:?}",
519                                        terminal_id,
520                                        response
521                                    );
522                                    // Receiver may be dropped if writer thread exited.
523                                    #[allow(clippy::let_underscore_must_use)]
524                                    let _ = pty_response_tx
525                                        .send(TerminalCommand::Write(response.into_bytes()));
526                                }
527
528                                // Incrementally stream new scrollback lines to backing file
529                                if let Some(ref mut writer) = backing_writer {
530                                    match state.flush_new_scrollback(writer) {
531                                        Ok(lines_written) => {
532                                            if lines_written > 0 {
533                                                // Update the history end offset
534                                                if let Ok(pos) = writer.get_ref().metadata() {
535                                                    state.set_backing_file_history_end(pos.len());
536                                                }
537                                                // Best-effort flush; backing file errors handled below.
538                                                #[allow(clippy::let_underscore_must_use)]
539                                                let _ = writer.flush();
540                                            }
541                                        }
542                                        Err(e) => {
543                                            tracing::warn!(
544                                                "Terminal backing file write error: {}",
545                                                e
546                                            );
547                                            backing_writer = None;
548                                        }
549                                    }
550                                }
551                            }
552
553                            // Append raw bytes to log if available (for session restore replay)
554                            if let Some(w) = log_writer.as_mut() {
555                                if let Err(e) = w.write_all(&buf[..n]) {
556                                    tracing::warn!("Terminal log write error: {}", e);
557                                    log_writer = None; // stop logging on error
558                                } else if let Err(e) = w.flush() {
559                                    tracing::warn!("Terminal log flush error: {}", e);
560                                    log_writer = None;
561                                }
562                            }
563
564                            // Notify main loop to redraw (receiver may be dropped during shutdown).
565                            if let Some(ref bridge) = async_bridge {
566                                #[allow(clippy::let_underscore_must_use)]
567                                let _ = bridge.sender().send(
568                                    crate::services::async_bridge::AsyncMessage::TerminalOutput {
569                                        terminal: wt_id,
570                                    },
571                                );
572                            }
573                        }
574                        Err(e) => {
575                            tracing::error!("Terminal read error: {}", e);
576                            break;
577                        }
578                    }
579                }
580                alive_clone.store(false, std::sync::atomic::Ordering::Relaxed);
581                // Best-effort flush of log/backing files during teardown.
582                if let Some(mut w) = log_writer {
583                    #[allow(clippy::let_underscore_must_use)]
584                    let _ = w.flush();
585                }
586                if let Some(mut w) = backing_writer {
587                    #[allow(clippy::let_underscore_must_use)]
588                    let _ = w.flush();
589                }
590                // The wait-thread (spawned below) owns `child` and
591                // is the single source of `TerminalExited`, so the
592                // reader intentionally does not fire it here. Firing
593                // from both threads would race and sometimes produce
594                // `exit_code: None` despite a clean exit.
595            });
596
597            // Wait-thread: blocks on `child.wait()`, captures the
598            // exit status, and fires `TerminalExited` exactly once
599            // with the real code. The reader thread above has
600            // already dropped `alive_clone` to false on PTY EOF, so
601            // by the time we get here the child is either gone or
602            // about to be (the writer thread's killer.kill()
603            // accelerates the latter case for user-initiated
604            // shutdown).
605            let async_bridge_for_wait = self.async_bridge.clone();
606            thread::spawn(move || {
607                let exit_code = match child.wait() {
608                    Ok(status) => Some(status.exit_code() as i32),
609                    Err(e) => {
610                        tracing::warn!("child.wait() failed for {:?}: {}", terminal_id, e);
611                        None
612                    }
613                };
614                if let Some(ref bridge) = async_bridge_for_wait {
615                    #[allow(clippy::let_underscore_must_use)]
616                    let _ = bridge.sender().send(
617                        crate::services::async_bridge::AsyncMessage::TerminalExited {
618                            terminal: wt_id,
619                            exit_code,
620                        },
621                    );
622                }
623            });
624
625            // Capture the PTY master fd before the master moves into the
626            // writer thread below. Used later by `foreground_process_name`
627            // (tmux-style tab auto-naming). The fd stays valid for the
628            // terminal's lifetime because the writer thread owns the master.
629            let master_fd: Option<i32> = {
630                #[cfg(unix)]
631                {
632                    pty_pair.master.as_raw_fd()
633                }
634                #[cfg(not(unix))]
635                {
636                    None
637                }
638            };
639
640            // Spawn writer thread
641            let pty_size_ref = pty_pair.master;
642            thread::spawn(move || {
643                loop {
644                    match command_rx.recv() {
645                        Ok(TerminalCommand::Write(data)) => {
646                            if let Err(e) = master.write_all(&data) {
647                                tracing::error!("Terminal write error: {}", e);
648                                break;
649                            }
650                            // Best-effort flush — PTY write errors are handled above.
651                            #[allow(clippy::let_underscore_must_use)]
652                            let _ = master.flush();
653                        }
654                        Ok(TerminalCommand::Resize { cols, rows }) => {
655                            if let Err(e) = pty_size_ref.resize(PtySize {
656                                rows,
657                                cols,
658                                pixel_width: 0,
659                                pixel_height: 0,
660                            }) {
661                                tracing::warn!("Failed to resize PTY: {}", e);
662                            }
663                        }
664                        Ok(TerminalCommand::Shutdown) | Err(_) => {
665                            break;
666                        }
667                    }
668                }
669                // User-initiated shutdown: ask the OS to terminate
670                // the child via the cloned killer. The wait-thread
671                // (above) owns `child` and will reap the exit status
672                // for us; we don't call `wait` here because that
673                // would race the wait-thread for the exit status.
674                #[allow(clippy::let_underscore_must_use)]
675                let _ = child_killer.kill();
676            });
677
678            // Create handle
679            Ok(TerminalHandle {
680                state,
681                command_tx,
682                alive,
683                cols,
684                rows,
685                cwd: cwd.clone(),
686                shell,
687                pid: child_pid,
688                master_fd,
689            })
690        })();
691
692        let handle = handle_result?;
693
694        self.terminals.insert(id, handle);
695        tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
696
697        Ok(id)
698    }
699
700    /// Get a terminal handle by ID
701    pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
702        self.terminals.get(&id)
703    }
704
705    /// Get a mutable terminal handle by ID
706    pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
707        self.terminals.get_mut(&id)
708    }
709
710    /// Close a terminal
711    pub fn close(&mut self, id: TerminalId) -> bool {
712        if let Some(handle) = self.terminals.remove(&id) {
713            handle.shutdown();
714            true
715        } else {
716            false
717        }
718    }
719
720    /// Get all terminal IDs
721    pub fn terminal_ids(&self) -> Vec<TerminalId> {
722        self.terminals.keys().copied().collect()
723    }
724
725    /// Get count of open terminals
726    pub fn count(&self) -> usize {
727        self.terminals.len()
728    }
729
730    /// Shutdown all terminals
731    pub fn shutdown_all(&mut self) {
732        for (_, handle) in self.terminals.drain() {
733            handle.shutdown();
734        }
735    }
736
737    /// Clean up dead terminals
738    pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
739        let dead: Vec<TerminalId> = self
740            .terminals
741            .iter()
742            .filter(|(_, h)| !h.is_alive())
743            .map(|(id, _)| *id)
744            .collect();
745
746        for id in &dead {
747            self.terminals.remove(id);
748        }
749
750        dead
751    }
752}
753
754impl Drop for TerminalManager {
755    fn drop(&mut self) {
756        self.shutdown_all();
757    }
758}
759
760/// Convert a Windows verbatim path (`\\?\C:\…` or `\\?\UNC\server\share\…`)
761/// into its non-verbatim equivalent (`C:\…` or `\\server\share\…`).
762///
763/// Returns the input unchanged on non-Windows platforms or for paths that
764/// have no verbatim prefix.
765pub(crate) fn strip_verbatim_prefix(path: &std::path::Path) -> Cow<'_, std::path::Path> {
766    #[cfg(windows)]
767    {
768        use std::path::{Component, Prefix};
769
770        let mut components = path.components();
771        let prefix = match components.next() {
772            Some(Component::Prefix(p)) => p,
773            _ => return Cow::Borrowed(path),
774        };
775
776        let mut rebuilt = std::path::PathBuf::new();
777        match prefix.kind() {
778            Prefix::VerbatimDisk(drive) => {
779                rebuilt.push(format!("{}:\\", drive as char));
780            }
781            Prefix::VerbatimUNC(server, share) => {
782                rebuilt.push(format!(
783                    r"\\{}\{}\",
784                    server.to_string_lossy(),
785                    share.to_string_lossy()
786                ));
787            }
788            _ => return Cow::Borrowed(path),
789        }
790        // Skip the original RootDir (which the rebuilt prefix already includes)
791        // and append the rest of the components.
792        for component in components {
793            if matches!(component, Component::RootDir) {
794                continue;
795            }
796            rebuilt.push(component.as_os_str());
797        }
798        Cow::Owned(rebuilt)
799    }
800    #[cfg(not(windows))]
801    {
802        Cow::Borrowed(path)
803    }
804}
805
806/// Detect the user's shell
807pub fn detect_shell() -> String {
808    // Try $SHELL environment variable first
809    if let Ok(shell) = std::env::var("SHELL") {
810        if !shell.is_empty() {
811            return shell;
812        }
813    }
814
815    // Fall back to platform defaults
816    #[cfg(unix)]
817    {
818        "/bin/sh".to_string()
819    }
820    #[cfg(windows)]
821    {
822        super::windows_shell::select_windows_shell()
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    #[test]
831    fn test_terminal_id_display() {
832        let id = TerminalId(42);
833        assert_eq!(format!("{}", id), "Terminal-42");
834    }
835
836    /// Terminal ids are per-window: each manager numbers from 0, so two
837    /// windows both hand out `Terminal-0`. The owning window is what
838    /// disambiguates them — output messages are tagged with the
839    /// `(window, terminal)` pair so a `Terminal-0` from one session can't
840    /// be attributed to another session's `Terminal-0`. (Regression
841    /// guard for the dock "pending output on the wrong session" bug.)
842    #[test]
843    fn terminal_ids_collide_across_windows_but_window_disambiguates() {
844        use fresh_core::{WindowId, WindowTerminalId};
845
846        let win_a = TerminalManager::new(WindowId(1));
847        let win_b = TerminalManager::new(WindowId(2));
848
849        // Both managers would assign the same local id to their first
850        // terminal — the namespaces are independent.
851        assert_eq!(win_a.next_terminal_id(), win_b.next_terminal_id());
852        assert_eq!(win_a.next_terminal_id(), TerminalId(0));
853
854        // Each manager knows its owner, so the global identity differs.
855        assert_eq!(win_a.window_id(), WindowId(1));
856        assert_eq!(win_b.window_id(), WindowId(2));
857        let a0 = WindowTerminalId::new(win_a.window_id(), win_a.next_terminal_id());
858        let b0 = WindowTerminalId::new(win_b.window_id(), win_b.next_terminal_id());
859        assert_ne!(
860            a0, b0,
861            "same local terminal id in different windows must be distinct globally"
862        );
863    }
864
865    #[test]
866    fn test_detect_shell() {
867        let shell = detect_shell();
868        assert!(!shell.is_empty());
869    }
870
871    #[cfg(not(windows))]
872    #[test]
873    fn strip_verbatim_prefix_is_noop_on_unix() {
874        use std::path::Path;
875        let p = Path::new("/home/user/project");
876        assert_eq!(strip_verbatim_prefix(p).as_ref(), p);
877    }
878
879    #[cfg(windows)]
880    #[test]
881    fn strip_verbatim_prefix_removes_verbatim_disk() {
882        use std::path::{Path, PathBuf};
883        let verbatim = PathBuf::from(r"\\?\C:\Users\HP\OneDrive\Desktop\PY'PGMS");
884        let stripped = strip_verbatim_prefix(&verbatim);
885        assert_eq!(
886            stripped.as_ref(),
887            Path::new(r"C:\Users\HP\OneDrive\Desktop\PY'PGMS"),
888            "verbatim disk prefix should be replaced with plain drive form"
889        );
890    }
891
892    #[cfg(windows)]
893    #[test]
894    fn strip_verbatim_prefix_removes_verbatim_unc() {
895        use std::path::{Path, PathBuf};
896        let verbatim = PathBuf::from(r"\\?\UNC\server\share\dir\file");
897        let stripped = strip_verbatim_prefix(&verbatim);
898        assert_eq!(
899            stripped.as_ref(),
900            Path::new(r"\\server\share\dir\file"),
901            "verbatim UNC prefix should be replaced with plain UNC form"
902        );
903    }
904
905    #[cfg(windows)]
906    #[test]
907    fn strip_verbatim_prefix_passes_plain_paths_through() {
908        use std::path::{Path, PathBuf};
909        let plain = PathBuf::from(r"C:\Users\HP\project");
910        let result = strip_verbatim_prefix(&plain);
911        assert_eq!(result.as_ref(), Path::new(r"C:\Users\HP\project"));
912    }
913}