aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 00:32:46 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 00:32:46 +0300
commit3de6ea799965a7d2aad0f7db2f23f54cd24cb039 (patch)
treec9233a7f3d432aed7761c9db9c0072fee4ff5de2
parent0fec1199671becba511e03d98a4921e375a46fe3 (diff)
feat: configure new terminal cwd
-rw-r--r--docs/next/CHANGELOG.md3
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx11
-rw-r--r--src/app/api/panes.rs5
-rw-r--r--src/app/api/tabs.rs16
-rw-r--r--src/app/api/workspaces.rs11
-rw-r--r--src/app/creation.rs47
-rw-r--r--src/app/input/mod.rs6
-rw-r--r--src/app/mod.rs28
-rw-r--r--src/app/state.rs4
-rw-r--r--src/config.rs2
-rw-r--r--src/config/model.rs56
-rw-r--r--src/main.rs5
12 files changed, 161 insertions, 33 deletions
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>,
+) -> 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<std::path::PathBuf> {
+ pub(super) fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<PathBuf> {
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>) -> PathBuf {
+ resolve_new_terminal_cwd(&self.state.new_terminal_cwd, follow_cwd)
+ }
+
pub(super) fn workspace_creation_source(&self) -> Option<usize> {
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<usize> {
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<usize> {
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);
@@ -2055,6 +2061,26 @@ mod tests {
}
#[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<D>(deserializer: D) -> Result<Self, D::Error>
+ 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)]
@@ -434,6 +460,36 @@ 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#"
[ui]
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", "-"