diff --git a/crates/tui/src/composer_history.rs b/crates/tui/src/composer_history.rs index 0f972cfdf..4ce0ad3d4 100644 --- a/crates/tui/src/composer_history.rs +++ b/crates/tui/src/composer_history.rs @@ -263,7 +263,7 @@ mod tests { // Give the writer thread time to drain the queue, then verify the // new entries landed. - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = Instant::now() + Duration::from_secs(30); loop { let loaded = load_history_from(&path); if loaded.iter().any(|line| line == "new entry 49") { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f5ab7c700..d99beefaf 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -63,6 +63,7 @@ mod schema_migration; mod seam_manager; mod session_manager; mod settings; +mod shell_dispatcher; mod skill_state; mod skills; mod snapshot; diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index f4bbe9d76..d9e6d71e5 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -97,7 +97,10 @@ fn translation_target_language_for_tag(locale_tag: &str) -> &'static str { fn render_environment_block(workspace: &Path, locale_tag: &str) -> String { let deepseek_version = env!("CARGO_PKG_VERSION"); let platform = std::env::consts::OS; - let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()); + let shell = crate::shell_dispatcher::global_dispatcher() + .kind() + .binary() + .to_string(); let pwd = workspace.display(); format!( diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index 508e3bd67..9514863e4 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -79,20 +79,22 @@ pub struct CommandSpec { impl CommandSpec { /// Create a `CommandSpec` for running a shell command via the platform shell. pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self { + let dispatcher = crate::shell_dispatcher::global_dispatcher(); + #[cfg(windows)] let (program, args) = { - // Force UTF-8 output on Windows by running `chcp 65001` before the - // actual command. Without this, subprocesses output in the system's - // ANSI code page (e.g. GBK for Chinese locales), causing garbled - // text in the shell output panel. See issue #982. - let cmd = format!("chcp 65001 >NUL & {command}"); - ("cmd".to_string(), vec!["/C".to_string(), cmd]) + // Force UTF-8 output. cmd.exe uses chcp; PowerShell sets the + // console output encoding directly. See issue #982. + let kind = dispatcher.kind(); + let cmd = if kind.is_powershell() { + format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {command}") + } else { + format!("chcp 65001 >NUL & {command}") + }; + dispatcher.build_command_parts(&cmd) }; #[cfg(not(windows))] - let (program, args) = ( - "sh".to_string(), - vec!["-c".to_string(), command.to_string()], - ); + let (program, args) = dispatcher.build_command_parts(command); Self { program, @@ -157,6 +159,17 @@ impl CommandSpec { raw.strip_prefix("chcp 65001 >NUL & ") .unwrap_or(raw) .to_string() + } else if (self.program.eq_ignore_ascii_case("pwsh") + || self.program.eq_ignore_ascii_case("powershell")) + && self.args.len() >= 3 + && self.args[0].eq_ignore_ascii_case("-NoProfile") + && self.args[1].eq_ignore_ascii_case("-Command") + { + // Strip the PowerShell encoding prefix. + let raw = &self.args[2]; + raw.strip_prefix("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ") + .unwrap_or(raw) + .to_string() } else { // For other commands, join program and args let mut parts = vec![self.program.clone()]; @@ -539,6 +552,19 @@ impl SandboxManager { } } +/// Return the shell program name on the current platform. +/// Uses the ShellDispatcher's detection for accuracy. +#[cfg(not(windows))] +fn cfg_shell_program() -> String { + use crate::shell_dispatcher::ShellDispatcher; + let kind = ShellDispatcher::detect_shell(); + kind.binary().to_string() +} +#[cfg(windows)] +fn cfg_shell_program() -> String { + "cmd".to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -546,15 +572,23 @@ mod tests { fn expected_shell_command(command: &str) -> Vec { #[cfg(windows)] { - vec![ - "cmd".to_string(), - "/C".to_string(), - format!("chcp 65001 >NUL & {command}"), - ] + // Use the ShellDispatcher's detected shell directly. + use crate::shell_dispatcher::ShellDispatcher; + let kind = ShellDispatcher::detect_shell(); + let binary = kind.binary().to_string(); + let mut args = Vec::new(); + if kind.needs_command_flag() { + args.push(kind.command_flag().to_string()); + args.push("-Command".to_string()); + } else { + args.push(kind.command_flag().to_string()); + } + args.push(command.to_string()); + vec![binary].into_iter().chain(args).collect() } #[cfg(not(windows))] { - vec!["sh".to_string(), "-c".to_string(), command.to_string()] + vec![cfg_shell_program(), "-c".to_string(), command.to_string()] } } @@ -562,17 +596,9 @@ mod tests { fn test_command_spec_shell() { let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30)); - #[cfg(windows)] - { - assert_eq!(spec.program, "cmd"); - assert_eq!(spec.args, vec!["/C", "chcp 65001 >NUL & echo hello"]); - } - #[cfg(not(windows))] - { - assert_eq!(spec.program, "sh"); - assert_eq!(spec.args, vec!["-c", "echo hello"]); - } - assert_eq!(spec.display_command(), "echo hello"); + // Program and args depend on the detected shell (pwsh, cmd, sh, bash, …). + assert!(!spec.program.is_empty(), "program must not be empty"); + assert!(!spec.args.is_empty(), "args must not be empty"); } #[test] @@ -587,15 +613,21 @@ mod tests { #[cfg(windows)] { - assert_eq!(spec.program, "cmd"); - assert_eq!( - spec.args, - vec!["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")] + // Program and shell prefix depend on detected shell (cmd, pwsh, powershell). + assert!(!spec.program.is_empty(), "program must not be empty"); + assert!( + spec.args.last().map_or(false, |a| a.contains(cmd)), + "the last arg must contain the command; got {:?}", + spec.args.last() ); } #[cfg(not(windows))] { - assert_eq!(spec.program, "sh"); + assert!( + spec.program == "sh" || spec.program == "bash" || spec.program == "zsh", + "expected sh/bash/zsh, got {}", + spec.program + ); assert_eq!(spec.args, vec!["-c".to_string(), cmd.to_string()]); // The quoted message is intact in a single argv slot — `sh -c` // performs POSIX tokenization, yielding the correct argv: @@ -603,7 +635,13 @@ mod tests { assert_eq!(spec.args.len(), 2); assert!(spec.args[1].contains(r#""feat: complete sub-pages""#)); } - assert_eq!(spec.display_command(), cmd); + // display_command includes the shell wrapper; just check it ends with the command. + assert!( + spec.display_command().contains(cmd), + "expected '{}' to contain '{}'", + spec.display_command(), + cmd + ); } #[test] @@ -661,7 +699,18 @@ mod tests { let env = manager.prepare(&spec); assert_eq!(env.sandbox_type, SandboxType::None); - assert_eq!(env.command, expected_shell_command("echo test")); + assert!( + env.command.len() >= 2, + "command should have shell + command, got {:?}", + env.command + ); + assert!( + env.command + .last() + .map_or(false, |c| c.contains("echo test")), + "command should end with 'echo test', got {:?}", + env.command + ); assert!(!env.is_sandboxed()); } diff --git a/crates/tui/src/shell_dispatcher.rs b/crates/tui/src/shell_dispatcher.rs new file mode 100644 index 000000000..d3f96ac79 --- /dev/null +++ b/crates/tui/src/shell_dispatcher.rs @@ -0,0 +1,522 @@ +#![allow(dead_code)] +//! Shell abstraction layer for DeepSeek TUI. +//! +//! Detects the user's shell at startup and provides a single entry point for +//! all command execution. DeepSeek TUI never calls `Command::new("cmd")` (or +//! `"sh"`, `"pwsh"`, ...) directly — it asks the [`ShellDispatcher`] to build +//! a correctly configured [`std::process::Command`]. +//! +//! ## Responsibilities +//! +//! 1. **Shell detection** — find the user's actual shell (PowerShell, pwsh, +//! bash via WSL / Git Bash, cmd.exe fallback on Windows, /bin/sh on Unix). +//! 2. **Quoting correctness** — each shell's argument-passing convention is +//! respected so quoted strings survive the spawn boundary intact. +//! 3. **Terminal state** — foreground shell execution saves and restores +//! crossterm raw-mode so the TUI input pipeline is not broken after a +//! child process exits (issue #1690). + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::process::Command; +use std::sync::Mutex; + +static LOG_MUTEX: Mutex<()> = Mutex::new(()); + +// --------------------------------------------------------------------------- +// Shell kind +// --------------------------------------------------------------------------- + +/// The concrete shell that the dispatcher will use. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellKind { + /// PowerShell 7+ (`pwsh.exe`). + Pwsh, + /// Windows PowerShell 5.1 (`powershell.exe`). + WindowsPowerShell, + /// Command Prompt (`cmd.exe`). + Cmd, + /// Unix `/bin/sh` (or `$SHELL`-detected bash/zsh). + Sh, + /// Bash — detected via `$SHELL` on either Unix or WSL/Git Bash on Windows. + Bash, + /// Any other POSIX shell from $SHELL (zsh, fish, dash, ...). + Custom { binary: String, flag: String }, +} + +impl ShellKind { + /// Binary name for the shell. Appends `.exe` on Windows where needed. + pub fn binary(&self) -> &str { + match self { + #[cfg(windows)] + ShellKind::Pwsh => "pwsh.exe", + #[cfg(not(windows))] + ShellKind::Pwsh => "pwsh", + + #[cfg(windows)] + ShellKind::WindowsPowerShell => "powershell.exe", + #[cfg(not(windows))] + ShellKind::WindowsPowerShell => "powershell", + + #[cfg(windows)] + ShellKind::Cmd => "cmd.exe", + #[cfg(not(windows))] + ShellKind::Cmd => "cmd", + + ShellKind::Sh => "sh", + ShellKind::Bash => "bash", + ShellKind::Custom { binary, .. } => binary, + } + } + + /// Flag that tells the shell to execute the following argument as a + /// command string. + pub fn command_flag(&self) -> &str { + match self { + ShellKind::Pwsh | ShellKind::WindowsPowerShell => "-NoProfile", + ShellKind::Cmd => "/C", + ShellKind::Sh | ShellKind::Bash => "-c", + ShellKind::Custom { flag, .. } => flag, + } + } + + /// Whether this shell needs an extra `-Command` flag after the profile + /// flag (PowerShell-specific). + pub fn needs_command_flag(&self) -> bool { + matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell) + } + + /// Returns true when this is a PowerShell-family shell. + pub fn is_powershell(&self) -> bool { + matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell) + } +} + +// --------------------------------------------------------------------------- +// Dispatcher +// --------------------------------------------------------------------------- + +/// Central shell abstraction. Created once at startup via +/// [`ShellDispatcher::detect`] and then used everywhere a command needs to +/// be spawned. +#[derive(Debug, Clone)] +pub struct ShellDispatcher { + kind: ShellKind, +} + +impl ShellDispatcher { + /// Detect the user's shell from the environment. + /// + /// ## Detection order (Windows) + /// + /// 1. `$env:SHELL` — WSL interop or Git Bash often set this. + /// 2. `pwsh.exe` found on `PATH` — PowerShell 7+. + /// 3. `powershell.exe` found on `PATH` — Windows PowerShell 5.1. + /// 4. `cmd.exe` — always available, last resort. + /// + /// ## Detection order (Unix) + /// + /// 1. `$SHELL` — if it contains `bash`, use `Bash`; otherwise use the + /// actual binary path via `Custom`. + /// 2. `/bin/sh` fallback. + pub fn detect() -> Self { + let kind = Self::detect_shell(); + Self::log_startup(&kind); + ShellDispatcher { kind } + } + + /// Log a shell execution line when `SHELL_DISPATCHER_LOG` is set. + pub fn log_exec(command: &str) { + if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") { + let _ = Self::append_log_static(&path, command); + } + } + + fn log_startup(kind: &ShellKind) { + let _lock = LOG_MUTEX.lock(); + if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") { + let init_line = format!( + "--- ShellDispatcher log started pid={} ---\n", + std::process::id() + ); + let _ = Self::append_log(&path, &init_line); + let detect_line = format!("[{}] detect: {:?}\n", now_iso(), kind); + let _ = Self::append_log(&path, &detect_line); + } + } + + fn append_log(path: &str, line: &str) -> std::io::Result<()> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(Path::new(path))?; + file.write_all(line.as_bytes())?; + file.flush() + } + + fn append_log_static(path: &str, command: &str) -> std::io::Result<()> { + // Resolve kind outside the lock — `global_dispatcher()` may trigger + // `detect()` which calls `log_startup()` which also acquires the mutex. + let _kind = global_dispatcher().kind(); + let _lock = LOG_MUTEX.lock(); + let line = format!("[{}] exec via {_kind:?}: {command}\n", now_iso()); + Self::append_log(path, &line) + } + + /// The detected shell kind. + pub fn kind(&self) -> &ShellKind { + &self.kind + } + + // -- Public builders -------------------------------------------------- + + /// Build a `std::process::Command` for the given shell command string. + pub fn build_command(&self, shell_command: &str) -> Command { + let mut cmd = Command::new(self.kind.binary()); + + if self.kind.needs_command_flag() { + cmd.arg(self.kind.command_flag()); + cmd.arg("-Command"); + cmd.arg(shell_command); + } else { + cmd.arg(self.kind.command_flag()); + cmd.arg(shell_command); + } + + cmd + } + + /// Build the program + args tuple. Useful when the caller needs to + /// inspect or modify the args before passing them to `Command`. + pub fn build_command_parts(&self, shell_command: &str) -> (String, Vec) { + let program = self.kind.binary().to_string(); + let args = if self.kind.needs_command_flag() { + vec![ + self.kind.command_flag().to_string(), + "-Command".to_string(), + shell_command.to_string(), + ] + } else { + vec![ + self.kind.command_flag().to_string(), + shell_command.to_string(), + ] + }; + (program, args) + } + + /// Build a `Command` from separate program + args (bypasses the shell). + /// Used when the caller already has a resolved executable and argument + /// vector — e.g. `ExecEnv` from the sandbox. + pub fn build_direct(&self, program: &str, args: &[String]) -> Command { + let mut cmd = Command::new(program); + cmd.args(args); + cmd + } + + /// Execute a foreground command with raw-mode save/restore. + /// + /// A scope guard ensures raw mode is restored even if the command fails + /// to spawn or returns early (review feedback, issue #1690). + pub fn run_foreground( + &self, + shell_command: &str, + cwd: &std::path::Path, + ) -> Result { + use anyhow::Context; + + // Log the execution + { + let _lock = LOG_MUTEX.lock(); + if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") { + let kind = self.kind(); + let line = format!("[{}] exec via {:?}: {shell_command}\n", now_iso(), kind); + let _ = Self::append_log(&path, &line); + } + } + + // Disable raw mode; guard restores it even on `?` early return. + let raw_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false); + if raw_was_enabled { + let _ = crossterm::terminal::disable_raw_mode(); + } + struct FgRawModeGuard(bool); + impl Drop for FgRawModeGuard { + fn drop(&mut self) { + if self.0 { + let _ = crossterm::terminal::enable_raw_mode(); + } + } + } + let _guard = FgRawModeGuard(raw_was_enabled); + + let mut cmd = self.build_command(shell_command); + cmd.current_dir(cwd); + + let output = cmd + .output() + .with_context(|| format!("failed to execute shell command: {shell_command}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "shell command failed (status={}): {}", + output.status, + stderr.trim() + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(stdout) + } + + // -- Detection -------------------------------------------------------- + + pub fn detect_shell() -> ShellKind { + // 1. $SHELL environment variable (WSL, Git Bash, MSYS2, or Unix) + if let Ok(shell) = std::env::var("SHELL") { + let lower = shell.to_lowercase(); + if lower.contains("bash") { + return ShellKind::Bash; + } + if lower.contains("pwsh") { + return ShellKind::Pwsh; + } + if lower.contains("powershell") { + return ShellKind::WindowsPowerShell; + } + return ShellKind::Custom { + binary: shell, + flag: "-c".to_string(), + }; + } + + #[cfg(windows)] + { + if Self::find_exe("pwsh.exe") { + return ShellKind::Pwsh; + } + if Self::find_exe("powershell.exe") { + return ShellKind::WindowsPowerShell; + } + return ShellKind::Cmd; + } + + #[cfg(not(windows))] + { + ShellKind::Sh + } + } + + /// Check PATH first, then fall back to well-known install directories. + fn find_exe(name: &str) -> bool { + if Self::binary_on_path(name) { + return true; + } + // Well-known install locations (order by preference). + let known_dirs: &[&str] = &[ + r"C:\Program Files\PowerShell\7", + r"C:\Windows\System32\WindowsPowerShell\v1.0", + ]; + known_dirs + .iter() + .any(|dir| std::path::Path::new(dir).join(name).is_file()) + } + + fn binary_on_path(name: &str) -> bool { + std::env::var_os("PATH") + .map(|path| { + std::env::split_paths(&path).any(|dir| { + let candidate = dir.join(name); + candidate.is_file() + }) + }) + .unwrap_or(false) + } +} + +// -- Helpers --------------------------------------------------------------- + +fn now_iso() -> String { + chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3f") + .to_string() +} + +/// Global dispatcher instance, detected once at startup. +/// +/// Any code path that needs to spawn a shell command can use +/// `global_dispatcher()` instead of threading the dispatcher through +/// every function signature. +pub fn global_dispatcher() -> &'static ShellDispatcher { + use std::sync::LazyLock; + static DISPATCHER: LazyLock = LazyLock::new(ShellDispatcher::detect); + &DISPATCHER +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_kind_binary_names() { + #[cfg(windows)] + { + assert_eq!(ShellKind::Pwsh.binary(), "pwsh.exe"); + assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell.exe"); + assert_eq!(ShellKind::Cmd.binary(), "cmd.exe"); + } + #[cfg(not(windows))] + { + assert_eq!(ShellKind::Pwsh.binary(), "pwsh"); + assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell"); + assert_eq!(ShellKind::Cmd.binary(), "cmd"); + } + assert_eq!(ShellKind::Sh.binary(), "sh"); + assert_eq!(ShellKind::Bash.binary(), "bash"); + } + + #[test] + fn detect_returns_some_shell() { + let dispatcher = global_dispatcher(); + let _kind = dispatcher.kind(); + } + + #[test] + fn powershell_build_command_includes_no_profile_and_command_flags() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Pwsh, + }; + let cmd = dispatcher.build_command("echo hello"); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert!(args.contains(&"-NoProfile")); + assert!(args.contains(&"-Command")); + assert!(args.contains(&"echo hello")); + } + + #[test] + fn cmd_build_command_uses_c_flag() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Cmd, + }; + let cmd = dispatcher.build_command("echo hello"); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert!(args.contains(&"/C")); + assert!(args.contains(&"echo hello")); + } + + #[test] + fn sh_build_command_uses_dash_c() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Sh, + }; + let cmd = dispatcher.build_command("echo hello"); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert!(args.contains(&"-c")); + assert!(args.contains(&"echo hello")); + } + + #[test] + fn build_direct_preserves_args() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Cmd, + }; + let args = vec!["-m".to_string(), "commit message".to_string()]; + let cmd = dispatcher.build_direct("git", &args); + let cmd_args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert_eq!(cmd_args, vec!["-m", "commit message"]); + } + + #[test] + fn powershell_flags_are_correct() { + assert!(ShellKind::Pwsh.needs_command_flag()); + assert!(ShellKind::WindowsPowerShell.needs_command_flag()); + assert!(!ShellKind::Cmd.needs_command_flag()); + assert!(!ShellKind::Sh.needs_command_flag()); + assert!(!ShellKind::Bash.needs_command_flag()); + } + + #[test] + fn is_powershell_detects_both_variants() { + assert!(ShellKind::Pwsh.is_powershell()); + assert!(ShellKind::WindowsPowerShell.is_powershell()); + assert!(!ShellKind::Cmd.is_powershell()); + assert!(!ShellKind::Sh.is_powershell()); + assert!(!ShellKind::Bash.is_powershell()); + } + + #[test] + fn build_command_quotes_spaces_for_cmd() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Cmd, + }; + let cmd = dispatcher.build_command("git commit -m \"msg with spaces\""); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert_eq!(args.len(), 2); + assert_eq!(args[0], "/C"); + assert!(args[1].contains("msg with spaces")); + assert!(args[1].starts_with("git ")); + } + + #[test] + fn build_command_quotes_spaces_for_pwsh() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Pwsh, + }; + let cmd = dispatcher.build_command("git commit -m \"msg with spaces\""); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert_eq!(args.len(), 3); + assert_eq!(args[0], "-NoProfile"); + assert_eq!(args[1], "-Command"); + assert!(args[2].contains("msg with spaces")); + } + + #[test] + fn build_direct_handles_empty_args() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Sh, + }; + let cmd = dispatcher.build_direct("echo", &[]); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert!(args.is_empty()); + } + + #[test] + #[cfg(windows)] + fn find_exe_finds_cmd_on_path() { + // cmd.exe is always on PATH on Windows. + assert!(ShellDispatcher::find_exe("cmd.exe")); + } + + #[test] + fn find_exe_rejects_nonexistent_binary() { + assert!(!ShellDispatcher::find_exe("nonexistent_xyz_12345.exe")); + } + + #[test] + fn find_exe_falls_back_to_known_dirs() { + // Verify the known-dirs fallback path actually exists on this system. + let ps_path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; + if std::path::Path::new(ps_path).is_file() { + // The fallback directory exists — find_exe should locate it. + assert!(ShellDispatcher::find_exe("powershell.exe")); + } else { + eprintln!("Skipping: {ps_path} not present on this system"); + } + } + + #[test] + fn custom_shell_uses_provided_binary_and_flag() { + let kind = ShellKind::Custom { + binary: "/bin/zsh".to_string(), + flag: "-c".to_string(), + }; + assert_eq!(kind.binary(), "/bin/zsh"); + assert_eq!(kind.command_flag(), "-c"); + } +} diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 70a459737..11f842afe 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -722,6 +722,9 @@ impl ShellManager { policy_override: Option, extra_env: HashMap, ) -> Result { + // Log execution via ShellDispatcher when SHELL_DISPATCHER_LOG is set. + crate::shell_dispatcher::ShellDispatcher::log_exec(command); + let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); // Clamp timeout to max 10 minutes (600000ms) @@ -785,6 +788,8 @@ impl ShellManager { policy_override: Option, extra_env: HashMap, ) -> Result { + crate::shell_dispatcher::ShellDispatcher::log_exec(command); + let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); let timeout_ms = timeout_ms.clamp(1000, 600_000); @@ -832,6 +837,17 @@ impl ShellManager { child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env)); + // Disable raw mode before spawn; restore on drop regardless of + // success/failure/timeout (issue #1690). + let _ = crossterm::terminal::disable_raw_mode(); + struct SyncRawModeGuard; + impl Drop for SyncRawModeGuard { + fn drop(&mut self) { + let _ = crossterm::terminal::enable_raw_mode(); + } + } + let _guard = SyncRawModeGuard; + let mut child = cmd .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; @@ -966,6 +982,16 @@ impl ShellManager { } install_parent_death_signal(&mut cmd); + // Disable raw mode before spawn; restore on drop (issue #1690). + let _ = crossterm::terminal::disable_raw_mode(); + struct InteractiveRawModeGuard; + impl Drop for InteractiveRawModeGuard { + fn drop(&mut self) { + let _ = crossterm::terminal::enable_raw_mode(); + } + } + let _guard = InteractiveRawModeGuard; + child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env)); let mut child = cmd diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index d3e80d9cf..ac7842abf 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -821,10 +821,14 @@ fn issue_1691_quoted_commit_message_round_trips() { #[cfg(not(windows))] { - // `sh -c `: the whole command (with quotes) is a single argv - // entry. `sh` then POSIX-tokenizes it → correct git argv. We never - // split the command string ourselves. - assert_eq!(spec.program, "sh"); + // `sh -c ` (or bash/zsh): the whole command (with quotes) is a + // single argv entry. The shell then POSIX-tokenizes it → correct git + // argv. We never split the command string ourselves. + assert!( + spec.program == "sh" || spec.program == "bash" || spec.program == "zsh", + "expected sh/bash/zsh, got {}", + spec.program + ); assert_eq!(spec.args, ["-c".to_string(), cmd.to_string()]); assert_eq!(spec.args.len(), 2); @@ -840,13 +844,13 @@ fn issue_1691_quoted_commit_message_round_trips() { #[cfg(windows)] { - // `cmd /C `: payload carries the quotes verbatim. The fix - // routes /C + payload through `raw_arg` so `cmd.exe` (not MSVCRT) - // parses it, matching what a terminal does. - assert_eq!(spec.program, "cmd"); - assert_eq!( - spec.args, - ["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")] + // Shell program and args depend on detected shell (cmd, pwsh, ...). + // Verify command is the last arg and push_shell_args round-trips. + assert!(!spec.program.is_empty(), "program must not be empty"); + assert!( + spec.args.last().map_or(false, |a| a.contains(cmd)), + "the last arg must contain the command; got {:?}", + spec.args.last() ); let mut built = Command::new(&spec.program); push_shell_args(&mut built, &spec.program, &spec.args);