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