aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 00:29:53 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 00:34:27 +0300
commit2b97799b6715fd6c355db9c225029765971915e6 (patch)
treecb7c2c7093f439ad3460687b88c3e6cf539b64e8 /src
parentff8e5a14abe445a61dc200fd30a70870c75d183c (diff)
feat: add configurable default shell
refs #196
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs16
-rw-r--r--src/app/creation.rs2
-rw-r--r--src/app/input/mod.rs1
-rw-r--r--src/app/input/navigate.rs1
-rw-r--r--src/app/mod.rs9
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config/io.rs8
-rw-r--r--src/config/model.rs21
-rw-r--r--src/main.rs5
-rw-r--r--src/pane.rs41
-rw-r--r--src/persist/restore.rs6
-rw-r--r--src/workspace.rs16
-rw-r--r--src/workspace/tab.rs11
13 files changed, 133 insertions, 6 deletions
diff --git a/src/app/api.rs b/src/app/api.rs
index ab26f82b..6eb95f03 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -637,6 +637,9 @@ impl App {
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| std::path::PathBuf::from("/"));
let (rows, cols) = self.state.estimate_pane_size();
+ let default_shell = self.state.default_shell.clone();
+ let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
+ let host_terminal_theme = self.state.host_terminal_theme;
let result = self
.state
.workspaces
@@ -647,8 +650,9 @@ impl App {
rows,
cols,
cwd,
- self.state.pane_scrollback_limit_bytes,
- self.state.host_terminal_theme,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ &default_shell,
)
});
match result {
@@ -1019,6 +1023,9 @@ impl App {
)
})
});
+ let default_shell = self.state.default_shell.clone();
+ let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
+ let host_terminal_theme = self.state.host_terminal_theme;
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return serde_json::to_string(&ErrorResponse {
id: request.id,
@@ -1043,8 +1050,9 @@ impl App {
rows,
cols,
split_cwd,
- self.state.pane_scrollback_limit_bytes,
- self.state.host_terminal_theme,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ &default_shell,
params.focus,
) {
Some(Ok(result)) => result,
diff --git a/src/app/creation.rs b/src/app/creation.rs
index f1638e15..f3d8644e 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -87,6 +87,7 @@ impl App {
initial_cwd,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
+ &self.state.default_shell,
)?;
self.state
.terminal_runtimes
@@ -118,6 +119,7 @@ impl App {
cols,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
+ &self.state.default_shell,
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 ad68f9da..4cafc926 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -259,6 +259,7 @@ impl AppState {
cwd,
self.pane_scrollback_limit_bytes,
self.host_terminal_theme,
+ &self.default_shell,
) {
let new_id = new_pane.pane_id;
self.terminal_runtimes
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 34d0a342..a2967ca3 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -1092,6 +1092,7 @@ mod tests {
80,
app.state.pane_scrollback_limit_bytes,
app.state.host_terminal_theme,
+ &app.state.default_shell,
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 3e8a8b83..ca053ba1 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -234,6 +234,7 @@ impl App {
24,
80,
config.advanced.scrollback_limit_bytes,
+ &config.terminal.default_shell,
event_tx.clone(),
render_notify.clone(),
render_dirty.clone(),
@@ -412,6 +413,7 @@ impl App {
prompt_new_tab_name: config.ui.prompt_new_tab_name,
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
kitty_graphics_enabled: config.experimental.kitty_graphics,
+ default_shell: config.terminal.default_shell.clone(),
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
accent: crate::config::parse_color(&config.ui.accent),
sound: config.ui.sound.clone(),
@@ -891,6 +893,10 @@ impl App {
self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
}
+ if !invalid_section("terminal") {
+ self.state.default_shell = config.terminal.default_shell.clone();
+ }
+
if !invalid_section("theme") {
self.state.palette = resolve_palette_with_legacy_accent(config, !invalid_section("ui"));
self.state.theme_name = config
@@ -1321,7 +1327,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
+ "[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1344,6 +1350,7 @@ mod tests {
app.state.agent_panel_scope,
state::AgentPanelScope::CurrentWorkspace
);
+ assert_eq!(app.state.default_shell, "nu");
assert!(app.state.config_diagnostic.is_none());
let toast = app.state.toast.as_ref().unwrap();
assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled);
diff --git a/src/app/state.rs b/src/app/state.rs
index 746474df..0c38171d 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -965,6 +965,7 @@ pub struct AppState {
pub prompt_new_tab_name: bool,
pub show_agent_labels_on_pane_borders: bool,
pub kitty_graphics_enabled: bool,
+ pub default_shell: String,
pub pane_scrollback_limit_bytes: usize,
#[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth
pub accent: Color,
@@ -1211,6 +1212,7 @@ impl AppState {
prompt_new_tab_name: true,
show_agent_labels_on_pane_borders: false,
kitty_graphics_enabled: false,
+ default_shell: String::new(),
pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES,
accent: Color::Cyan,
sound: SoundConfig {
diff --git a/src/config/io.rs b/src/config/io.rs
index 268a1e8b..07b63d37 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -162,6 +162,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
);
load_live_section(
table,
+ "terminal",
+ "terminal config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.terminal = section,
+ );
+ load_live_section(
+ table,
"ui",
"ui config",
&mut diagnostics,
diff --git a/src/config/model.rs b/src/config/model.rs
index 673eec02..ff3a7b22 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -34,6 +34,13 @@ pub struct ToastConfig {
pub delivery: ToastDelivery,
}
+#[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,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigReloadStatus {
@@ -66,6 +73,7 @@ pub fn validated_sidebar_bounds(min: u16, max: u16) -> Option<(u16, u16)> {
pub struct Config {
pub onboarding: Option<bool>,
pub theme: ThemeConfig,
+ pub terminal: TerminalConfig,
pub keys: KeysConfig,
pub ui: UiConfig,
pub advanced: AdvancedConfig,
@@ -298,6 +306,19 @@ mod tests {
use super::*;
#[test]
+ fn terminal_default_shell_defaults_empty_and_parses() {
+ let default_config = Config::default();
+ assert!(default_config.terminal.default_shell.is_empty());
+
+ let toml = r#"
+[terminal]
+default_shell = "nu"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.terminal.default_shell, "nu");
+ }
+
+ #[test]
fn agent_panel_scope_config_parses() {
let toml = r#"
[ui]
diff --git a/src/main.rs b/src/main.rs
index c8811328..0814c09e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -75,6 +75,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# red = "#ff6188"
# green = "#a6e3a1"
+[terminal]
+# Executable used for new interactive panes.
+# Empty means $SHELL, then /bin/sh.
+# default_shell = ""
+
[keys]
# Prefix key to enter navigate mode (default: "ctrl+b")
# Examples: "ctrl+b", "f12", "esc", "-"
diff --git a/src/pane.rs b/src/pane.rs
index aba31943..10112f0f 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -250,6 +250,22 @@ fn shutdown_pane_processes(pane_id: PaneId, child_pid: u32) {
);
}
+fn pane_shell(configured_shell: &str) -> String {
+ pane_shell_from(configured_shell, std::env::var("SHELL").ok())
+}
+
+fn pane_shell_from(configured_shell: &str, env_shell: Option<String>) -> String {
+ let configured_shell = configured_shell.trim();
+ if !configured_shell.is_empty() {
+ return configured_shell.to_string();
+ }
+
+ env_shell
+ .map(|shell| shell.trim().to_string())
+ .filter(|shell| !shell.is_empty())
+ .unwrap_or_else(|| "/bin/sh".into())
+}
+
impl PaneRuntime {
pub fn shutdown(self) {
self.detect_handle.abort();
@@ -267,11 +283,12 @@ impl PaneRuntime {
cwd: std::path::PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
- let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
+ let shell = pane_shell(default_shell);
let mut cmd = CommandBuilder::new(&shell);
cmd.cwd(cwd);
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
@@ -1100,6 +1117,28 @@ mod tests {
}
#[test]
+ fn pane_shell_prefers_configured_shell() {
+ assert_eq!(
+ pane_shell_from("/usr/bin/nu", Some("/bin/bash".to_string())),
+ "/usr/bin/nu"
+ );
+ }
+
+ #[test]
+ fn pane_shell_falls_back_to_shell_env() {
+ assert_eq!(
+ pane_shell_from("", Some("/bin/bash".to_string())),
+ "/bin/bash"
+ );
+ }
+
+ #[test]
+ fn pane_shell_ignores_empty_values() {
+ assert_eq!(pane_shell_from(" ", Some(" ".to_string())), "/bin/sh");
+ assert_eq!(pane_shell_from("", None), "/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 0ac49e80..f558761d 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -21,6 +21,7 @@ pub fn restore(
rows: u16,
cols: u16,
scrollback_limit_bytes: usize,
+ default_shell: &str,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -38,6 +39,7 @@ pub fn restore(
rows,
cols,
scrollback_limit_bytes,
+ default_shell,
events.clone(),
render_notify.clone(),
render_dirty.clone(),
@@ -57,6 +59,7 @@ fn restore_workspace(
rows: u16,
cols: u16,
scrollback_limit_bytes: usize,
+ default_shell: &str,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -78,6 +81,7 @@ fn restore_workspace(
rows,
cols,
scrollback_limit_bytes,
+ default_shell,
events.clone(),
render_notify.clone(),
render_dirty.clone(),
@@ -123,6 +127,7 @@ fn restore_tab(
rows: u16,
cols: u16,
scrollback_limit_bytes: usize,
+ default_shell: &str,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -181,6 +186,7 @@ fn restore_tab(
cwd.clone(),
scrollback_limit_bytes,
crate::terminal_theme::TerminalTheme::default(),
+ default_shell,
events.clone(),
render_notify.clone(),
render_dirty.clone(),
diff --git a/src/workspace.rs b/src/workspace.rs
index 69a83d75..47fa9cf7 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -89,6 +89,7 @@ impl Workspace {
cols: u16,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -99,6 +100,7 @@ impl Workspace {
cols,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
events,
render_notify,
render_dirty,
@@ -123,6 +125,7 @@ impl Workspace {
cols,
scrollback_limit_bytes,
host_terminal_theme,
+ "",
events,
render_notify,
render_dirty,
@@ -137,6 +140,7 @@ impl Workspace {
cols: u16,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -163,6 +167,7 @@ impl Workspace {
cols,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
events,
render_notify,
render_dirty,
@@ -223,6 +228,7 @@ impl Workspace {
cwd: PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
self.create_tab_with_runtime(
rows,
@@ -230,6 +236,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
None,
)
}
@@ -241,6 +248,7 @@ impl Workspace {
cwd: PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
argv: Option<&[String]>,
) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
let number = self.tabs.len() + 1;
@@ -278,6 +286,7 @@ impl Workspace {
cols,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
events,
render_notify,
render_dirty,
@@ -343,6 +352,7 @@ impl Workspace {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
) -> std::io::Result<crate::workspace::tab::NewPane> {
let new_pane = self
.active_tab_mut()
@@ -354,6 +364,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
)?;
self.register_new_pane(new_pane.pane_id);
Ok(new_pane)
@@ -368,6 +379,7 @@ impl Workspace {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
focus_new_pane: bool,
) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
self.split_pane_with_runtime(
@@ -378,6 +390,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
focus_new_pane,
None,
)
@@ -404,6 +417,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
+ "",
focus_new_pane,
Some(argv),
)
@@ -419,6 +433,7 @@ impl Workspace {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
focus_new_pane: bool,
argv: Option<&[String]>,
) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
@@ -444,6 +459,7 @@ impl Workspace {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
)
} {
Ok(new_pane) => new_pane,
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index 943a45a0..3616b95c 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -53,6 +53,7 @@ impl Tab {
cols: u16,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -64,6 +65,7 @@ impl Tab {
cols,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
events,
render_notify,
render_dirty,
@@ -90,6 +92,7 @@ impl Tab {
cols,
scrollback_limit_bytes,
host_terminal_theme,
+ "",
events,
render_notify,
render_dirty,
@@ -105,6 +108,7 @@ impl Tab {
cols: u16,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -132,6 +136,7 @@ impl Tab {
initial_cwd.clone(),
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
events.clone(),
render_notify.clone(),
render_dirty.clone(),
@@ -189,6 +194,7 @@ impl Tab {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
@@ -197,6 +203,7 @@ impl Tab {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
None,
)
}
@@ -219,6 +226,7 @@ impl Tab {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
+ "",
Some(SplitCommand::Shell { command, extra_env }),
)
}
@@ -240,6 +248,7 @@ impl Tab {
cwd,
scrollback_limit_bytes,
host_terminal_theme,
+ "",
Some(SplitCommand::Argv { argv }),
)
}
@@ -252,6 +261,7 @@ impl Tab {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
command: Option<SplitCommand<'_>>,
) -> std::io::Result<NewPane> {
let previous_focus = self.layout.focused();
@@ -298,6 +308,7 @@ impl Tab {
actual_cwd.clone(),
scrollback_limit_bytes,
host_terminal_theme,
+ default_shell,
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),