feat: configure new terminal cwd

This commit is contained in:
Ogulcan Celik 2026-05-23 00:32:46 +03:00
parent 0fec119967
commit 3de6ea7999
12 changed files with 161 additions and 33 deletions

View File

@ -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

View File

@ -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:

View File

@ -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;

View File

@ -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;

View File

@ -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 {

View File

@ -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();

View File

@ -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(

View File

@ -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();

View File

@ -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 {

View File

@ -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},

View File

@ -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)]
@ -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#"

View File

@ -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", "-"