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}