diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api.rs | 7 | ||||
| -rw-r--r-- | src/detect/mod.rs | 22 | ||||
| -rw-r--r-- | src/pane.rs | 222 | ||||
| -rw-r--r-- | src/pane/osc.rs | 15 |
4 files changed, 222 insertions, 44 deletions
diff --git a/src/app/api.rs b/src/app/api.rs index b1b5f4fd..eeb1127f 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -455,9 +455,10 @@ impl App { return false; } - crate::pane::uses_windows_powershell_prompt_cwd_reporting( - crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), - ) + crate::pane::uses_windows_powershell_pane_shell(crate::pane::PaneShellConfig::new( + &self.state.default_shell, + self.state.shell_mode, + )) } } diff --git a/src/detect/mod.rs b/src/detect/mod.rs index e88d5e5d..cd350331 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -888,6 +888,28 @@ mod tests { ); } + // A plain shell pane launched with herdr's injected prompt integration + // must still classify as a shell, not an agent, even though its argv now + // carries a -Command payload. + #[test] + fn identify_agent_in_job_ignores_herdr_powershell_shell_integration_argv() { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 1, + "powershell.exe", + &[ + "powershell.exe", + "-NoExit", + "-Command", + crate::pane::WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND, + ], + )], + }; + + assert_eq!(identify_agent_in_job(&job), None); + } + #[test] fn identify_agent_in_job_detects_opencode_exe_from_pnpm_package() { let job = crate::platform::ForegroundJob { diff --git a/src/pane.rs b/src/pane.rs index 2ba0d639..6094af83 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1223,12 +1223,34 @@ impl<'a> PaneShellConfig<'a> { } } +/// Target platform for shell launch policy. Parameterized (instead of raw +/// `cfg!` checks at each decision point) so every branch stays testable on +/// every host platform. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ShellLaunchTarget { + Windows, + Macos, + OtherUnix, +} + +impl ShellLaunchTarget { + fn current() -> Self { + if cfg!(windows) { + Self::Windows + } else if cfg!(target_os = "macos") { + Self::Macos + } else { + Self::OtherUnix + } + } +} + fn shell_mode_uses_login_shell( mode: crate::config::ShellModeConfig, - target_is_macos: bool, + target: ShellLaunchTarget, ) -> bool { match mode { - crate::config::ShellModeConfig::Auto => target_is_macos, + crate::config::ShellModeConfig::Auto => target == ShellLaunchTarget::Macos, crate::config::ShellModeConfig::Login => true, crate::config::ShellModeConfig::NonLogin => false, } @@ -1280,46 +1302,74 @@ fn resolve_shell_for_login_mode(shell: &str) -> io::Result<String> { }) } +/// Sourced via `-NoExit -Command` when launching PowerShell on Windows. It +/// wraps whatever `prompt` function the user's profile left behind so each +/// prompt render appends the cwd as OSC 9;9 — the sequence Windows Terminal +/// and ConEmu standardized for shell integration. PowerShell never updates +/// its Win32 process cwd on `Set-Location`, so prompt-time reporting is the +/// only reliable cwd source on Windows. +/// +/// The snippet must not contain double quotes: powershell.exe parses its +/// command line with its own rules that disagree with the ArgvQuote escaping +/// portable-pty applies, and embedded `\"` sequences get corrupted in +/// transit. Single-quoted strings and `[char]` codes keep the round-trip +/// byte-exact, and the OSC 9;9 payload is emitted unquoted (the original +/// ConEmu form, which the cwd tracker accepts). +/// +/// The original prompt must be invoked before any other statement in the +/// wrapper: anything that runs first resets `$?`, so a status-aware user +/// prompt would show success after a failed command (verified on 5.1). +pub(crate) const WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND: &str = r"if ($null -eq $global:__HerdrOriginalPrompt) { $global:__HerdrOriginalPrompt = $function:prompt; function global:prompt { $out = @(& $global:__HerdrOriginalPrompt) -join ' '; $loc = $ExecutionContext.SessionState.Path.CurrentLocation; if ($loc.Provider.Name -eq 'FileSystem') { $esc = [string][char]27; $out += $esc + ']9;9;' + $loc.ProviderPath + $esc + '\' }; $out } }"; + fn pane_shell_command_builder_for_target( shell_config: PaneShellConfig<'_>, - target_is_macos: bool, + target: ShellLaunchTarget, ) -> io::Result<CommandBuilder> { let shell = pane_shell(shell_config.default_shell); - if shell_mode_uses_login_shell(shell_config.mode, target_is_macos) { + if shell_mode_uses_login_shell(shell_config.mode, target) { let mut cmd = CommandBuilder::new_default_prog(); cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?); Ok(cmd) } else { - Ok(CommandBuilder::new(&shell)) + let mut cmd = CommandBuilder::new(&shell); + if uses_windows_powershell_pane_shell_for_target(shell_config, target) { + cmd.args([ + "-NoExit", + "-Command", + WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND, + ]); + } + Ok(cmd) } } fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result<CommandBuilder> { - pane_shell_command_builder_for_target(shell_config, cfg!(target_os = "macos")) + pane_shell_command_builder_for_target(shell_config, ShellLaunchTarget::current()) } -#[cfg(windows)] -pub(crate) fn uses_windows_powershell_prompt_cwd_reporting( - shell_config: PaneShellConfig<'_>, -) -> bool { - if shell_mode_uses_login_shell(shell_config.mode, cfg!(target_os = "macos")) { - return false; - } - is_windows_powershell_shell(&pane_shell(shell_config.default_shell)) +/// True when panes launch an interactive PowerShell directly on Windows. +/// Gates the prompt-based cwd reporting pipeline and the agent-exit shell +/// respawn recovery. +pub(crate) fn uses_windows_powershell_pane_shell(shell_config: PaneShellConfig<'_>) -> bool { + uses_windows_powershell_pane_shell_for_target(shell_config, ShellLaunchTarget::current()) } -#[cfg(not(windows))] -pub(crate) fn uses_windows_powershell_prompt_cwd_reporting( - _shell_config: PaneShellConfig<'_>, +fn uses_windows_powershell_pane_shell_for_target( + shell_config: PaneShellConfig<'_>, + target: ShellLaunchTarget, ) -> bool { - false + target == ShellLaunchTarget::Windows + && !shell_mode_uses_login_shell(shell_config.mode, target) + && is_powershell_shell(&pane_shell(shell_config.default_shell)) } -#[cfg(windows)] -fn is_windows_powershell_shell(shell: &str) -> bool { - let name = Path::new(shell) - .file_name() - .and_then(std::ffi::OsStr::to_str) +fn is_powershell_shell(shell: &str) -> bool { + // Split on both separators by hand: `Path::file_name` only treats `\` as + // a separator on Windows hosts, and this predicate must evaluate Windows + // shell paths correctly from tests on any host. + let name = shell + .rsplit(['/', '\\']) + .next() .unwrap_or(shell) .to_ascii_lowercase(); matches!( @@ -1496,7 +1546,7 @@ impl PaneRuntime { render_dirty: Arc<AtomicBool>, ) -> std::io::Result<Self> { let windows_powershell_prompt_cwd_reporting = - uses_windows_powershell_prompt_cwd_reporting(shell_config); + uses_windows_powershell_pane_shell(shell_config); let mut cmd = pane_shell_command_builder(shell_config)?; cmd.cwd(cwd); apply_pane_terminal_env(&mut cmd); @@ -2765,19 +2815,23 @@ mod tests { fn shell_mode_auto_uses_login_shell_only_on_macos() { assert!(shell_mode_uses_login_shell( crate::config::ShellModeConfig::Auto, - true + ShellLaunchTarget::Macos + )); + assert!(!shell_mode_uses_login_shell( + crate::config::ShellModeConfig::Auto, + ShellLaunchTarget::OtherUnix )); assert!(!shell_mode_uses_login_shell( crate::config::ShellModeConfig::Auto, - false + ShellLaunchTarget::Windows )); assert!(shell_mode_uses_login_shell( crate::config::ShellModeConfig::Login, - false + ShellLaunchTarget::OtherUnix )); assert!(!shell_mode_uses_login_shell( crate::config::ShellModeConfig::NonLogin, - true + ShellLaunchTarget::Macos )); } @@ -2786,7 +2840,7 @@ mod tests { fn login_shell_builder_uses_default_prog_with_resolved_shell_env() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Login), - false, + ShellLaunchTarget::OtherUnix, ) .unwrap(); assert!(cmd.is_default_prog()); @@ -2801,7 +2855,7 @@ mod tests { fn auto_shell_builder_uses_login_shell_on_macos_target() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Auto), - true, + ShellLaunchTarget::Macos, ) .unwrap(); assert!(cmd.is_default_prog()); @@ -2815,26 +2869,112 @@ mod tests { fn auto_shell_builder_keeps_direct_shell_on_non_macos_target() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Auto), - false, + ShellLaunchTarget::OtherUnix, ) .unwrap(); assert!(!cmd.is_default_prog()); assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]); } - #[cfg(windows)] #[test] - fn windows_powershell_shell_builder_launches_plain_shell() { + fn windows_powershell_builder_injects_prompt_cwd_shell_integration() { + for shell in [ + "powershell.exe", + "pwsh.exe", + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + ] { + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new(shell, crate::config::ShellModeConfig::NonLogin), + ShellLaunchTarget::Windows, + ) + .unwrap(); + + assert_eq!( + cmd.get_argv(), + &[ + std::ffi::OsString::from(shell), + std::ffi::OsString::from("-NoExit"), + std::ffi::OsString::from("-Command"), + std::ffi::OsString::from(WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND), + ] + ); + } + + let script = WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND; + assert!(script.contains("]9;9;"), "missing OSC 9;9 emit: {script}"); + assert!( + script.contains("$global:__HerdrOriginalPrompt = $function:prompt"), + "must wrap the profile-defined prompt: {script}" + ); + assert!( + script.contains("$null -eq $global:__HerdrOriginalPrompt"), + "wrap must be idempotent for nested sessions: {script}" + ); + assert!( + script.contains("'FileSystem'"), + "must not report non-filesystem provider paths: {script}" + ); + assert!( + !script.contains('"'), + "double quotes corrupt the powershell.exe command-line round-trip: {script}" + ); + let invoke_original = script + .find("@(& $global:__HerdrOriginalPrompt)") + .expect("wrapper must invoke the original prompt"); + let cwd_lookup = script + .find("$loc =") + .expect("wrapper must look up the current location"); + assert!( + invoke_original < cwd_lookup, + "original prompt must run first or $? is reset before a status-aware prompt reads it: {script}" + ); + } + + #[test] + fn windows_non_powershell_builder_launches_plain_shell() { let cmd = pane_shell_command_builder_for_target( - PaneShellConfig::new("powershell.exe", crate::config::ShellModeConfig::NonLogin), - false, + PaneShellConfig::new("cmd.exe", crate::config::ShellModeConfig::NonLogin), + ShellLaunchTarget::Windows, ) .unwrap(); - assert_eq!( - cmd.get_argv(), - &[std::ffi::OsString::from("powershell.exe")] - ); + assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("cmd.exe")]); + } + + #[test] + fn unix_powershell_builder_launches_plain_shell() { + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new("pwsh", crate::config::ShellModeConfig::NonLogin), + ShellLaunchTarget::OtherUnix, + ) + .unwrap(); + + assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("pwsh")]); + } + + #[test] + fn windows_powershell_pane_shell_predicate_requires_windows_and_non_login() { + let pwsh = PaneShellConfig::new("pwsh.exe", crate::config::ShellModeConfig::NonLogin); + assert!(uses_windows_powershell_pane_shell_for_target( + pwsh, + ShellLaunchTarget::Windows + )); + assert!(!uses_windows_powershell_pane_shell_for_target( + pwsh, + ShellLaunchTarget::OtherUnix + )); + assert!(!uses_windows_powershell_pane_shell_for_target( + pwsh, + ShellLaunchTarget::Macos + )); + assert!(!uses_windows_powershell_pane_shell_for_target( + PaneShellConfig::new("pwsh.exe", crate::config::ShellModeConfig::Login), + ShellLaunchTarget::Windows + )); + assert!(!uses_windows_powershell_pane_shell_for_target( + PaneShellConfig::new("cmd.exe", crate::config::ShellModeConfig::NonLogin), + ShellLaunchTarget::Windows + )); } #[test] @@ -2844,7 +2984,7 @@ mod tests { "/__herdr_missing_shell__", crate::config::ShellModeConfig::Login, ), - false, + ShellLaunchTarget::OtherUnix, ) .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound); @@ -2876,7 +3016,7 @@ mod tests { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("fake-shell", crate::config::ShellModeConfig::Login), - false, + ShellLaunchTarget::OtherUnix, ) .unwrap(); diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 2fef72a2..92a743c4 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -1057,6 +1057,21 @@ mod tests { ); } + // The quoted form is what Windows Terminal's documented shell integration + // snippet emits. Herdr's own injected prompt integration deliberately + // emits the path unquoted; see WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND. + #[test] + fn cwd_osc_tracker_detects_quoted_powershell_prompt_cwd_sequence() { + let mut tracker = CwdOscTracker::default(); + + tracker.observe(b"PS C:\\my proj> \x1b]9;9;\"C:\\my proj\"\x1b\\"); + + assert_eq!( + tracker.drain_latest(), + Some(std::path::PathBuf::from("C:\\my proj")) + ); + } + // ----------------------------------------------------------------------- // AgentOscStateTracker tests // ----------------------------------------------------------------------- |
