Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ded38f8
feat: add ShellDispatcher for shell-agnostic command execution
aboimpinto May 18, 2026
7b18e74
feat(shell_dispatcher): harden PowerShell detection and add execution…
aboimpinto May 19, 2026
fafb76e
style: cargo fmt after rebase
aboimpinto May 24, 2026
e932ca1
fix: add #[allow(dead_code)] to ShellDispatcher items not yet wired
aboimpinto May 24, 2026
b589393
fix: make raw-mode guard conditional and remove unused_variables allow
aboimpinto May 24, 2026
28c80e8
fix: restore #[allow(unused_variables)] for dead code in ShellDispatcher
aboimpinto May 24, 2026
9f09ded
fix: prefix unused kind with _kind to suppress unused_variables warning
aboimpinto May 24, 2026
b885ee8
fix: move kind variable inside #[cfg(windows)] block to avoid unused …
aboimpinto May 24, 2026
10d3d97
fix: update tests for cross-platform ShellDispatcher
aboimpinto May 24, 2026
6d8c673
style: cargo fmt after test fixes
aboimpinto May 24, 2026
1990673
fix: make ShellDispatcher::detect_shell() pub for test helpers
aboimpinto May 24, 2026
59a9466
fix: relax Windows test expectations for dynamic shell detection
aboimpinto May 24, 2026
b9a0029
style: cargo fmt
aboimpinto May 24, 2026
9b6d04e
fix: handle pwsh multi-arg format in test helpers
aboimpinto May 24, 2026
0f91731
fix: relax prepare_unsandboxed assertion for pwsh UTF8 prefix
aboimpinto May 24, 2026
ccae6b4
fix: replace field-access format string with explicit arg
aboimpinto May 24, 2026
78dc07f
style: cargo fmt
aboimpinto May 24, 2026
c5a251a
fix: replace second field-access format string with explicit arg
aboimpinto May 24, 2026
c39b1a1
style: cargo fmt sandbox/mod.rs
aboimpinto May 24, 2026
19feabb
fix: increase flaky composer_history test timeout from 5s to 30s for …
aboimpinto May 24, 2026
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
2 changes: 1 addition & 1 deletion crates/tui/src/composer_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion crates/tui/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
117 changes: 83 additions & 34 deletions crates/tui/src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()];
Expand Down Expand Up @@ -539,40 +552,53 @@ 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::*;

fn expected_shell_command(command: &str) -> Vec<String> {
#[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()]
}
}

#[test]
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]
Expand All @@ -587,23 +613,35 @@ 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:
// ["git","commit","-m","feat: complete sub-pages"].
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]
Expand Down Expand Up @@ -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());
}

Expand Down
Loading
Loading