aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 01:53:55 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 01:55:36 +0300
commit8df1639048529b383358000879b5ee078a90b9f9 (patch)
tree7b0088246c3cc0834ed04cf20bf9726645bdb040
parent817a1562db9156e34f13487344ea147618f9070b (diff)
fix: use login shells for macos panes
refs #350
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx9
-rw-r--r--src/app/api.rs4
-rw-r--r--src/app/api/panes.rs2
-rw-r--r--src/app/api/tabs.rs2
-rw-r--r--src/app/creation.rs4
-rw-r--r--src/app/input/mod.rs2
-rw-r--r--src/app/input/mouse.rs2
-rw-r--r--src/app/input/navigate.rs2
-rw-r--r--src/app/input/terminal.rs2
-rw-r--r--src/app/mod.rs46
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config.rs3
-rw-r--r--src/config/model.rs14
-rw-r--r--src/main.rs4
-rw-r--r--src/pane.rs231
-rw-r--r--src/persist/restore.rs26
-rw-r--r--src/server/notifications.rs2
-rw-r--r--src/terminal/runtime.rs8
-rw-r--r--src/ui/mobile.rs2
-rw-r--r--src/ui/sidebar.rs2
-rw-r--r--src/workspace.rs32
-rw-r--r--src/workspace/tab.rs22
23 files changed, 362 insertions, 62 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 20f39658..ec56a4b5 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -4,6 +4,7 @@
### Fixed
- The OpenCode integration no longer treats `session.created` or `session.updated` plugin events as idle signals, so active sessions stay marked working until OpenCode reports `session.status` or `session.idle`. (#351)
+- New interactive panes now use login-shell startup on macOS by default so Homebrew and other login PATH setup is available, with `terminal.shell_mode = "non_login"` as an opt-out. (#350)
## [0.6.5] - 2026-05-29
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 936cc3ac..48a5a321 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -56,6 +56,15 @@ default_shell = "nu"
When unset or empty, Herdr uses `$SHELL`, then `/bin/sh`. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Command panes still run through `/bin/sh -c`; detached custom command keybindings use Herdr's existing `/bin/sh -lc` path.
+Set how Herdr starts newly created interactive pane shells:
+
+```toml
+[terminal]
+shell_mode = "auto"
+```
+
+`shell_mode = "auto"` starts login shells on macOS so login-only PATH setup such as `/usr/libexec/path_helper` and Homebrew shell initialization runs in new panes. On other platforms, it keeps the existing non-login shell behavior. Use `"login"` to force login-shell startup, or `"non_login"` to opt out. Command panes, detached custom command keybindings, and explicit argv launches keep their existing command execution paths.
+
Set the working directory policy for new panes, tabs, and workspaces:
```toml
diff --git a/src/app/api.rs b/src/app/api.rs
index df20cd34..a26b2856 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -311,7 +311,7 @@ impl App {
cwd,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
- &self.state.default_shell,
+ crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
self.event_tx.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
@@ -644,7 +644,7 @@ mod tests {
live_cwd.clone(),
0,
crate::terminal_theme::TerminalTheme::default(),
- "/bin/sh",
+ crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 4620329f..67bac8d3 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -50,7 +50,7 @@ impl App {
split_cwd,
scrollback_limit_bytes,
host_terminal_theme,
- &default_shell,
+ crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
params.focus,
) {
Some(Ok(result)) => result,
diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs
index f605d143..4057eced 100644
--- a/src/app/api/tabs.rs
+++ b/src/app/api/tabs.rs
@@ -86,7 +86,7 @@ impl App {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- &default_shell,
+ crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
)
});
match result {
diff --git a/src/app/creation.rs b/src/app/creation.rs
index 0f63d60d..f8edd676 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -110,7 +110,7 @@ impl App {
initial_cwd,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
- &self.state.default_shell,
+ crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
)?;
let root_pane = ws.tabs[idx].root_pane;
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
@@ -142,7 +142,7 @@ impl App {
cols,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
- &self.state.default_shell,
+ crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
self.event_tx.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 76a32fc1..cfcc3039 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -417,7 +417,7 @@ impl AppState {
cwd,
self.pane_scrollback_limit_bytes,
self.host_terminal_theme,
- &self.default_shell,
+ crate::pane::PaneShellConfig::new(&self.default_shell, self.shell_mode),
) {
let new_id = new_pane.pane_id;
terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime);
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index a9badfa9..e3ad0bc5 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -1975,7 +1975,7 @@ mod tests {
80,
app.state.pane_scrollback_limit_bytes,
app.state.host_terminal_theme,
- &app.state.default_shell,
+ crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode),
app.event_tx.clone(),
app.render_notify.clone(),
app.render_dirty.clone(),
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index e7581c5b..6a7e8d7c 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -1820,7 +1820,7 @@ last_pane = "prefix+tab"
80,
app.state.pane_scrollback_limit_bytes,
app.state.host_terminal_theme,
- &app.state.default_shell,
+ crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode),
app.event_tx.clone(),
app.render_notify.clone(),
app.render_dirty.clone(),
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 0a35c3be..5447e2b0 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -845,7 +845,7 @@ mod tests {
80,
app.state.pane_scrollback_limit_bytes,
app.state.host_terminal_theme,
- &app.state.default_shell,
+ crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode),
app.event_tx.clone(),
app.render_notify.clone(),
app.render_dirty.clone(),
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 1291d2a0..d8071c4d 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -289,6 +289,7 @@ impl App {
80,
config.advanced.scrollback_limit_bytes,
&config.terminal.default_shell,
+ config.terminal.shell_mode,
config.session.resume_agents_on_restore,
event_tx.clone(),
render_notify.clone(),
@@ -499,6 +500,7 @@ impl App {
cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(),
kitty_graphics_enabled: config.experimental.kitty_graphics,
default_shell: config.terminal.default_shell.clone(),
+ shell_mode: config.terminal.shell_mode,
new_terminal_cwd: config.terminal.new_cwd.clone(),
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
accent: crate::config::parse_color(&config.ui.accent),
@@ -606,6 +608,7 @@ impl App {
snapshot,
config.advanced.scrollback_limit_bytes,
&config.terminal.default_shell,
+ config.terminal.shell_mode,
imports,
app.event_tx.clone(),
app.render_notify.clone(),
@@ -1143,6 +1146,7 @@ impl App {
if !invalid_section("terminal") {
self.state.default_shell = config.terminal.default_shell.clone();
+ self.state.shell_mode = config.terminal.shell_mode;
self.state.new_terminal_cwd = config.terminal.new_cwd.clone();
}
@@ -1707,7 +1711,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\n[ui.toast]\ndelivery = \"herdr\"\n",
+ "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1735,6 +1739,10 @@ mod tests {
assert!(app.state.request_client_config_reload);
assert_eq!(app.state.default_shell, "nu");
assert_eq!(
+ app.state.shell_mode,
+ crate::config::ShellModeConfig::NonLogin
+ );
+ assert_eq!(
app.state.new_terminal_cwd,
crate::config::NewTerminalCwdConfig::Home
);
@@ -1996,6 +2004,42 @@ mod tests {
}
#[test]
+ fn reload_config_preserves_invalid_terminal_section_but_applies_valid_ui() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-invalid-terminal-section");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(
+ &path,
+ "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"sideways\"\nnew_cwd = \"home\"\n[ui.toast]\ndelivery = \"terminal\"\n",
+ )
+ .unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ let original_default_shell = app.state.default_shell.clone();
+ let original_shell_mode = app.state.shell_mode;
+ let original_new_cwd = app.state.new_terminal_cwd.clone();
+ let report = app.reload_config();
+
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
+ assert_eq!(app.state.default_shell, original_default_shell);
+ assert_eq!(app.state.shell_mode, original_shell_mode);
+ assert_eq!(app.state.new_terminal_cwd, original_new_cwd);
+ assert_eq!(
+ app.state.toast_config.delivery,
+ crate::config::ToastDelivery::Terminal
+ );
+ assert!(app
+ .state
+ .config_diagnostic
+ .as_deref()
+ .is_some_and(|message| message.contains("invalid terminal config")));
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn settings_save_toast_delivery_persists_then_applies_live_config() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("settings-save-toast-delivery");
diff --git a/src/app/state.rs b/src/app/state.rs
index 012d79e4..a9c1b8a3 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1157,6 +1157,7 @@ pub struct AppState {
pub cjk_ime_cursor_shape: u8,
pub kitty_graphics_enabled: bool,
pub default_shell: String,
+ pub shell_mode: crate::config::ShellModeConfig,
pub new_terminal_cwd: NewTerminalCwdConfig,
pub pane_scrollback_limit_bytes: usize,
#[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth
@@ -1461,6 +1462,7 @@ impl AppState {
cjk_ime_cursor_shape: 2, // steady_block
kitty_graphics_enabled: false,
default_shell: String::new(),
+ shell_mode: crate::config::ShellModeConfig::Auto,
new_terminal_cwd: NewTerminalCwdConfig::Follow,
pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES,
accent: Color::Cyan,
diff --git a/src/config.rs b/src/config.rs
index b4fcf1ef..ab194cf5 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -19,7 +19,8 @@ pub use self::{
},
model::{
validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport,
- ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ToastConfig, ToastDelivery,
+ ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ShellModeConfig, ToastConfig,
+ ToastDelivery,
},
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
diff --git a/src/config/model.rs b/src/config/model.rs
index 84d03bc2..77af2f75 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -63,11 +63,22 @@ impl<'de> Deserialize<'de> for NewTerminalCwdConfig {
}
}
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ShellModeConfig {
+ #[default]
+ Auto,
+ Login,
+ NonLogin,
+}
+
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct TerminalConfig {
/// Executable used for new interactive panes. Empty means SHELL, then /bin/sh.
pub default_shell: String,
+ /// Startup mode for new interactive pane shells.
+ pub shell_mode: ShellModeConfig,
/// CWD policy for new interactive panes, tabs, and workspaces.
pub new_cwd: NewTerminalCwdConfig,
}
@@ -494,13 +505,16 @@ mod tests {
fn terminal_default_shell_defaults_empty_and_parses() {
let default_config = Config::default();
assert!(default_config.terminal.default_shell.is_empty());
+ assert_eq!(default_config.terminal.shell_mode, ShellModeConfig::Auto);
let toml = r#"
[terminal]
default_shell = "nu"
+shell_mode = "non_login"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.terminal.default_shell, "nu");
+ assert_eq!(config.terminal.shell_mode, ShellModeConfig::NonLogin);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 163ce66e..a3d19876 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -84,6 +84,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Empty means $SHELL, then /bin/sh.
# default_shell = ""
+# Startup mode for new interactive pane shells: "auto", "login", or "non_login".
+# "auto" uses login shells on macOS and keeps the current behavior elsewhere.
+# shell_mode = "auto"
+
# CWD policy for new panes, tabs, and workspaces when no explicit --cwd is provided.
# Use "follow" to inherit the source pane/workspace, "home" for $HOME,
# "current" for Herdr's process directory, or a fixed path such as "~/Projects".
diff --git a/src/pane.rs b/src/pane.rs
index 885fb11d..97aeb9cb 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1,5 +1,6 @@
use std::cell::Cell;
-use std::io::{Read, Write};
+use std::io::{self, Read, Write};
+use std::path::Path;
use std::sync::{
atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
Arc, Mutex,
@@ -637,6 +638,96 @@ fn pane_shell_from(configured_shell: &str, env_shell: Option<String>) -> String
.unwrap_or_else(|| "/bin/sh".into())
}
+#[derive(Clone, Copy)]
+pub(crate) struct PaneShellConfig<'a> {
+ pub(crate) default_shell: &'a str,
+ pub(crate) mode: crate::config::ShellModeConfig,
+}
+
+impl<'a> PaneShellConfig<'a> {
+ pub(crate) fn new(default_shell: &'a str, mode: crate::config::ShellModeConfig) -> Self {
+ Self {
+ default_shell,
+ mode,
+ }
+ }
+}
+
+fn shell_mode_uses_login_shell(
+ mode: crate::config::ShellModeConfig,
+ target_is_macos: bool,
+) -> bool {
+ match mode {
+ crate::config::ShellModeConfig::Auto => target_is_macos,
+ crate::config::ShellModeConfig::Login => true,
+ crate::config::ShellModeConfig::NonLogin => false,
+ }
+}
+
+fn is_executable_file(path: &Path) -> bool {
+ let Ok(metadata) = path.metadata() else {
+ return false;
+ };
+ if !metadata.is_file() {
+ return false;
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ metadata.permissions().mode() & 0o111 != 0
+ }
+ #[cfg(not(unix))]
+ {
+ true
+ }
+}
+
+fn resolve_shell_for_login_mode(shell: &str) -> io::Result<String> {
+ if shell.contains(std::path::MAIN_SEPARATOR) {
+ let path = Path::new(shell);
+ return is_executable_file(path)
+ .then(|| shell.to_string())
+ .ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::NotFound,
+ format!("login shell {shell:?} is not executable"),
+ )
+ });
+ }
+
+ std::env::var_os("PATH")
+ .and_then(|path| {
+ std::env::split_paths(&path)
+ .map(|dir| dir.join(shell))
+ .find(|candidate| is_executable_file(candidate))
+ })
+ .and_then(|path| path.into_os_string().into_string().ok())
+ .ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::NotFound,
+ format!("login shell {shell:?} was not found on PATH"),
+ )
+ })
+}
+
+fn pane_shell_command_builder_for_target(
+ shell_config: PaneShellConfig<'_>,
+ target_is_macos: bool,
+) -> io::Result<CommandBuilder> {
+ let shell = pane_shell(shell_config.default_shell);
+ if shell_mode_uses_login_shell(shell_config.mode, target_is_macos) {
+ let mut cmd = CommandBuilder::new_default_prog();
+ cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?);
+ Ok(cmd)
+ } else {
+ Ok(CommandBuilder::new(&shell))
+ }
+}
+
+fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result<CommandBuilder> {
+ pane_shell_command_builder_for_target(shell_config, cfg!(target_os = "macos"))
+}
+
#[cfg(unix)]
fn duplicate_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::os::fd::RawFd> {
let duplicated = unsafe { libc::dup(fd) };
@@ -909,7 +1000,7 @@ impl PaneRuntime {
cwd: std::path::PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: PaneShellConfig<'_>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -921,7 +1012,7 @@ impl PaneRuntime {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
None,
events,
render_notify,
@@ -936,14 +1027,13 @@ impl PaneRuntime {
cwd: std::path::PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: PaneShellConfig<'_>,
initial_history_ansi: Option<&str>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
- let shell = pane_shell(default_shell);
- let mut cmd = CommandBuilder::new(&shell);
+ let mut cmd = pane_shell_command_builder(shell_config)?;
cmd.cwd(cwd);
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
apply_pane_terminal_env(&mut cmd);
@@ -2294,6 +2384,135 @@ mod tests {
}
#[test]
+ fn shell_mode_auto_uses_login_shell_only_on_macos() {
+ assert!(shell_mode_uses_login_shell(
+ crate::config::ShellModeConfig::Auto,
+ true
+ ));
+ assert!(!shell_mode_uses_login_shell(
+ crate::config::ShellModeConfig::Auto,
+ false
+ ));
+ assert!(shell_mode_uses_login_shell(
+ crate::config::ShellModeConfig::Login,
+ false
+ ));
+ assert!(!shell_mode_uses_login_shell(
+ crate::config::ShellModeConfig::NonLogin,
+ true
+ ));
+ }
+
+ #[test]
+ 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,
+ )
+ .unwrap();
+ assert!(cmd.is_default_prog());
+ assert_eq!(
+ cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str),
+ Some("/bin/sh")
+ );
+ }
+
+ #[test]
+ 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,
+ )
+ .unwrap();
+ assert!(cmd.is_default_prog());
+ assert_eq!(
+ cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str),
+ Some("/bin/sh")
+ );
+ }
+
+ #[test]
+ 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,
+ )
+ .unwrap();
+ assert!(!cmd.is_default_prog());
+ assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]);
+ }
+
+ #[test]
+ fn login_shell_builder_rejects_missing_shell_instead_of_falling_back() {
+ let err = pane_shell_command_builder_for_target(
+ PaneShellConfig::new(
+ "/__herdr_missing_shell__",
+ crate::config::ShellModeConfig::Login,
+ ),
+ false,
+ )
+ .unwrap_err();
+ assert_eq!(err.kind(), io::ErrorKind::NotFound);
+ }
+
+ #[test]
+ fn login_shell_builder_resolves_bare_shell_names_from_path() {
+ let _lock = crate::integration::integration_env_lock();
+ let base = std::env::temp_dir().join(format!(
+ "herdr-login-shell-path-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ let bin = base.join("bin");
+ std::fs::create_dir_all(&bin).unwrap();
+ let shell = bin.join("fake-shell");
+ std::fs::write(&shell, "#!/bin/sh\nexit 0\n").unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
+ }
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("PATH", &bin);
+
+ let cmd = pane_shell_command_builder_for_target(
+ PaneShellConfig::new("fake-shell", crate::config::ShellModeConfig::Login),
+ false,
+ )
+ .unwrap();
+
+ assert!(cmd.is_default_prog());
+ assert_eq!(
+ cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str),
+ shell.to_str()
+ );
+ match original_path {
+ Some(path) => std::env::set_var("PATH", path),
+ None => std::env::remove_var("PATH"),
+ }
+ let _ = std::fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn login_shell_resolution_preserves_shell_paths() {
+ assert_eq!(resolve_shell_for_login_mode("/bin/sh").unwrap(), "/bin/sh");
+ }
+
+ #[test]
+ fn non_login_shell_builder_execs_resolved_shell_directly() {
+ let cmd = pane_shell_command_builder(PaneShellConfig::new(
+ "/bin/sh",
+ crate::config::ShellModeConfig::NonLogin,
+ ))
+ .unwrap();
+ assert!(!cmd.is_default_prog());
+ assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]);
+ }
+
+ #[test]
fn pane_terminal_identity_overrides_outer_terminal_env() {
let output = capture_shell_output("printf '%s\\n%s\\n' \"$TERM\" \"$COLORTERM\"", &[]);
assert_eq!(output, "xterm-256color\ntruecolor\n");
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 916478b6..881fca6c 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -36,7 +36,7 @@ struct PaneRestoreStartup<'a> {
struct RestoreRuntimeContext<'a> {
scrollback_limit_bytes: usize,
- default_shell: &'a str,
+ shell_config: crate::pane::PaneShellConfig<'a>,
resume_agents_on_restore: bool,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
@@ -68,6 +68,7 @@ pub fn restore(
cols: u16,
scrollback_limit_bytes: usize,
default_shell: &str,
+ shell_mode: crate::config::ShellModeConfig,
resume_agents_on_restore: bool,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
@@ -80,7 +81,7 @@ pub fn restore(
rows,
cols,
scrollback_limit_bytes,
- default_shell,
+ crate::pane::PaneShellConfig::new(default_shell, shell_mode),
resume_agents_on_restore,
&mut imported_panes,
events,
@@ -94,6 +95,7 @@ pub fn restore_handoff(
snapshot: &SessionSnapshot,
scrollback_limit_bytes: usize,
default_shell: &str,
+ shell_mode: crate::config::ShellModeConfig,
imports: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
@@ -105,7 +107,7 @@ pub fn restore_handoff(
24,
80,
scrollback_limit_bytes,
- default_shell,
+ crate::pane::PaneShellConfig::new(default_shell, shell_mode),
false,
imports,
events,
@@ -159,7 +161,7 @@ fn restore_with_imports_strict(
rows: u16,
cols: u16,
scrollback_limit_bytes: usize,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
resume_agents_on_restore: bool,
imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
events: mpsc::Sender<AppEvent>,
@@ -172,7 +174,7 @@ fn restore_with_imports_strict(
rows,
cols,
scrollback_limit_bytes,
- default_shell,
+ shell_config,
resume_agents_on_restore,
imported_panes,
events,
@@ -199,7 +201,7 @@ fn restore_with_imports(
rows: u16,
cols: u16,
scrollback_limit_bytes: usize,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
resume_agents_on_restore: bool,
imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
events: mpsc::Sender<AppEvent>,
@@ -212,7 +214,7 @@ fn restore_with_imports(
rows,
cols,
scrollback_limit_bytes,
- default_shell,
+ shell_config,
resume_agents_on_restore,
imported_panes,
events,
@@ -228,7 +230,7 @@ fn restore_with_imports_and_failures(
rows: u16,
cols: u16,
scrollback_limit_bytes: usize,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
resume_agents_on_restore: bool,
imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
events: mpsc::Sender<AppEvent>,
@@ -243,7 +245,7 @@ fn restore_with_imports_and_failures(
for (idx, ws_snap) in snapshot.workspaces.iter().enumerate() {
let runtime_context = RestoreRuntimeContext {
scrollback_limit_bytes,
- default_shell,
+ shell_config,
resume_agents_on_restore,
events: events.clone(),
render_notify: render_notify.clone(),
@@ -454,7 +456,7 @@ fn restore_tab(
cwd.clone(),
runtime_context.scrollback_limit_bytes,
crate::terminal_theme::TerminalTheme::default(),
- runtime_context.default_shell,
+ runtime_context.shell_config,
startup.initial_history_ansi,
runtime_context.events.clone(),
runtime_context.render_notify.clone(),
@@ -1053,6 +1055,7 @@ mod tests {
80,
0,
"/bin/true",
+ crate::config::ShellModeConfig::NonLogin,
false,
events,
Arc::new(Notify::new()),
@@ -1152,6 +1155,7 @@ mod tests {
80,
0,
"/bin/sh",
+ crate::config::ShellModeConfig::NonLogin,
true,
events,
Arc::new(Notify::new()),
@@ -1187,6 +1191,7 @@ mod tests {
40,
4096,
"/bin/sh",
+ crate::config::ShellModeConfig::NonLogin,
false,
events,
render_notify,
@@ -1225,6 +1230,7 @@ mod tests {
40,
4096,
"/bin/sh",
+ crate::config::ShellModeConfig::NonLogin,
false,
events,
render_notify,
diff --git a/src/server/notifications.rs b/src/server/notifications.rs
index 0ed681ac..f0b2d746 100644
--- a/src/server/notifications.rs
+++ b/src/server/notifications.rs
@@ -113,7 +113,7 @@ mod tests {
live_cwd.clone(),
0,
crate::terminal_theme::TerminalTheme::default(),
- "/bin/sh",
+ crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index b1a25ee9..c7bb8810 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -84,7 +84,7 @@ impl TerminalRuntime {
cwd: std::path::PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -96,7 +96,7 @@ impl TerminalRuntime {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
events,
render_notify,
render_dirty,
@@ -111,7 +111,7 @@ impl TerminalRuntime {
cwd: std::path::PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
initial_history_ansi: Option<&str>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
@@ -124,7 +124,7 @@ impl TerminalRuntime {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
initial_history_ansi,
events,
render_notify,
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index c1dd57b6..9b400f51 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -1005,7 +1005,7 @@ mod tests {
live_cwd.clone(),
0,
crate::terminal_theme::TerminalTheme::default(),
- "/bin/sh",
+ crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 4c68aeb4..692e0236 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -1286,7 +1286,7 @@ mod tests {
live_cwd.clone(),
0,
crate::terminal_theme::TerminalTheme::default(),
- "/bin/sh",
+ crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
diff --git a/src/workspace.rs b/src/workspace.rs
index a302f3f8..4bab61a7 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -103,7 +103,7 @@ impl Workspace {
cols: u16,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -114,7 +114,7 @@ impl Workspace {
cols,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
events,
render_notify,
render_dirty,
@@ -139,7 +139,7 @@ impl Workspace {
cols,
scrollback_limit_bytes,
host_terminal_theme,
- "",
+ crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
events,
render_notify,
render_dirty,
@@ -154,7 +154,7 @@ impl Workspace {
cols: u16,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -181,7 +181,7 @@ impl Workspace {
cols,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
events,
render_notify,
render_dirty,
@@ -244,7 +244,7 @@ impl Workspace {
cwd: PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
self.create_tab_with_runtime(
rows,
@@ -252,7 +252,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
None,
)
}
@@ -264,7 +264,7 @@ impl Workspace {
cwd: PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
argv: Option<&[String]>,
) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
let number = self.tabs.len() + 1;
@@ -302,7 +302,7 @@ impl Workspace {
cols,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
events,
render_notify,
render_dirty,
@@ -368,7 +368,7 @@ impl Workspace {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
) -> std::io::Result<crate::workspace::tab::NewPane> {
let new_pane = self
.active_tab_mut()
@@ -380,7 +380,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
)?;
self.register_new_pane(new_pane.pane_id);
Ok(new_pane)
@@ -395,7 +395,7 @@ impl Workspace {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
focus_new_pane: bool,
) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
self.split_pane_with_runtime(
@@ -406,7 +406,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
focus_new_pane,
None,
)
@@ -433,7 +433,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- "",
+ crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
focus_new_pane,
Some(argv),
)
@@ -449,7 +449,7 @@ impl Workspace {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
focus_new_pane: bool,
argv: Option<&[String]>,
) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
@@ -475,7 +475,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
)
} {
Ok(new_pane) => new_pane,
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index 100e67d0..f3ad594e 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -53,7 +53,7 @@ impl Tab {
cols: u16,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -65,7 +65,7 @@ impl Tab {
cols,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
events,
render_notify,
render_dirty,
@@ -92,7 +92,7 @@ impl Tab {
cols,
scrollback_limit_bytes,
host_terminal_theme,
- "",
+ crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
events,
render_notify,
render_dirty,
@@ -108,7 +108,7 @@ impl Tab {
cols: u16,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -136,7 +136,7 @@ impl Tab {
initial_cwd.clone(),
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
events.clone(),
render_notify.clone(),
render_dirty.clone(),
@@ -194,7 +194,7 @@ impl Tab {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
@@ -203,7 +203,7 @@ impl Tab {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
None,
)
}
@@ -226,7 +226,7 @@ impl Tab {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- "",
+ crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
Some(SplitCommand::Shell { command, extra_env }),
)
}
@@ -248,7 +248,7 @@ impl Tab {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
- "",
+ crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin),
Some(SplitCommand::Argv { argv }),
)
}
@@ -261,7 +261,7 @@ impl Tab {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- default_shell: &str,
+ shell_config: crate::pane::PaneShellConfig<'_>,
command: Option<SplitCommand<'_>>,
) -> std::io::Result<NewPane> {
let previous_focus = self.layout.focused();
@@ -308,7 +308,7 @@ impl Tab {
actual_cwd.clone(),
scrollback_limit_bytes,
host_terminal_theme,
- default_shell,
+ shell_config,
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),