diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 15:44:31 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 15:44:31 +0300 |
| commit | ebd800755832784e1e48e250bf1db11a8b286209 (patch) | |
| tree | c15022e702ae242b744d182a3c94e71007f275a9 | |
| parent | 7380e0ed16470c570984485930048a80034ba381 (diff) | |
add config diagnostics and split configuration docs
| -rw-r--r-- | CONFIGURATION.md | 131 | ||||
| -rw-r--r-- | README.md | 81 | ||||
| -rw-r--r-- | src/app/mod.rs | 18 | ||||
| -rw-r--r-- | src/app/state.rs | 4 | ||||
| -rw-r--r-- | src/config.rs | 247 | ||||
| -rw-r--r-- | src/main.rs | 16 | ||||
| -rw-r--r-- | src/ui.rs | 28 |
7 files changed, 382 insertions, 143 deletions
diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 00000000..f78ec7ca --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,131 @@ +# configuration + +herdr reads config from: + +```text +~/.config/herdr/config.toml +``` + +print the full default config with: + +```bash +herdr --default-config +``` + +if a config value is invalid, or two navigate actions use the same keybinding, herdr falls back to a safe default and shows a startup warning in the UI. + +## keybindings + +keybindings live under `[keys]`. + +supported syntax: +- plain keys: `n`, `x`, `-`, `` ` `` +- modifiers: `ctrl+b`, `shift+n`, `alt+x` +- special keys: `enter`, `esc`, `tab`, `backspace` +- function keys: `f1`, `f12` +- uppercase letters also imply shift: `D` works like `shift+d` + +notes: +- most reliable bindings are plain keys, `ctrl+letter`, `esc`/`tab`/`enter`, and function keys +- `alt+...` and punctuation-with-modifiers may vary depending on terminal/tmux setup +- for navigate-mode actions, duplicate keybindings are treated as config errors; later conflicting bindings fall back to defaults + +example: + +```toml +[keys] +prefix = "ctrl+b" +new_workspace = "n" +rename_workspace = "shift+n" +close_workspace = "d" +split_vertical = "v" +split_horizontal = "-" +close_pane = "x" +fullscreen = "f" +resize_mode = "r" +toggle_sidebar = "b" +``` + +### key reference + +| key | default | action | +|-----|---------|--------| +| `prefix` | `ctrl+b` | enter or leave navigate mode | +| `new_workspace` | `n` | create a new workspace | +| `rename_workspace` | `shift+n` | rename selected workspace | +| `close_workspace` | `d` | close selected workspace | +| `split_vertical` | `v` | split pane vertically (side by side) | +| `split_horizontal` | `-` | split pane horizontally (stacked) | +| `close_pane` | `x` | close focused pane | +| `fullscreen` | `f` | toggle focused pane fullscreen | +| `resize_mode` | `r` | enter or leave resize mode | +| `toggle_sidebar` | `b` | collapse or expand the sidebar | + +## ui + +```toml +[ui] +sidebar_width = 26 +confirm_close = true +accent = "cyan" +``` + +### options + +| option | default | description | +|--------|---------|-------------| +| `sidebar_width` | `26` | base sidebar width before auto-scaling | +| `confirm_close` | `true` | ask before closing a workspace | +| `accent` | `cyan` | highlight and border color | + +`accent` accepts: +- named colors like `cyan`, `blue`, `magenta` +- hex like `#89b4fa` +- rgb like `rgb(137,180,250)` + +## sound + +```toml +[ui.sound] +enabled = true + +[ui.sound.agents] +claude = "default" +droid = "off" +``` + +### options + +| option | default | description | +|--------|---------|-------------| +| `ui.sound.enabled` | `true` | enable background agent sounds | + +per-agent values: +- `default` +- `on` +- `off` + +available agent keys: +- `pi` +- `claude` +- `codex` +- `gemini` +- `cursor` +- `cline` +- `open_code` +- `github_copilot` +- `kimi` +- `droid` +- `amp` + +## environment variables + +| variable | description | +|----------|-------------| +| `HERDR_LOG` | log level filter (default: `herdr=info`) | + +logs are written to: + +```text +~/.config/herdr/herdr.log +``` @@ -7,7 +7,7 @@ <p align="center">herd your agents.</p> <p align="center"> - <a href="https://herdr.dev">herdr.dev</a> · <a href="#install">install</a> · <a href="#usage">usage</a> + <a href="https://herdr.dev">herdr.dev</a> · <a href="#install">install</a> · <a href="#usage">usage</a> · <a href="./CONFIGURATION.md">configuration</a> </p> --- @@ -96,27 +96,19 @@ press `ctrl+b` (the prefix key) to switch back to navigate mode. from there you ### navigate mode (prefix: ctrl+b) -| key | action | configurable | -|-----|--------|:---:| -| `n` | new workspace | ✓ | -| `N` | rename workspace | ✓ | -| `d` | close workspace | ✓ | -| `1`-`9` | switch to workspace by number | | -| `↑` `↓` | select workspace | | -| `enter` | open selected workspace | | -| `v` | split pane vertically | ✓ | -| `-` | split pane horizontally | ✓ | -| `h` `j` `k` `l` | navigate between panes | | -| `tab` | cycle panes | | -| `f` | toggle fullscreen | ✓ | -| `x` | close pane | ✓ | -| `r` | enter resize mode | ✓ | -| `b` | toggle sidebar collapse | ✓ | -| `q` | quit | | - -keys marked ✓ can be changed in `~/.config/herdr/config.toml` under `[keys]`. the prefix key is also configurable. - -keybindings use strings like `ctrl+b`, `f12`, `esc`, `tab`, `-`, or `shift+x`. the most reliable bindings are plain keys, `ctrl+letter`, `esc`/`tab`/`enter`, and function keys. `alt+...` and punctuation-with-modifiers may vary depending on your terminal and tmux setup. +navigate mode is the workspace control layer. movement actions stay in navigate mode; mutating actions like split, close, new workspace, and sidebar toggle return you to terminal mode. + +common defaults: +- `n` new workspace +- `shift+n` rename workspace +- `d` close workspace +- `v` / `-` split pane +- `x` close pane +- `f` fullscreen +- `r` resize mode +- `b` toggle sidebar + +full keybinding and config reference: [`CONFIGURATION.md`](./CONFIGURATION.md) ### resize mode @@ -146,54 +138,13 @@ you're in a real terminal. everything works — your shell, vim, htop, ssh, anyt config file: `~/.config/herdr/config.toml` -generate the default config with all options: +print the full default config with: ```bash herdr --default-config ``` -```toml -[keys] -# prefix key to enter navigate mode -prefix = "ctrl+b" - -# navigate-mode actions -new_workspace = "n" -rename_workspace = "shift+n" -close_workspace = "d" -split_vertical = "v" -split_horizontal = "-" -close_pane = "x" -fullscreen = "f" -resize_mode = "r" -toggle_sidebar = "b" - -[ui] -# accent color: hex (#89b4fa), named (cyan, blue), or rgb(r,g,b) -accent = "cyan" - -# ask for confirmation before closing a workspace -confirm_close = true - -[ui.sound] -# play sounds when agents change state in background workspaces -# a chime when an agent finishes, an alert when one needs input -enabled = true - -[ui.sound.agents] -# per-agent override: default | on | off -# droid is muted by default -claude = "default" -droid = "off" -``` - -### environment variables - -| variable | description | -|----------|-------------| -| `HERDR_LOG` | log level filter (default: `herdr=info`) | - -logs: `~/.config/herdr/herdr.log` +for all keybindings, UI options, sound settings, and environment variables, see [`CONFIGURATION.md`](./CONFIGURATION.md). ## session persistence diff --git a/src/app/mod.rs b/src/app/mod.rs index ad704460..d02f68d6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,7 +9,7 @@ mod input; pub mod state; use std::io; -use std::time::Duration; +use std::time::{Duration, Instant}; use crossterm::event::{self, Event, KeyEventKind}; use ratatui::layout::Rect; @@ -29,10 +29,11 @@ pub struct App { pub event_tx: mpsc::Sender<AppEvent>, event_rx: mpsc::Receiver<AppEvent>, no_session: bool, + config_diagnostic_deadline: Option<Instant>, } impl App { - pub fn new(config: &Config, no_session: bool) -> Self { + pub fn new(config: &Config, no_session: bool, config_diagnostic: Option<String>) -> Self { let (prefix_code, prefix_mods) = config.prefix_key(); let (event_tx, event_rx) = mpsc::channel::<AppEvent>(64); @@ -79,6 +80,7 @@ impl App { context_menu: None, update_available: None, update_dismissed: false, + config_diagnostic, prefix_code, prefix_mods, sidebar_width: config.ui.sidebar_width, @@ -96,6 +98,10 @@ impl App { } Self { + config_diagnostic_deadline: state + .config_diagnostic + .as_ref() + .map(|_| Instant::now() + Duration::from_secs(8)), state, event_tx, event_rx, @@ -105,6 +111,14 @@ impl App { pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { while !self.state.should_quit { + if self + .config_diagnostic_deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + self.config_diagnostic_deadline = None; + self.state.config_diagnostic = None; + } + terminal.draw(|frame| { crate::ui::compute_view(&mut self.state, frame.area()); crate::ui::render(&self.state, frame); diff --git a/src/app/state.rs b/src/app/state.rs index 140a1513..8ee5e67a 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -58,9 +58,10 @@ pub struct AppState { pub(crate) drag: Option<DragState>, pub selection: Option<Selection>, pub context_menu: Option<ContextMenuState>, - // Update notification + // Notifications pub update_available: Option<String>, pub update_dismissed: bool, + pub config_diagnostic: Option<String>, // Config pub prefix_code: KeyCode, pub prefix_mods: KeyModifiers, @@ -132,6 +133,7 @@ impl AppState { context_menu: None, update_available: None, update_dismissed: false, + config_diagnostic: None, prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, sidebar_width: 26, diff --git a/src/config.rs b/src/config.rs index 754ede0a..e4899248 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,12 @@ pub struct Config { pub ui: UiConfig, } +#[derive(Debug)] +pub struct LoadedConfig { + pub config: Config, + pub diagnostics: Vec<String>, +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct KeysConfig { @@ -168,86 +174,146 @@ impl Default for AgentSoundOverrides { } impl Config { - pub fn load() -> Self { + pub fn load() -> LoadedConfig { let path = config_path(); if path.exists() { match std::fs::read_to_string(&path) { - Ok(content) => match toml::from_str(&content) { - Ok(config) => return config, - Err(e) => warn!(err = %e, "config parse error, using defaults"), + Ok(content) => match toml::from_str::<Config>(&content) { + Ok(config) => { + let diagnostics = config.collect_diagnostics(); + return LoadedConfig { config, diagnostics }; + } + Err(e) => { + warn!(err = %e, "config parse error, using defaults"); + return LoadedConfig { + config: Self::default(), + diagnostics: vec![format!("config parse error: {e}; using defaults")], + }; + } }, - Err(e) => warn!(err = %e, "config read error, using defaults"), + Err(e) => { + warn!(err = %e, "config read error, using defaults"); + return LoadedConfig { + config: Self::default(), + diagnostics: vec![format!("config read error: {e}; using defaults")], + }; + } } } - Self::default() + LoadedConfig { + config: Self::default(), + diagnostics: Vec::new(), + } } pub fn prefix_key(&self) -> (KeyCode, KeyModifiers) { - parse_key_combo_or_warn( - &self.keys.prefix, - "keys.prefix", - (KeyCode::Char('b'), KeyModifiers::CONTROL), - ) + self.validated_keybinds().1 } /// Parsed keybinds for navigate mode actions. pub fn keybinds(&self) -> Keybinds { - Keybinds { - new_workspace: parse_key_combo_or_warn( - &self.keys.new_workspace, - "keys.new_workspace", - (KeyCode::Char('n'), KeyModifiers::empty()), - ), - new_workspace_label: self.keys.new_workspace.clone(), - rename_workspace: parse_key_combo_or_warn( - &self.keys.rename_workspace, - "keys.rename_workspace", - (KeyCode::Char('n'), KeyModifiers::SHIFT), - ), - rename_workspace_label: self.keys.rename_workspace.clone(), - close_workspace: parse_key_combo_or_warn( - &self.keys.close_workspace, - "keys.close_workspace", - (KeyCode::Char('d'), KeyModifiers::empty()), - ), - close_workspace_label: self.keys.close_workspace.clone(), - split_vertical: parse_key_combo_or_warn( - &self.keys.split_vertical, - "keys.split_vertical", - (KeyCode::Char('v'), KeyModifiers::empty()), - ), - split_vertical_label: self.keys.split_vertical.clone(), - split_horizontal: parse_key_combo_or_warn( - &self.keys.split_horizontal, - "keys.split_horizontal", - (KeyCode::Char('-'), KeyModifiers::empty()), - ), - split_horizontal_label: self.keys.split_horizontal.clone(), - close_pane: parse_key_combo_or_warn( - &self.keys.close_pane, - "keys.close_pane", - (KeyCode::Char('x'), KeyModifiers::empty()), - ), - close_pane_label: self.keys.close_pane.clone(), - fullscreen: parse_key_combo_or_warn( - &self.keys.fullscreen, - "keys.fullscreen", - (KeyCode::Char('f'), KeyModifiers::empty()), - ), - fullscreen_label: self.keys.fullscreen.clone(), - resize_mode: parse_key_combo_or_warn( - &self.keys.resize_mode, - "keys.resize_mode", - (KeyCode::Char('r'), KeyModifiers::empty()), - ), - resize_mode_label: self.keys.resize_mode.clone(), - toggle_sidebar: parse_key_combo_or_warn( - &self.keys.toggle_sidebar, - "keys.toggle_sidebar", - (KeyCode::Char('b'), KeyModifiers::empty()), - ), - toggle_sidebar_label: self.keys.toggle_sidebar.clone(), + self.validated_keybinds().3 + } + + pub fn collect_diagnostics(&self) -> Vec<String> { + let (prefix_diag, _, keybind_diags, _) = self.validated_keybinds(); + prefix_diag.into_iter().chain(keybind_diags).collect() + } + + fn validated_keybinds(&self) -> (Option<String>, (KeyCode, KeyModifiers), Vec<String>, Keybinds) { + #[derive(Clone)] + struct Binding<'a> { + field: &'a str, + label: String, + default_label: &'a str, + value: (KeyCode, KeyModifiers), + default: (KeyCode, KeyModifiers), + } + + let mut diagnostics = Vec::new(); + let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( + &self.keys.prefix, + "keys.prefix", + (KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + if let Some(diag) = &prefix_diag { + warn!(message = %diag, "config diagnostic"); + } + + fn binding<'a>( + field: &'a str, + configured_label: &'a str, + default_label: &'a str, + default: (KeyCode, KeyModifiers), + diagnostics: &mut Vec<String>, + ) -> Binding<'a> { + let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default); + let label = if let Some(diag) = diag { + diagnostics.push(diag); + default_label.to_string() + } else { + configured_label.to_string() + }; + Binding { + field, + label, + default_label, + value, + default, + } + } + + let mut bindings = vec![ + binding("keys.new_workspace", &self.keys.new_workspace, "n", (KeyCode::Char('n'), KeyModifiers::empty()), &mut diagnostics), + binding("keys.rename_workspace", &self.keys.rename_workspace, "shift+n", (KeyCode::Char('n'), KeyModifiers::SHIFT), &mut diagnostics), + binding("keys.close_workspace", &self.keys.close_workspace, "d", (KeyCode::Char('d'), KeyModifiers::empty()), &mut diagnostics), + binding("keys.split_vertical", &self.keys.split_vertical, "v", (KeyCode::Char('v'), KeyModifiers::empty()), &mut diagnostics), + binding("keys.split_horizontal", &self.keys.split_horizontal, "-", (KeyCode::Char('-'), KeyModifiers::empty()), &mut diagnostics), + binding("keys.close_pane", &self.keys.close_pane, "x", (KeyCode::Char('x'), KeyModifiers::empty()), &mut diagnostics), + binding("keys.fullscreen", &self.keys.fullscreen, "f", (KeyCode::Char('f'), KeyModifiers::empty()), &mut diagnostics), + binding("keys.resize_mode", &self.keys.resize_mode, "r", (KeyCode::Char('r'), KeyModifiers::empty()), &mut diagnostics), + binding("keys.toggle_sidebar", &self.keys.toggle_sidebar, "b", (KeyCode::Char('b'), KeyModifiers::empty()), &mut diagnostics), + ]; + + use std::collections::HashMap; + let mut seen: HashMap<(KeyCode, KeyModifiers), &str> = HashMap::new(); + for binding in &mut bindings { + if let Some(first_field) = seen.get(&binding.value) { + let diag = format!( + "duplicate keybinding: {} conflicts with {}; using default {}", + binding.field, first_field, binding.default_label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + binding.value = binding.default; + binding.label = binding.default_label.to_string(); + } else { + seen.insert(binding.value, binding.field); + } } + + let keybinds = Keybinds { + new_workspace: bindings[0].value, + new_workspace_label: bindings[0].label.clone(), + rename_workspace: bindings[1].value, + rename_workspace_label: bindings[1].label.clone(), + close_workspace: bindings[2].value, + close_workspace_label: bindings[2].label.clone(), + split_vertical: bindings[3].value, + split_vertical_label: bindings[3].label.clone(), + split_horizontal: bindings[4].value, + split_horizontal_label: bindings[4].label.clone(), + close_pane: bindings[5].value, + close_pane_label: bindings[5].label.clone(), + fullscreen: bindings[6].value, + fullscreen_label: bindings[6].label.clone(), + resize_mode: bindings[7].value, + resize_mode_label: bindings[7].label.clone(), + toggle_sidebar: bindings[8].value, + toggle_sidebar_label: bindings[8].label.clone(), + }; + + (prefix_diag, prefix, diagnostics, keybinds) } } @@ -396,17 +462,22 @@ fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { Some((code, modifiers)) } -fn parse_key_combo_or_warn( +fn parse_key_combo_with_diagnostic( s: &str, field: &str, fallback: (KeyCode, KeyModifiers), -) -> (KeyCode, KeyModifiers) { - parse_key_combo(s).unwrap_or_else(|| { - warn!(field, value = s, "invalid keybinding, using fallback"); - fallback - }) +) -> ((KeyCode, KeyModifiers), Option<String>) { + match parse_key_combo(s) { + Some(binding) => (binding, None), + None => { + let diag = format!("invalid keybinding: {field} = {s:?}; using fallback"); + warn!(message = %diag, "config diagnostic"); + (fallback, Some(diag)) + } + } } + #[cfg(test)] mod tests { use super::*; @@ -555,6 +626,38 @@ split_horizontal = "D" } #[test] + fn invalid_keybinding_produces_diagnostic_and_falls_back() { + let toml = r#" +[keys] +rename_workspace = "wat" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics.iter().any(|d| d.contains("keys.rename_workspace"))); + assert_eq!(kb.rename_workspace, (KeyCode::Char('n'), KeyModifiers::SHIFT)); + assert_eq!(kb.rename_workspace_label, "shift+n"); + } + + #[test] + fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() { + let toml = r#" +[keys] +new_workspace = "g" +rename_workspace = "g" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics.iter().any(|d| d.contains("duplicate keybinding"))); + assert_eq!(kb.new_workspace, (KeyCode::Char('g'), KeyModifiers::empty())); + assert_eq!(kb.rename_workspace, (KeyCode::Char('n'), KeyModifiers::SHIFT)); + assert_eq!(kb.rename_workspace_label, "shift+n"); + } + + #[test] fn sound_table_config_parses() { let toml = r#" [ui.sound] diff --git a/src/main.rs b/src/main.rs index 93d79b2e..4f1d9cfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -192,7 +192,19 @@ fn main() -> io::Result<()> { original_hook(info); })); - let config = config::Config::load(); + let loaded_config = config::Config::load(); + let config = &loaded_config.config; + let config_diagnostic = if loaded_config.diagnostics.is_empty() { + None + } else if loaded_config.diagnostics.len() == 1 { + Some(loaded_config.diagnostics[0].clone()) + } else { + Some(format!( + "{} (and {} more)", + loaded_config.diagnostics[0], + loaded_config.diagnostics.len() - 1 + )) + }; info!("herdr starting, pid={}", std::process::id()); // Background auto-update (non-blocking, best-effort) @@ -222,7 +234,7 @@ fn main() -> io::Result<()> { std::io::stdout().flush()?; } - let mut app = app::App::new(&config, no_session); + let mut app = app::App::new(config, no_session, config_diagnostic); let result = app.run(&mut terminal).await; // Reset modifyOtherKeys if we enabled it @@ -71,12 +71,15 @@ pub fn render(app: &AppState, frame: &mut Frame) { Mode::Terminal => {} } - // Update notification (rendered on top of everything) + // Notifications (rendered on top of everything) if let Some(version) = &app.update_available { if !app.update_dismissed { render_update_notification(frame, terminal_area, version, app.accent); } } + if let Some(message) = &app.config_diagnostic { + render_config_diagnostic(frame, terminal_area, message); + } } /// Compute pane layout info and resize pane runtimes to match. @@ -768,6 +771,29 @@ fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, acce /// /// Filled dot = needs attention (working, or finished unseen). /// Hollow dot = nothing to do here. +fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str) { + let text = format!(" config warning: {message} "); + let width = text.len() as u16 + 2; + let notif_area = Rect::new( + area.x + area.width.saturating_sub(width.min(area.width)), + area.y, + width.min(area.width), + 1, + ); + + frame.render_widget(Clear, notif_area); + frame.render_widget( + Paragraph::new(Span::styled( + text, + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + notif_area, + ); +} + fn state_icon_style(state: AgentState, seen: bool) -> (&'static str, Style) { match (state, seen) { (AgentState::Waiting, _) => ("●", Style::default().fg(Color::Red)), |
