diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 00:29:53 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 00:34:27 +0300 |
| commit | 2b97799b6715fd6c355db9c225029765971915e6 (patch) | |
| tree | cb7c2c7093f439ad3460687b88c3e6cf539b64e8 /src | |
| parent | ff8e5a14abe445a61dc200fd30a70870c75d183c (diff) | |
feat: add configurable default shell
refs #196
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api.rs | 16 | ||||
| -rw-r--r-- | src/app/creation.rs | 2 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 9 | ||||
| -rw-r--r-- | src/app/state.rs | 2 | ||||
| -rw-r--r-- | src/config/io.rs | 8 | ||||
| -rw-r--r-- | src/config/model.rs | 21 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/pane.rs | 41 | ||||
| -rw-r--r-- | src/persist/restore.rs | 6 | ||||
| -rw-r--r-- | src/workspace.rs | 16 | ||||
| -rw-r--r-- | src/workspace/tab.rs | 11 |
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(), |
