Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions crates/tui/src/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use std::process::Command;
use std::time::{Duration, Instant};
use tempfile::TempDir;

use crate::shell_invocation::{ShellInvocation, shell_invocation};
use crate::shell_invocation::{ShellInvocation, shell_invocation, shell_program_stem};
#[cfg(test)]
use crate::shell_invocation::{ShellPlatform, ShellProbe, shell_invocation_for_platform};

Expand All @@ -36,8 +36,9 @@ fn push_eval_shell_args(cmd: &mut Command, invocation: &ShellInvocation) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let is_cmd = shell_program_stem(&invocation.program).is_some_and(|stem| stem == "cmd");
if invocation.raw_payload_on_windows
&& invocation.program.eq_ignore_ascii_case("cmd")
&& is_cmd
&& invocation.args.len() == 2
&& invocation.args[0].eq_ignore_ascii_case("/C")
{
Expand Down Expand Up @@ -805,6 +806,7 @@ mod tests {
powershell.args,
vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string()
]
Expand All @@ -820,4 +822,26 @@ mod tests {
assert_eq!(unix.args, vec!["-c".to_string(), command.to_string()]);
assert!(!unix.raw_payload_on_windows);
}

#[cfg(windows)]
#[test]
fn push_eval_shell_args_uses_raw_arg_for_full_path_cmd() {
let invocation = ShellInvocation {
program: r"C:\Windows\System32\cmd.exe".to_string(),
args: vec![
"/C".to_string(),
r#"chcp 65001 >NUL & git commit -m "quoted""#.to_string(),
],
raw_payload_on_windows: true,
};

let mut cmd = Command::new("cmd");
push_eval_shell_args(&mut cmd, &invocation);
let got: Vec<String> = cmd
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();

assert_eq!(got, invocation.args);
}
}
16 changes: 15 additions & 1 deletion crates/tui/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use crate::models::SystemPrompt;
use crate::project_context::{ProjectContext, load_project_context_with_parents};
use crate::shell_invocation::{shell_invocation, shell_program_stem};
use crate::tui::app::AppMode;
use crate::tui::approval::ApprovalMode;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -143,7 +144,8 @@ 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 = if cfg!(windows) {
crate::shell_invocation::shell_invocation("").program
let resolved = shell_invocation("").program;
shell_program_stem(&resolved).unwrap_or(resolved)
} else {
std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string())
};
Expand Down Expand Up @@ -1074,6 +1076,18 @@ mod tests {
assert!(block.contains(&format!("- pwd: {}", tmp.path().display())));
assert!(block.contains("- platform:"));
assert!(block.contains("- shell:"));

#[cfg(windows)]
{
let shell_line = block
.lines()
.find(|line| line.starts_with("- shell: "))
.expect("shell line present");
assert!(
!shell_line.contains('\\') && !shell_line.contains('/'),
"Windows prompt shell should use the stem, not a full path"
);
}
}

#[test]
Expand Down
89 changes: 72 additions & 17 deletions crates/tui/src/shell_invocation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ pub(crate) struct ShellProbe {
pub(crate) shell: Option<String>,
pub(crate) comspec: Option<String>,
pub(crate) pwsh_on_path: bool,
pub(crate) powershell_on_path: bool,
}
Comment thread
aboimpinto marked this conversation as resolved.

impl ShellProbe {
Expand All @@ -89,7 +88,6 @@ impl ShellProbe {
.ok()
.filter(|value| !value.trim().is_empty()),
pwsh_on_path: command_on_path("pwsh.exe") || command_on_path("pwsh"),
powershell_on_path: command_on_path("powershell.exe") || command_on_path("powershell"),
}
Comment thread
aboimpinto marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -128,14 +126,13 @@ fn windows_shell_invocation(command: &str, probe: &ShellProbe) -> ShellInvocatio
return shell;
}

// Default Windows resolution is intentionally pwsh.exe -> cmd.exe. Windows
// PowerShell 5.x can still be selected explicitly through SHELL, but it is
// not used as an implicit fallback.
if probe.pwsh_on_path {
return powershell_invocation("pwsh.exe", command);
}

if probe.powershell_on_path {
return powershell_invocation("powershell.exe", command);
}

if let Some(comspec) = probe
Comment thread
aboimpinto marked this conversation as resolved.
.comspec
.as_deref()
Expand Down Expand Up @@ -173,6 +170,7 @@ fn powershell_invocation(program: &str, command: &str) -> ShellInvocation {
program: program.to_string(),
args: vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string(),
],
Expand All @@ -196,17 +194,14 @@ fn windows_posix_shell_program<'a>(shell: &'a str, stem: &'a str) -> &'a str {
}
}

fn shell_program_stem(program: &str) -> Option<String> {
pub(crate) fn shell_program_stem(program: &str) -> Option<String> {
let normalized = program.trim().replace('\\', "/");
let filename = normalized.rsplit('/').next()?.trim();
let stem = filename
.strip_suffix(".exe")
.or_else(|| filename.strip_suffix(".EXE"))
.unwrap_or(filename);
let filename = normalized.rsplit('/').next()?.trim().to_ascii_lowercase();
let stem = filename.strip_suffix(".exe").unwrap_or(&filename);
if stem.is_empty() {
None
} else {
Some(stem.to_ascii_lowercase())
Some(stem.to_string())
}
}

Expand Down Expand Up @@ -266,6 +261,7 @@ mod tests {
invocation.args,
[
"-NoProfile",
"-NonInteractive",
"-Command",
r#"Remove-Item -Path "target file.txt" -Force"#
]
Expand All @@ -277,6 +273,35 @@ mod tests {
);
}

#[test]
fn windows_shell_env_can_select_windows_powershell() {
let invocation = shell_invocation_for_platform(
"Get-ChildItem",
ShellPlatform::Windows,
&ShellProbe {
shell: Some(
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(),
),
comspec: Some(r"C:\Windows\System32\cmd.exe".to_string()),
pwsh_on_path: false,
},
);

assert_eq!(
invocation.program,
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
);
assert_eq!(
invocation.args,
["-NoProfile", "-NonInteractive", "-Command", "Get-ChildItem"]
);
assert!(!invocation.raw_payload_on_windows);
assert_eq!(
invocation.display_command().as_deref(),
Some("Get-ChildItem")
);
}

#[test]
fn windows_uses_pwsh_before_cmd_when_available() {
let invocation = shell_invocation_for_platform(
Expand All @@ -290,30 +315,60 @@ mod tests {
);

assert_eq!(invocation.program, "pwsh.exe");
assert_eq!(invocation.args, ["-NoProfile", "-Command", "Get-ChildItem"]);
assert_eq!(
invocation.args,
["-NoProfile", "-NonInteractive", "-Command", "Get-ChildItem"]
);
assert!(!invocation.raw_payload_on_windows);
}

#[test]
fn windows_without_pwsh_falls_straight_to_cmd_not_windows_powershell() {
let invocation = shell_invocation_for_platform(
"git status --short",
ShellPlatform::Windows,
&ShellProbe {
comspec: Some(
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(),
),
pwsh_on_path: false,
..probe()
},
);

assert_eq!(invocation.program, "cmd");
assert_eq!(
invocation.args,
["/C", "chcp 65001 >NUL & git status --short"]
);
assert!(invocation.raw_payload_on_windows);
assert_eq!(
invocation.display_command().as_deref(),
Some("git status --short")
);
}

#[test]
fn windows_falls_back_to_comspec_cmd_with_utf8_prefix() {
let invocation = shell_invocation_for_platform(
r#"git commit -m "hello world""#,
"git status --short",
ShellPlatform::Windows,
&ShellProbe {
comspec: Some(r"C:\Windows\System32\cmd.exe".to_string()),
pwsh_on_path: false,
..probe()
},
);

assert_eq!(invocation.program, r"C:\Windows\System32\cmd.exe");
assert_eq!(
invocation.args,
["/C", r#"chcp 65001 >NUL & git commit -m "hello world""#]
["/C", "chcp 65001 >NUL & git status --short"]
);
assert!(invocation.raw_payload_on_windows);
assert_eq!(
invocation.display_command().as_deref(),
Some(r#"git commit -m "hello world""#)
Some("git status --short")
);
}
Comment thread
aboimpinto marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Expand Down