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    #[allow(clippy::too_many_arguments)]
283    pub fn spawn(
284        &mut self,
285        cols: u16,
286        rows: u16,
287        cwd: Option<std::path::PathBuf>,
288        log_path: Option<std::path::PathBuf>,
289        backing_path: Option<std::path::PathBuf>,
290        terminal_wrapper: crate::services::authority::TerminalWrapper,
291        env_delta: crate::services::env_provider::EnvDelta,
292    ) -> Result<TerminalId, String> {
293        let id = TerminalId(self.next_id);
294        self.next_id += 1;
295
296        let handle = self.build_terminal(
297            id,
298            cols,
299            rows,
300            cwd,
301            log_path,
302            backing_path,
303            terminal_wrapper,
304            env_delta,
305        )?;
306
307        self.terminals.insert(id, handle);
308        tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
309
310        Ok(id)
311    }
312
313    /// Build a PTY-backed terminal: open the pty, launch the shell, and wire up
314    /// the three background threads (reader, wait, writer) that drive it. Kept
315    /// separate from [`TerminalManager::spawn`] so the happy path reads
316    /// top-to-bottom with `?` instead of being buried in an error-handling
317    /// closure.
318    #[allow(clippy::too_many_arguments)]
319    fn build_terminal(
320        &self,
321        id: TerminalId,
322        cols: u16,
323        rows: u16,
324        cwd: Option<std::path::PathBuf>,
325        log_path: Option<std::path::PathBuf>,
326        backing_path: Option<std::path::PathBuf>,
327        terminal_wrapper: TerminalWrapper,
328        env_delta: crate::services::env_provider::EnvDelta,
329    ) -> Result<TerminalHandle, String> {
330        let pty_pair = open_pty(cols, rows)?;
331
332        // The active authority's terminal wrapper drives the shell command
333        // unconditionally — local wraps `detect_shell()` with no args;
334        // container/remote authorities re-parent into `docker exec -w …`,
335        // `ssh …`, etc.
336        let (cmd, shell) = build_shell_command(terminal_wrapper, cwd.as_deref(), &env_delta);
337
338        // Spawn the shell process.
339        let child = pty_pair
340            .slave
341            .spawn_command(cmd)
342            .map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
343        tracing::debug!("Shell process spawned successfully");
344
345        // Capture the pid (for process-group signalling) and a killer handle
346        // before `child` moves into the wait-thread below.
347        let child_pid = child.process_id();
348        let child_killer = child.clone_killer();
349
350        let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
351
352        // If the backing file already exists (session restore), seed the history
353        // end so entering terminal mode doesn't truncate it to 0.
354        if let Some(p) = backing_path.as_ref() {
355            if let Ok(metadata) = std::fs::metadata(p) {
356                if metadata.len() > 0 {
357                    if let Ok(mut s) = state.lock() {
358                        s.set_backing_file_history_end(metadata.len());
359                    }
360                }
361            }
362        }
363
364        let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
365        let alive = Arc::new(AtomicBool::new(true));
366
367        let master_writer = pty_pair
368            .master
369            .take_writer()
370            .map_err(|e| format!("Failed to get PTY writer: {}", e))?;
371        let reader = pty_pair
372            .master
373            .try_clone_reader()
374            .map_err(|e| format!("Failed to get PTY reader: {}", e))?;
375
376        let log_writer = open_log_writer(log_path.as_deref());
377        let backing_writer = open_backing_writer(backing_path.as_deref());
378
379        // Tag output/exit with the owning window so the main loop never has to
380        // guess which session a `Terminal-N` belongs to (ids collide across
381        // windows). See `fresh_core::WindowTerminalId`.
382        let wt_id = fresh_core::WindowTerminalId::new(self.window_id, id);
383
384        // Reader thread: drains PTY output, feeds the emulator, streams
385        // scrollback / raw log to disk, and pings the main loop to redraw.
386        let reader_loop = ReaderLoop {
387            reader,
388            state: state.clone(),
389            response_tx: command_tx.clone(),
390            backing_writer,
391            log_writer,
392            async_bridge: self.async_bridge.clone(),
393            wt_id,
394            terminal_id: id,
395            alive: alive.clone(),
396        };
397        thread::spawn(move || reader_loop.run());
398
399        // Wait thread: blocks on `child.wait()` and fires `TerminalExited`
400        // exactly once with the real exit code.
401        spawn_wait_thread(child, self.async_bridge.clone(), wt_id, id);
402
403        // Capture the PTY master fd before the master moves into the writer
404        // thread. Used later by `foreground_process_name` (tab auto-naming).
405        let master_fd: Option<i32> = {
406            #[cfg(unix)]
407            {
408                pty_pair.master.as_raw_fd()
409            }
410            #[cfg(not(unix))]
411            {
412                None
413            }
414        };
415
416        // Writer thread: owns the master, applies queued writes/resizes, and
417        // kills the child on shutdown.
418        spawn_writer_thread(command_rx, master_writer, pty_pair.master, child_killer);
419
420        Ok(TerminalHandle {
421            state,
422            command_tx,
423            alive,
424            cols,
425            rows,
426            cwd,
427            shell,
428            pid: child_pid,
429            master_fd,
430        })
431    }
432
433    /// Get a terminal handle by ID
434    pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
435        self.terminals.get(&id)
436    }
437
438    /// Get a mutable terminal handle by ID
439    pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
440        self.terminals.get_mut(&id)
441    }
442
443    /// Close a terminal
444    pub fn close(&mut self, id: TerminalId) -> bool {
445        if let Some(handle) = self.terminals.remove(&id) {
446            handle.shutdown();
447            true
448        } else {
449            false
450        }
451    }
452
453    /// Get all terminal IDs
454    pub fn terminal_ids(&self) -> Vec<TerminalId> {
455        self.terminals.keys().copied().collect()
456    }
457
458    /// Get count of open terminals
459    pub fn count(&self) -> usize {
460        self.terminals.len()
461    }
462
463    /// Shutdown all terminals
464    pub fn shutdown_all(&mut self) {
465        for (_, handle) in self.terminals.drain() {
466            handle.shutdown();
467        }
468    }
469
470    /// Clean up dead terminals
471    pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
472        let dead: Vec<TerminalId> = self
473            .terminals
474            .iter()
475            .filter(|(_, h)| !h.is_alive())
476            .map(|(id, _)| *id)
477            .collect();
478
479        for id in &dead {
480            self.terminals.remove(id);
481        }
482
483        dead
484    }
485}
486
487/// Open a native PTY of the given size, mapping the platform error into a
488/// human-readable string (with a ConPTY hint on Windows).
489fn open_pty(cols: u16, rows: u16) -> Result<portable_pty::PtyPair, String> {
490    native_pty_system()
491        .openpty(PtySize {
492            rows,
493            cols,
494            pixel_width: 0,
495            pixel_height: 0,
496        })
497        .map_err(|e| {
498            #[cfg(windows)]
499            {
500                format!(
501                    "Failed to open PTY: {}. Note: Terminal requires Windows 10 version 1809 or later with ConPTY support.",
502                    e
503                )
504            }
505            #[cfg(not(windows))]
506            {
507                format!("Failed to open PTY: {}", e)
508            }
509        })
510}
511
512/// Build the shell `CommandBuilder` for a terminal from the active authority's
513/// wrapper, returning the command plus the shell executable name (for the
514/// handle / diagnostics). `manages_cwd` wrappers (docker/ssh) already establish
515/// cwd in their own args, so both cwd and the local `FRESH_SESSION`
516/// advertisement are skipped for them — their inner shell runs on another host
517/// this `CommandBuilder`'s env can't reach.
518fn build_shell_command(
519    terminal_wrapper: TerminalWrapper,
520    cwd: Option<&std::path::Path>,
521    env_delta: &crate::services::env_provider::EnvDelta,
522) -> (CommandBuilder, String) {
523    let TerminalWrapper {
524        command: shell,
525        args: cmd_args,
526        manages_cwd: skip_cwd,
527    } = terminal_wrapper;
528    tracing::info!("Spawning terminal with shell: {}", shell);
529
530    let mut cmd = CommandBuilder::new(&shell);
531    for arg in &cmd_args {
532        cmd.arg(arg);
533    }
534    if !skip_cwd {
535        if let Some(dir) = cwd {
536            // Hand the shell a non-verbatim path so PowerShell can infer the
537            // drive; a verbatim `\\?\C:\…` path yields provider-prefixed prompts.
538            cmd.cwd(strip_verbatim_prefix(dir).as_ref());
539        }
540    }
541
542    // Apply the activated-environment delta (venv/direnv/mise) before the
543    // control vars below, so TERM/FRESH_SESSION win over any same-named key
544    // (issue #2355).
545    for (k, v) in &env_delta.set {
546        cmd.env(k, v);
547    }
548    for k in &env_delta.unset {
549        cmd.env_remove(k);
550    }
551
552    // Advertise terminal capabilities; the built-in emulator is alacritty-based.
553    cmd.env("TERM", "xterm-256color");
554
555    // Advertise this editor's local control socket so a nested `fresh` forwards
556    // file/dir opens back to us instead of starting a second editor.
557    if !skip_cwd {
558        if let Some(session_id) = crate::server::local_control::local_session_id() {
559            cmd.env("FRESH_SESSION", session_id);
560        }
561    }
562
563    // On Windows, ensure PROMPT is set for cmd.exe.
564    #[cfg(windows)]
565    {
566        if shell.to_lowercase().contains("cmd") {
567            cmd.env("PROMPT", "$P$G");
568        }
569    }
570
571    (cmd, shell)
572}
573
574/// Open the optional raw-PTY log file (append mode) for full-session capture.
575fn open_log_writer(
576    log_path: Option<&std::path::Path>,
577) -> Option<std::io::BufWriter<std::fs::File>> {
578    log_path
579        .and_then(|p| {
580            std::fs::OpenOptions::new()
581                .create(true)
582                .append(true)
583                .open(p)
584                .ok()
585        })
586        .map(std::io::BufWriter::new)
587}
588
589/// Open the optional scrollback backing file. On session restore (the file
590/// already has content) we append to continue streaming; otherwise we truncate
591/// to start fresh.
592fn open_backing_writer(
593    backing_path: Option<&std::path::Path>,
594) -> Option<std::io::BufWriter<std::fs::File>> {
595    backing_path
596        .and_then(|p| {
597            let existing_has_content =
598                p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
599            if existing_has_content {
600                std::fs::OpenOptions::new()
601                    .create(true)
602                    .append(true)
603                    .open(p)
604                    .ok()
605            } else {
606                std::fs::OpenOptions::new()
607                    .create(true)
608                    .write(true)
609                    .truncate(true)
610                    .open(p)
611                    .ok()
612            }
613        })
614        .map(std::io::BufWriter::new)
615}
616
617/// Wait-thread body: block on the child's exit and fire `TerminalExited` once.
618/// Owns `child` so it is the single source of the exit status (the reader
619/// thread deliberately doesn't fire it, to avoid a racing `exit_code: None`).
620fn spawn_wait_thread(
621    mut child: Box<dyn portable_pty::Child + Send + Sync>,
622    async_bridge: Option<AsyncBridge>,
623    wt_id: fresh_core::WindowTerminalId,
624    terminal_id: TerminalId,
625) {
626    thread::spawn(move || {
627        let exit_code = match child.wait() {
628            Ok(status) => Some(status.exit_code() as i32),
629            Err(e) => {
630                tracing::warn!("child.wait() failed for {:?}: {}", terminal_id, e);
631                None
632            }
633        };
634        if let Some(bridge) = &async_bridge {
635            #[allow(clippy::let_underscore_must_use)]
636            let _ = bridge.sender().send(
637                crate::services::async_bridge::AsyncMessage::TerminalExited {
638                    terminal: wt_id,
639                    exit_code,
640                },
641            );
642        }
643    });
644}
645
646/// Writer-thread body: own the master, apply queued writes/resizes, and kill
647/// the child on shutdown. The wait-thread reaps the exit status, so this thread
648/// intentionally doesn't call `wait` (which would race it).
649fn spawn_writer_thread(
650    command_rx: mpsc::Receiver<TerminalCommand>,
651    mut master: Box<dyn Write + Send>,
652    pty_master: Box<dyn portable_pty::MasterPty + Send>,
653    mut child_killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
654) {
655    thread::spawn(move || {
656        loop {
657            match command_rx.recv() {
658                Ok(TerminalCommand::Write(data)) => {
659                    if let Err(e) = master.write_all(&data) {
660                        tracing::error!("Terminal write error: {}", e);
661                        break;
662                    }
663                    // Best-effort flush — PTY write errors are handled above.
664                    #[allow(clippy::let_underscore_must_use)]
665                    let _ = master.flush();
666                }
667                Ok(TerminalCommand::Resize { cols, rows }) => {
668                    if let Err(e) = pty_master.resize(PtySize {
669                        rows,
670                        cols,
671                        pixel_width: 0,
672                        pixel_height: 0,
673                    }) {
674                        tracing::warn!("Failed to resize PTY: {}", e);
675                    }
676                }
677                Ok(TerminalCommand::Shutdown) | Err(_) => {
678                    break;
679                }
680            }
681        }
682        // User-initiated shutdown: ask the OS to terminate the child via the
683        // cloned killer. The wait-thread owns `child` and reaps the status.
684        #[allow(clippy::let_underscore_must_use)]
685        let _ = child_killer.kill();
686    });
687}
688
689/// Owns everything the PTY reader thread needs. Bundled into one struct so the
690/// thread body is a readable `run(self)` of small steps instead of a closure
691/// capturing a dozen locals at deep nesting.
692struct ReaderLoop {
693    reader: Box<dyn Read + Send>,
694    state: Arc<Mutex<TerminalState>>,
695    /// Sends PTY write-responses (e.g. DSR cursor reports) back to the writer.
696    response_tx: mpsc::Sender<TerminalCommand>,
697    /// Incremental scrollback stream (rendered lines), if a backing file is set.
698    backing_writer: Option<std::io::BufWriter<std::fs::File>>,
699    /// Raw byte log for session-restore replay, if a log file is set.
700    log_writer: Option<std::io::BufWriter<std::fs::File>>,
701    async_bridge: Option<AsyncBridge>,
702    wt_id: fresh_core::WindowTerminalId,
703    terminal_id: TerminalId,
704    alive: Arc<AtomicBool>,
705}
706
707impl ReaderLoop {
708    /// Drain the PTY until EOF or error, then mark the terminal dead and flush.
709    fn run(mut self) {
710        tracing::debug!("Terminal {:?} reader thread started", self.terminal_id);
711        let mut buf = [0u8; 4096];
712        let mut total_bytes = 0usize;
713        loop {
714            match self.reader.read(&mut buf) {
715                Ok(0) => {
716                    // EOF - process exited.
717                    tracing::info!(
718                        "Terminal {:?} EOF after {} total bytes",
719                        self.terminal_id,
720                        total_bytes
721                    );
722                    break;
723                }
724                Ok(n) => {
725                    total_bytes += n;
726                    // Hot path: a busy terminal reads tens of thousands of
727                    // chunks/sec, so this stays at `trace` (off by default) to
728                    // avoid flooding the log — and, if that log is tailed into a
729                    // terminal this manager owns, a positive-feedback loop.
730                    tracing::trace!(
731                        "Terminal {:?} received {} bytes (total: {})",
732                        self.terminal_id,
733                        n,
734                        total_bytes
735                    );
736                    self.process_output(&buf[..n]);
737                    self.append_raw_log(&buf[..n]);
738                    self.notify_redraw();
739                }
740                Err(e) => {
741                    tracing::error!("Terminal read error: {}", e);
742                    break;
743                }
744            }
745        }
746        self.alive
747            .store(false, std::sync::atomic::Ordering::Relaxed);
748        // Best-effort flush of log/backing files during teardown. The
749        // wait-thread is the single source of `TerminalExited`, so the reader
750        // intentionally does not fire it here (firing from both races and can
751        // yield `exit_code: None` despite a clean exit).
752        if let Some(mut w) = self.log_writer.take() {
753            #[allow(clippy::let_underscore_must_use)]
754            let _ = w.flush();
755        }
756        if let Some(mut w) = self.backing_writer.take() {
757            #[allow(clippy::let_underscore_must_use)]
758            let _ = w.flush();
759        }
760    }
761
762    /// Feed `bytes` to the emulator, forward any PTY write-responses, and stream
763    /// new scrollback to the backing file. Holds the state lock for the whole
764    /// step so scrollback offsets stay consistent with the emulator grid.
765    fn process_output(&mut self, bytes: &[u8]) {
766        let Ok(mut state) = self.state.lock() else {
767            return;
768        };
769        state.process_output(bytes);
770
771        // Send any PTY write responses (e.g. DSR cursor position). Critical on
772        // Windows ConPTY, where PowerShell waits for this before prompting.
773        for response in state.drain_pty_write_queue() {
774            tracing::debug!(
775                "Terminal {:?} sending PTY response: {:?}",
776                self.terminal_id,
777                response
778            );
779            // Receiver may be dropped if the writer thread exited.
780            #[allow(clippy::let_underscore_must_use)]
781            let _ = self
782                .response_tx
783                .send(TerminalCommand::Write(response.into_bytes()));
784        }
785
786        // Incrementally stream new scrollback lines to the backing file.
787        if let Some(writer) = self.backing_writer.as_mut() {
788            match state.flush_new_scrollback(writer) {
789                Ok(lines_written) => {
790                    if lines_written > 0 {
791                        if let Ok(pos) = writer.get_ref().metadata() {
792                            state.set_backing_file_history_end(pos.len());
793                        }
794                        #[allow(clippy::let_underscore_must_use)]
795                        let _ = writer.flush();
796                    }
797                }
798                Err(e) => {
799                    tracing::warn!("Terminal backing file write error: {}", e);
800                    self.backing_writer = None;
801                }
802            }
803        }
804    }
805
806    /// Append raw bytes to the session log (for restore replay), if enabled.
807    fn append_raw_log(&mut self, bytes: &[u8]) {
808        if let Some(w) = self.log_writer.as_mut() {
809            if let Err(e) = w.write_all(bytes) {
810                tracing::warn!("Terminal log write error: {}", e);
811                self.log_writer = None;
812            } else if let Err(e) = w.flush() {
813                tracing::warn!("Terminal log flush error: {}", e);
814                self.log_writer = None;
815            }
816        }
817    }
818
819    /// Notify the main loop that this terminal produced output (redraw).
820    fn notify_redraw(&self) {
821        if let Some(bridge) = &self.async_bridge {
822            #[allow(clippy::let_underscore_must_use)]
823            let _ = bridge.sender().send(
824                crate::services::async_bridge::AsyncMessage::TerminalOutput {
825                    terminal: self.wt_id,
826                },
827            );
828        }
829    }
830}
831
832impl Drop for TerminalManager {
833    fn drop(&mut self) {
834        self.shutdown_all();
835    }
836}
837
838/// Convert a Windows verbatim path (`\\?\C:\…` or `\\?\UNC\server\share\…`)
839/// into its non-verbatim equivalent (`C:\…` or `\\server\share\…`).
840///
841/// Returns the input unchanged on non-Windows platforms or for paths that
842/// have no verbatim prefix.
843pub(crate) fn strip_verbatim_prefix(path: &std::path::Path) -> Cow<'_, std::path::Path> {
844    #[cfg(windows)]
845    {
846        use std::path::{Component, Prefix};
847
848        let mut components = path.components();
849        let prefix = match components.next() {
850            Some(Component::Prefix(p)) => p,
851            _ => return Cow::Borrowed(path),
852        };
853
854        let mut rebuilt = std::path::PathBuf::new();
855        match prefix.kind() {
856            Prefix::VerbatimDisk(drive) => {
857                rebuilt.push(format!("{}:\\", drive as char));
858            }
859            Prefix::VerbatimUNC(server, share) => {
860                rebuilt.push(format!(
861                    r"\\{}\{}\",
862                    server.to_string_lossy(),
863                    share.to_string_lossy()
864                ));
865            }
866            _ => return Cow::Borrowed(path),
867        }
868        // Skip the original RootDir (which the rebuilt prefix already includes)
869        // and append the rest of the components.
870        for component in components {
871            if matches!(component, Component::RootDir) {
872                continue;
873            }
874            rebuilt.push(component.as_os_str());
875        }
876        Cow::Owned(rebuilt)
877    }
878    #[cfg(not(windows))]
879    {
880        Cow::Borrowed(path)
881    }
882}
883
884/// Detect the user's shell
885pub fn detect_shell() -> String {
886    // Try $SHELL environment variable first
887    if let Ok(shell) = std::env::var("SHELL") {
888        if !shell.is_empty() {
889            return shell;
890        }
891    }
892
893    // Fall back to platform defaults
894    #[cfg(unix)]
895    {
896        "/bin/sh".to_string()
897    }
898    #[cfg(windows)]
899    {
900        super::windows_shell::select_windows_shell()
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907
908    #[test]
909    fn test_terminal_id_display() {
910        let id = TerminalId(42);
911        assert_eq!(format!("{}", id), "Terminal-42");
912    }
913
914    /// Terminal ids are per-window: each manager numbers from 0, so two
915    /// windows both hand out `Terminal-0`. The owning window is what
916    /// disambiguates them — output messages are tagged with the
917    /// `(window, terminal)` pair so a `Terminal-0` from one session can't
918    /// be attributed to another session's `Terminal-0`. (Regression
919    /// guard for the dock "pending output on the wrong session" bug.)
920    #[test]
921    fn terminal_ids_collide_across_windows_but_window_disambiguates() {
922        use fresh_core::{WindowId, WindowTerminalId};
923
924        let win_a = TerminalManager::new(WindowId(1));
925        let win_b = TerminalManager::new(WindowId(2));
926
927        // Both managers would assign the same local id to their first
928        // terminal — the namespaces are independent.
929        assert_eq!(win_a.next_terminal_id(), win_b.next_terminal_id());
930        assert_eq!(win_a.next_terminal_id(), TerminalId(0));
931
932        // Each manager knows its owner, so the global identity differs.
933        assert_eq!(win_a.window_id(), WindowId(1));
934        assert_eq!(win_b.window_id(), WindowId(2));
935        let a0 = WindowTerminalId::new(win_a.window_id(), win_a.next_terminal_id());
936        let b0 = WindowTerminalId::new(win_b.window_id(), win_b.next_terminal_id());
937        assert_ne!(
938            a0, b0,
939            "same local terminal id in different windows must be distinct globally"
940        );
941    }
942
943    #[test]
944    fn test_detect_shell() {
945        let shell = detect_shell();
946        assert!(!shell.is_empty());
947    }
948
949    #[cfg(not(windows))]
950    #[test]
951    fn strip_verbatim_prefix_is_noop_on_unix() {
952        use std::path::Path;
953        let p = Path::new("/home/user/project");
954        assert_eq!(strip_verbatim_prefix(p).as_ref(), p);
955    }
956
957    #[cfg(windows)]
958    #[test]
959    fn strip_verbatim_prefix_removes_verbatim_disk() {
960        use std::path::{Path, PathBuf};
961        let verbatim = PathBuf::from(r"\\?\C:\Users\HP\OneDrive\Desktop\PY'PGMS");
962        let stripped = strip_verbatim_prefix(&verbatim);
963        assert_eq!(
964            stripped.as_ref(),
965            Path::new(r"C:\Users\HP\OneDrive\Desktop\PY'PGMS"),
966            "verbatim disk prefix should be replaced with plain drive form"
967        );
968    }
969
970    #[cfg(windows)]
971    #[test]
972    fn strip_verbatim_prefix_removes_verbatim_unc() {
973        use std::path::{Path, PathBuf};
974        let verbatim = PathBuf::from(r"\\?\UNC\server\share\dir\file");
975        let stripped = strip_verbatim_prefix(&verbatim);
976        assert_eq!(
977            stripped.as_ref(),
978            Path::new(r"\\server\share\dir\file"),
979            "verbatim UNC prefix should be replaced with plain UNC form"
980        );
981    }
982
983    #[cfg(windows)]
984    #[test]
985    fn strip_verbatim_prefix_passes_plain_paths_through() {
986        use std::path::{Path, PathBuf};
987        let plain = PathBuf::from(r"C:\Users\HP\project");
988        let result = strip_verbatim_prefix(&plain);
989        assert_eq!(result.as_ref(), Path::new(r"C:\Users\HP\project"));
990    }
991}