diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 2f336838..bba8a29d 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Added `terminal.new_cwd` to choose whether new panes, tabs, and workspaces follow the source pane/workspace, start in `$HOME`, use Herdr's process directory, or use a fixed path. + ## [0.6.1] - 2026-05-22 ### Added diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 72dc610c..9cea753e 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -45,7 +45,7 @@ You can also open the global menu in Herdr and choose `reload config`. Reload applies most UI settings without restarting panes. Startup-only settings still need a restart. -## Default shell +## Terminal defaults Set the executable Herdr uses for newly created interactive panes: @@ -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 the working directory policy for new panes, tabs, and workspaces: + +```toml +[terminal] +new_cwd = "follow" +``` + +`new_cwd = "follow"` keeps the default behavior and inherits the source pane or workspace. Use `"home"` to always start in `$HOME`, `"current"` to use Herdr's process directory, or a fixed path such as `"~/Projects"`. Explicit `--cwd` values from the CLI or socket API still take precedence. + ## Worktrees Set the root directory Herdr uses for Git worktree checkouts created from the sidebar: diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 2b1741d9..e0df6845 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -21,14 +21,15 @@ impl App { }; let (rows, cols) = self.state.estimate_pane_size(); let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { - self.state.workspaces.get(ws_idx).and_then(|ws| { + let follow_cwd = self.state.workspaces.get(ws_idx).and_then(|ws| { let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; ws.tabs.get(tab_idx)?.cwd_for_pane( target_pane_id, &self.state.terminals, &self.terminal_runtimes, ) - }) + }); + Some(self.resolve_new_terminal_cwd(follow_cwd)) }); let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 7978a17f..06d59fe3 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -63,15 +63,13 @@ impl App { } else { return encode_error(id, "workspace_not_found", "no active workspace"); }; - let cwd = cwd - .map(PathBuf::from) - .or_else(|| { - self.state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - .and_then(|rt| rt.cwd()) - }) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from("/")); + let cwd = cwd.map(PathBuf::from).unwrap_or_else(|| { + let follow_cwd = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + .and_then(|rt| rt.cwd()); + self.resolve_new_terminal_cwd(follow_cwd) + }); 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; diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index f5e9a563..92b41486 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -45,11 +45,12 @@ impl App { id: String, params: WorkspaceCreateParams, ) -> String { - let cwd = params - .cwd - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from("/")); + let cwd = params.cwd.map(PathBuf::from).unwrap_or_else(|| { + let follow_cwd = self + .workspace_creation_source() + .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)); + self.resolve_new_terminal_cwd(follow_cwd) + }); match self.create_workspace_with_options(cwd, params.focus) { Ok(index) => { if let Some(label) = params.label { diff --git a/src/app/creation.rs b/src/app/creation.rs index f6166a85..2bfe389b 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -1,19 +1,44 @@ +use std::path::PathBuf; + use tracing::error; use super::{ api_helpers::{pane_agent_status, tab_attention_priority}, App, Mode, }; -use crate::workspace::Workspace; +use crate::{config::NewTerminalCwdConfig, workspace::Workspace}; + +pub(crate) fn resolve_new_terminal_cwd( + policy: &NewTerminalCwdConfig, + follow_cwd: Option, +) -> PathBuf { + match policy { + NewTerminalCwdConfig::Follow => follow_cwd + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from("/")), + NewTerminalCwdConfig::Home => std::env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from("/")), + NewTerminalCwdConfig::Current => { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")) + } + NewTerminalCwdConfig::Path(path) => crate::worktree::expand_tilde_path(path), + } +} impl App { - pub(super) fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option { + pub(super) fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option { self.state .workspaces .get(ws_idx)? .resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) } + pub(super) fn resolve_new_terminal_cwd(&self, follow_cwd: Option) -> PathBuf { + resolve_new_terminal_cwd(&self.state.new_terminal_cwd, follow_cwd) + } + pub(super) fn workspace_creation_source(&self) -> Option { if self.state.mode == Mode::Navigate && self.state.workspaces.get(self.state.selected).is_some() @@ -31,11 +56,10 @@ impl App { /// Create a workspace with a real PTY (needs event_tx). pub(crate) fn create_workspace(&mut self) { - let initial_cwd = self + let follow_cwd = self .workspace_creation_source() - .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); + .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)); + let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); if let Err(e) = self.create_workspace_with_options(initial_cwd, true) { error!(err = %e, "failed to create workspace"); self.state.mode = Mode::Navigate; @@ -44,12 +68,11 @@ impl App { pub(crate) fn create_tab(&mut self) { let custom_name = self.state.requested_new_tab_name.take(); - let initial_cwd = self + let follow_cwd = self .state .active - .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); + .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)); + let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); match self.create_tab_with_options(initial_cwd, true) { Ok(tab_idx) => { if let Some(name) = custom_name { @@ -73,7 +96,7 @@ impl App { pub(super) fn create_tab_with_options( &mut self, - initial_cwd: std::path::PathBuf, + initial_cwd: PathBuf, focus: bool, ) -> std::io::Result { let Some(ws_idx) = self.state.active else { @@ -107,7 +130,7 @@ impl App { pub(crate) fn create_workspace_with_options( &mut self, - initial_cwd: std::path::PathBuf, + initial_cwd: PathBuf, focus: bool, ) -> std::io::Result { let (rows, cols) = self.state.estimate_pane_size(); diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index e357b33d..ef8b93f3 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -256,13 +256,17 @@ impl AppState { let new_rows = (rows / 2).max(4); let new_cols = (cols / 2).max(10); - let cwd = self + let follow_cwd = self .active .and_then(|i| self.workspaces.get(i)) .and_then(|ws| { let tab = ws.active_tab()?; tab.cwd_for_pane(tab.layout.focused(), &self.terminals, terminal_runtimes) }); + let cwd = Some(super::creation::resolve_new_terminal_cwd( + &self.new_terminal_cwd, + follow_cwd, + )); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if let Ok(new_pane) = ws.split_focused( diff --git a/src/app/mod.rs b/src/app/mod.rs index 1eeba25b..4bcbada7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -454,6 +454,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(), + 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), sound: config.ui.sound.clone(), @@ -1004,6 +1005,7 @@ impl App { if !invalid_section("terminal") { self.state.default_shell = config.terminal.default_shell.clone(); + self.state.new_terminal_cwd = config.terminal.new_cwd.clone(); } if !invalid_section("worktrees") { @@ -1461,7 +1463,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"prefix+g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n", + "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+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); @@ -1486,6 +1488,10 @@ mod tests { state::AgentPanelScope::CurrentWorkspace ); assert_eq!(app.state.default_shell, "nu"); + assert_eq!( + app.state.new_terminal_cwd, + crate::config::NewTerminalCwdConfig::Home + ); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); @@ -2054,6 +2060,26 @@ mod tests { assert_eq!(seed_cwd, std::path::PathBuf::from("/tmp/pion")); } + #[test] + fn new_terminal_cwd_follow_uses_source_cwd() { + let cwd = creation::resolve_new_terminal_cwd( + &crate::config::NewTerminalCwdConfig::Follow, + Some(std::path::PathBuf::from("/tmp/herdr-source")), + ); + + assert_eq!(cwd, std::path::PathBuf::from("/tmp/herdr-source")); + } + + #[test] + fn new_terminal_cwd_path_uses_configured_path() { + let cwd = creation::resolve_new_terminal_cwd( + &crate::config::NewTerminalCwdConfig::Path("/tmp/herdr-fixed".into()), + Some(std::path::PathBuf::from("/tmp/herdr-source")), + ); + + assert_eq!(cwd, std::path::PathBuf::from("/tmp/herdr-fixed")); + } + #[test] fn server_stop_request_sets_should_quit_flag() { let mut app = test_app(); diff --git a/src/app/state.rs b/src/app/state.rs index ff039c14..5d4fb67d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,4 +1,4 @@ -use crate::config::{Keybinds, SoundConfig, ToastConfig, ToastDelivery}; +use crate::config::{Keybinds, NewTerminalCwdConfig, SoundConfig, ToastConfig, ToastDelivery}; use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use ratatui::style::Color; @@ -1075,6 +1075,7 @@ pub struct AppState { pub cjk_ime_cursor_shape: u8, pub kitty_graphics_enabled: bool, pub default_shell: String, + 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 pub accent: Color, @@ -1362,6 +1363,7 @@ impl AppState { cjk_ime_cursor_shape: 2, // steady_block kitty_graphics_enabled: false, default_shell: String::new(), + new_terminal_cwd: NewTerminalCwdConfig::Follow, pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, accent: Color::Cyan, sound: SoundConfig { diff --git a/src/config.rs b/src/config.rs index 4eb29af0..33558b4b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,7 +19,7 @@ pub use self::{ }, model::{ validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport, - ConfigReloadStatus, KeysConfig, ToastConfig, ToastDelivery, + ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ToastConfig, ToastDelivery, }, sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, diff --git a/src/config/model.rs b/src/config/model.rs index 215e6f32..68a55b0f 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -39,11 +39,37 @@ pub struct ToastConfig { pub delivery: ToastDelivery, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum NewTerminalCwdConfig { + #[default] + Follow, + Home, + Current, + Path(String), +} + +impl<'de> Deserialize<'de> for NewTerminalCwdConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + match value.trim() { + "" | "follow" => Ok(Self::Follow), + "home" => Ok(Self::Home), + "current" => Ok(Self::Current), + _ => Ok(Self::Path(value)), + } + } +} + #[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, + /// CWD policy for new interactive panes, tabs, and workspaces. + pub new_cwd: NewTerminalCwdConfig, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] @@ -433,6 +459,36 @@ default_shell = "nu" assert_eq!(config.terminal.default_shell, "nu"); } + #[test] + fn terminal_new_cwd_defaults_follow_and_parses() { + let default_config = Config::default(); + assert_eq!( + default_config.terminal.new_cwd, + NewTerminalCwdConfig::Follow + ); + + let config: Config = toml::from_str( + r#" +[terminal] +new_cwd = "home" +"#, + ) + .unwrap(); + assert_eq!(config.terminal.new_cwd, NewTerminalCwdConfig::Home); + + let config: Config = toml::from_str( + r#" +[terminal] +new_cwd = "~/Projects" +"#, + ) + .unwrap(); + assert_eq!( + config.terminal.new_cwd, + NewTerminalCwdConfig::Path("~/Projects".into()) + ); + } + #[test] fn agent_panel_scope_config_parses() { let toml = r#" diff --git a/src/main.rs b/src/main.rs index a846d092..cbe584ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Empty means $SHELL, then /bin/sh. # default_shell = "" +# 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". +# new_cwd = "follow" + [keys] # Prefix key to enter prefix mode (default: "ctrl+b") # Examples: "ctrl+b", "f12", "esc", "-"