From 520f4272d0f332f2a98a2a7486be1797feb86dcf Mon Sep 17 00:00:00 2001
From: Ogulcan Celik
Date: Tue, 12 May 2026 00:24:13 +0300
Subject: [PATCH] feat: add agent cycling and tab wheel switching
fixes #92
---
CONFIGURATION.md | 8 +-
README.md | 2 +-
src/app/actions.rs | 167 ++++++++++++++++++++++++++++++++++++++
src/app/input/mouse.rs | 109 ++++++++++++++++++++++---
src/app/input/navigate.rs | 48 +++++++++++
src/app/state.rs | 4 +
src/config/keybinds.rs | 65 +++++++++++----
src/config/model.rs | 6 ++
src/main.rs | 2 +
src/ui.rs | 2 +
src/ui/keybind_help.rs | 5 ++
website/css/style.css | 22 ++++-
website/index.html | 59 +++++++-------
13 files changed, 437 insertions(+), 62 deletions(-)
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 187ba04c..731480a8 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -96,8 +96,8 @@ close_workspace = "shift+d"
reload_config = "" # optional, unset by default
open_notification_target = "" # optional, unset by default
new_tab = "c"
-split_vertical = "v"
-split_horizontal = "-"
+split_vertical = "d"
+split_horizontal = "D"
close_pane = "x"
rename_pane = "" # optional, unset by default
fullscreen = "f"
@@ -105,6 +105,8 @@ resize_mode = "r"
toggle_sidebar = "b"
previous_workspace = "ctrl+alt+["
next_workspace = "ctrl+alt+]"
+previous_agent = "ctrl+["
+next_agent = "ctrl+]"
previous_tab = "alt+["
next_tab = "alt+]"
focus_pane_left = "alt+h"
@@ -126,6 +128,8 @@ focus_pane_right = "alt+l"
| `open_notification_target` | unset | jump to the currently visible notification target |
| `previous_workspace` | unset | switch to the previous workspace directly from terminal mode |
| `next_workspace` | unset | switch to the next workspace directly from terminal mode |
+| `previous_agent` | unset | focus the previous agent shown in the sidebar agent list |
+| `next_agent` | unset | focus the next agent shown in the sidebar agent list |
| `new_tab` | `c` | create a new tab |
| `rename_tab` | unset | rename the active tab |
| `previous_tab` | unset | switch to the previous tab directly from terminal mode |
diff --git a/README.md b/README.md
index d1fb505b..68dc0922 100644
--- a/README.md
+++ b/README.md
@@ -129,7 +129,7 @@ not a gui window, not a web dashboard, not electron. herdr runs inside whatever
- **workspaces** — organized around git repos or folder names, each with its own tabs and panes
- **tabs** — first-class in the socket api and cli
-- **mouse-native** — click panes, drag borders, select text to copy; not keyboard-only
+- **mouse-native** — click panes/tabs/workspaces/agents, drag borders, select text to copy, right-click menus; not keyboard-only
- **notifications** — sounds and toasts for background events; tab-aware suppression
- **10 built-in themes** — catppuccin (default), tokyo night, dracula, nord, gruvbox, one dark, solarized, kanagawa, rosé pine, vesper
- **session persistence** — pane processes survive client detach; sessions restore after full restart
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 317bbff7..6263679c 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -323,6 +323,80 @@ impl AppState {
}
}
+ pub fn next_agent(&mut self) {
+ self.cycle_agent_entry(true);
+ }
+
+ pub fn previous_agent(&mut self) {
+ self.cycle_agent_entry(false);
+ }
+
+ fn cycle_agent_entry(&mut self, forward: bool) {
+ let entries = crate::ui::agent_panel_entries(self);
+ if entries.is_empty() {
+ return;
+ }
+
+ let focused = self
+ .active
+ .and_then(|idx| self.workspaces.get(idx))
+ .and_then(crate::workspace::Workspace::focused_pane_id);
+ let current_idx =
+ focused.and_then(|pane_id| entries.iter().position(|entry| entry.pane_id == pane_id));
+ let target_idx = match (current_idx, forward) {
+ (Some(idx), true) => (idx + 1) % entries.len(),
+ (Some(0), false) => entries.len() - 1,
+ (Some(idx), false) => idx - 1,
+ (None, true) => 0,
+ (None, false) => entries.len() - 1,
+ };
+
+ let target = &entries[target_idx];
+ let ws_idx = target.ws_idx;
+ let tab_idx = target.tab_idx;
+ let pane_id = target.pane_id;
+
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ if let Some(tab) = self
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ if tab.panes.contains_key(&pane_id) {
+ tab.layout.focus_pane(pane_id);
+ self.mark_session_dirty();
+ }
+ }
+ self.ensure_agent_panel_entry_visible(target_idx);
+ }
+
+ fn ensure_agent_panel_entry_visible(&mut self, idx: usize) {
+ if self.sidebar_collapsed {
+ return;
+ }
+
+ let (_, detail_area) = crate::ui::expanded_sidebar_sections(
+ self.view.sidebar_rect,
+ self.sidebar_section_split,
+ );
+ let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area);
+ let visible = metrics.viewport_rows;
+ if visible == 0 {
+ return;
+ }
+
+ if idx < self.agent_panel_scroll {
+ self.agent_panel_scroll = idx;
+ } else if idx >= self.agent_panel_scroll.saturating_add(visible) {
+ self.agent_panel_scroll = idx.saturating_add(1).saturating_sub(visible);
+ }
+
+ let max_scroll =
+ crate::ui::agent_panel_scroll_metrics(self, detail_area).max_offset_from_bottom;
+ self.agent_panel_scroll = self.agent_panel_scroll.min(max_scroll);
+ }
+
pub fn close_selected_workspace(&mut self) {
if self.workspaces.is_empty() {
return;
@@ -871,6 +945,99 @@ mod tests {
assert_eq!(toast.context, "detach, then run `herdr update`");
}
+ fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) {
+ state.workspaces[ws_idx].tabs[tab_idx]
+ .panes
+ .get_mut(&pane_id)
+ .unwrap()
+ .set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ }
+
+ #[test]
+ fn next_agent_cycles_agent_panel_entries_in_all_scope() {
+ let mut first = Workspace::test_new("one");
+ let first_root = first.tabs[0].root_pane;
+ let first_second = first.test_split(Direction::Horizontal);
+ first.tabs[0].layout.focus_pane(first_root);
+ let second = Workspace::test_new("two");
+ let second_root = second.tabs[0].root_pane;
+
+ let mut state = AppState::test_new();
+ state.workspaces = vec![first, second];
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Terminal;
+ state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces;
+ mark_agent(&mut state, 0, 0, first_root);
+ mark_agent(&mut state, 0, 0, first_second);
+ mark_agent(&mut state, 1, 0, second_root);
+
+ state.next_agent();
+ assert_eq!(state.active, Some(0));
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second));
+
+ state.next_agent();
+ assert_eq!(state.active, Some(1));
+ assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
+
+ state.previous_agent();
+ assert_eq!(state.active, Some(0));
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second));
+ }
+
+ #[test]
+ fn next_agent_cycles_only_current_scope_entries() {
+ let mut first = Workspace::test_new("one");
+ let first_root = first.tabs[0].root_pane;
+ let first_second = first.test_split(Direction::Horizontal);
+ first.tabs[0].layout.focus_pane(first_second);
+ let second = Workspace::test_new("two");
+ let second_root = second.tabs[0].root_pane;
+
+ let mut state = AppState::test_new();
+ state.workspaces = vec![first, second];
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Terminal;
+ state.agent_panel_scope = crate::app::state::AgentPanelScope::CurrentWorkspace;
+ mark_agent(&mut state, 0, 0, first_root);
+ mark_agent(&mut state, 0, 0, first_second);
+ mark_agent(&mut state, 1, 0, second_root);
+
+ state.next_agent();
+
+ assert_eq!(state.active, Some(0));
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
+ }
+
+ #[test]
+ fn previous_agent_keeps_wrapped_target_visible_in_agent_panel() {
+ let mut workspace = Workspace::test_new("one");
+ let root = workspace.tabs[0].root_pane;
+ for idx in 1..8 {
+ workspace.test_add_tab(Some(&format!("tab-{idx}")));
+ }
+
+ let mut state = AppState::test_new();
+ state.workspaces = vec![workspace];
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Terminal;
+ state.agent_panel_scope = crate::app::state::AgentPanelScope::CurrentWorkspace;
+ for tab_idx in 0..state.workspaces[0].tabs.len() {
+ let pane_id = state.workspaces[0].tabs[tab_idx].root_pane;
+ mark_agent(&mut state, 0, tab_idx, pane_id);
+ }
+ state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
+
+ state.previous_agent();
+
+ let last_idx = state.workspaces[0].tabs.len() - 1;
+ assert_eq!(state.workspaces[0].active_tab, last_idx);
+ assert!(state.agent_panel_scroll > 0);
+ }
+
#[test]
fn switch_workspace_updates_active_and_selected() {
let mut state = app_with_workspaces(&["a", "b", "c"]);
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 99157d0d..779af3ed 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -581,19 +581,24 @@ impl AppState {
}
}
- MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
- if self.on_tab_bar(mouse.column, mouse.row) {
- match mouse.kind {
- MouseEventKind::ScrollUp => self.scroll_tabs_left(),
- MouseEventKind::ScrollDown => self.scroll_tabs_right(),
- _ => {}
- }
- } else if !self.scroll_selection_with_wheel(mouse) {
- self.selection = None;
- self.handle_terminal_wheel(mouse);
+ MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
+ if self.on_tab_bar(mouse.column, mouse.row) =>
+ {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.previous_tab(),
+ MouseEventKind::ScrollDown => self.next_tab(),
+ _ => {}
}
}
+ MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
+ if !in_sidebar && self.scroll_selection_with_wheel(mouse) => {}
+
+ MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
+ self.selection = None;
+ self.handle_terminal_wheel(mouse);
+ }
+
MouseEventKind::ScrollUp if in_sidebar => {
let agent_area = self.agent_panel_rect();
let over_agent_panel = agent_area != Rect::default()
@@ -1567,6 +1572,90 @@ mod tests {
assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport);
}
+ #[test]
+ fn wheel_over_tab_bar_switches_tabs() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("one");
+ ws.test_add_tab(Some("two"));
+ ws.test_add_tab(Some("three"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let tab_bar = app.state.view.tab_bar_rect;
+
+ app.handle_mouse(mouse(MouseEventKind::ScrollDown, tab_bar.x + 1, tab_bar.y));
+ assert_eq!(app.state.workspaces[0].active_tab, 1);
+
+ app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y));
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+
+ app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y));
+ assert_eq!(app.state.workspaces[0].active_tab, 2);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::ScrollDown,
+ tab_bar.x + tab_bar.width.saturating_sub(1),
+ tab_bar.y,
+ ));
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+ }
+
+ #[test]
+ fn wheel_over_overflowing_tab_bar_switches_tabs() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("one");
+ ws.tabs[0].set_custom_name("very-long-one".into());
+ ws.test_add_tab(Some("very-long-two"));
+ ws.test_add_tab(Some("very-long-three"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20));
+ assert!(app.state.view.tab_scroll_right_hit_area.width > 0);
+ let tab_bar = app.state.view.tab_bar_rect;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::ScrollDown,
+ tab_bar.x + tab_bar.width.saturating_sub(2),
+ tab_bar.y,
+ ));
+ assert_eq!(app.state.workspaces[0].active_tab, 1);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::ScrollDown,
+ tab_bar.x + tab_bar.width.saturating_sub(2),
+ tab_bar.y,
+ ));
+ assert_eq!(app.state.workspaces[0].active_tab, 2);
+ }
+
+ #[test]
+ fn wheel_outside_tab_bar_does_not_switch_tabs() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("one");
+ ws.test_add_tab(Some("two"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let terminal = app.state.view.terminal_area;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::ScrollDown,
+ terminal.x + 1,
+ terminal.y + 1,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+ }
+
#[test]
fn mobile_switch_button_opens_switcher_and_workspace_row_switches_workspace() {
let mut app = app_for_mouse_test();
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index ce3d9cf4..6dfaf475 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -30,6 +30,18 @@ pub(crate) fn terminal_direct_navigation_action(
{
return Some(NavigateAction::NextWorkspace);
}
+ if kb
+ .previous_agent
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousAgent);
+ }
+ if kb
+ .next_agent
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextAgent);
+ }
if kb
.previous_tab
.is_some_and(|(code, mods)| key_matches(key, code, mods))
@@ -363,6 +375,8 @@ pub(crate) enum NavigateAction {
CloseWorkspace,
PreviousWorkspace,
NextWorkspace,
+ PreviousAgent,
+ NextAgent,
NewTab,
RenameTab,
PreviousTab,
@@ -407,6 +421,18 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option {
+ state.previous_agent();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NextAgent => {
+ state.next_agent();
+ leave_navigate_mode(state);
+ }
NavigateAction::NewTab => super::modal::open_new_tab_dialog(state),
NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false),
NavigateAction::PreviousTab => {
@@ -729,6 +763,20 @@ mod tests {
assert_eq!(state.mobile_switcher_scroll, 1);
}
+ #[test]
+ fn terminal_direct_agent_shortcut_maps_to_navigation_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.next_agent = Some((KeyCode::Char('a'), KeyModifiers::ALT));
+ state.keybinds.next_agent_label = Some("alt+a".into());
+
+ let action = terminal_direct_navigation_action(
+ &state,
+ &KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT),
+ );
+
+ assert_eq!(action, Some(NavigateAction::NextAgent));
+ }
+
#[test]
fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
diff --git a/src/app/state.rs b/src/app/state.rs
index 3791f674..b350235a 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -903,6 +903,10 @@ impl AppState {
previous_workspace_label: None,
next_workspace: None,
next_workspace_label: None,
+ previous_agent: None,
+ previous_agent_label: None,
+ next_agent: None,
+ next_agent_label: None,
new_tab: (KeyCode::Char('c'), KeyModifiers::empty()),
new_tab_label: "c".into(),
rename_tab: None,
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 90dc2283..a18dcbc3 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -73,6 +73,10 @@ pub struct Keybinds {
pub previous_workspace_label: Option,
pub next_workspace: Option<(KeyCode, KeyModifiers)>,
pub next_workspace_label: Option,
+ pub previous_agent: Option<(KeyCode, KeyModifiers)>,
+ pub previous_agent_label: Option,
+ pub next_agent: Option<(KeyCode, KeyModifiers)>,
+ pub next_agent_label: Option,
pub new_tab: (KeyCode, KeyModifiers),
pub new_tab_label: String,
pub rename_tab: Option<(KeyCode, KeyModifiers)>,
@@ -365,6 +369,18 @@ impl Config {
&self.keys.next_workspace,
&mut diagnostics,
),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.previous_agent",
+ &self.keys.previous_agent,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.next_agent",
+ &self.keys.next_agent,
+ &mut diagnostics,
+ ),
optional_binding(
BindingScope::Navigate,
"keys.rename_tab",
@@ -610,26 +626,30 @@ impl Config {
previous_workspace_label: optional_bindings[3].label.clone(),
next_workspace: optional_bindings[4].value,
next_workspace_label: optional_bindings[4].label.clone(),
+ previous_agent: optional_bindings[5].value,
+ previous_agent_label: optional_bindings[5].label.clone(),
+ next_agent: optional_bindings[6].value,
+ next_agent_label: optional_bindings[6].label.clone(),
new_tab: bindings[3].value,
new_tab_label: bindings[3].label.clone(),
- rename_tab: optional_bindings[5].value,
- rename_tab_label: optional_bindings[5].label.clone(),
- previous_tab: optional_bindings[6].value,
- previous_tab_label: optional_bindings[6].label.clone(),
- next_tab: optional_bindings[7].value,
- next_tab_label: optional_bindings[7].label.clone(),
- close_tab: optional_bindings[8].value,
- close_tab_label: optional_bindings[8].label.clone(),
- rename_pane: optional_bindings[9].value,
- rename_pane_label: optional_bindings[9].label.clone(),
- focus_pane_left: optional_bindings[10].value,
- focus_pane_left_label: optional_bindings[10].label.clone(),
- focus_pane_down: optional_bindings[11].value,
- focus_pane_down_label: optional_bindings[11].label.clone(),
- focus_pane_up: optional_bindings[12].value,
- focus_pane_up_label: optional_bindings[12].label.clone(),
- focus_pane_right: optional_bindings[13].value,
- focus_pane_right_label: optional_bindings[13].label.clone(),
+ rename_tab: optional_bindings[7].value,
+ rename_tab_label: optional_bindings[7].label.clone(),
+ previous_tab: optional_bindings[8].value,
+ previous_tab_label: optional_bindings[8].label.clone(),
+ next_tab: optional_bindings[9].value,
+ next_tab_label: optional_bindings[9].label.clone(),
+ close_tab: optional_bindings[10].value,
+ close_tab_label: optional_bindings[10].label.clone(),
+ rename_pane: optional_bindings[11].value,
+ rename_pane_label: optional_bindings[11].label.clone(),
+ focus_pane_left: optional_bindings[12].value,
+ focus_pane_left_label: optional_bindings[12].label.clone(),
+ focus_pane_down: optional_bindings[13].value,
+ focus_pane_down_label: optional_bindings[13].label.clone(),
+ focus_pane_up: optional_bindings[14].value,
+ focus_pane_up_label: optional_bindings[14].label.clone(),
+ focus_pane_right: optional_bindings[15].value,
+ focus_pane_right_label: optional_bindings[15].label.clone(),
split_vertical: bindings[4].value,
split_vertical_label: bindings[4].label.clone(),
split_horizontal: bindings[5].value,
@@ -863,6 +883,8 @@ mod tests {
(KeyCode::Char('d'), KeyModifiers::SHIFT)
);
assert_eq!(kb.detach, None);
+ assert_eq!(kb.previous_agent, None);
+ assert_eq!(kb.next_agent, None);
assert_eq!(kb.split_vertical.0, KeyCode::Char('v'));
assert_eq!(kb.split_horizontal.0, KeyCode::Char('-'));
assert_eq!(kb.close_pane.0, KeyCode::Char('x'));
@@ -886,6 +908,8 @@ close_pane = "ctrl+w"
fullscreen = "z"
resize_mode = "ctrl+r"
toggle_sidebar = "tab"
+previous_agent = "alt+a"
+next_agent = "alt+d"
focus_pane_left = "alt+h"
focus_pane_right = "alt+right"
"#;
@@ -916,6 +940,11 @@ focus_pane_right = "alt+right"
assert_eq!(kb.fullscreen.0, KeyCode::Char('z'));
assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL));
assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty()));
+ assert_eq!(
+ kb.previous_agent,
+ Some((KeyCode::Char('a'), KeyModifiers::ALT))
+ );
+ assert_eq!(kb.next_agent, Some((KeyCode::Char('d'), KeyModifiers::ALT)));
assert_eq!(
kb.focus_pane_left,
Some((KeyCode::Char('h'), KeyModifiers::ALT))
diff --git a/src/config/model.rs b/src/config/model.rs
index 166e3ffa..8d2b356f 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -85,6 +85,10 @@ pub struct KeysConfig {
pub previous_workspace: String,
/// Select the next workspace. Unset by default.
pub next_workspace: String,
+ /// Focus the previous agent shown in the agent panel. Unset by default.
+ pub previous_agent: String,
+ /// Focus the next agent shown in the agent panel. Unset by default.
+ pub next_agent: String,
/// Create a new tab in the active workspace. Default: "c"
pub new_tab: String,
/// Rename the active tab. Unset by default.
@@ -162,6 +166,8 @@ impl Default for KeysConfig {
open_notification_target: "".into(),
previous_workspace: "".into(),
next_workspace: "".into(),
+ previous_agent: "".into(),
+ next_agent: "".into(),
new_tab: "c".into(),
rename_tab: "".into(),
previous_tab: "".into(),
diff --git a/src/main.rs b/src/main.rs
index 84a1870b..f4588bdc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -85,6 +85,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# close_workspace = "shift+d"
# previous_workspace = "" # optional, unset by default
# next_workspace = "" # optional, unset by default
+# previous_agent = "" # optional, unset by default
+# next_agent = "" # optional, unset by default
# detach = "" # optional explicit detach shortcut in server/client mode
# reload_config = "" # optional shortcut to reload config.toml without restarting
# open_notification_target = "" # optional shortcut to jump to the visible notification target
diff --git a/src/ui.rs b/src/ui.rs
index 88692901..b348866d 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -847,6 +847,8 @@ mod tests {
assert!(workspace_tab.contains(&("unset".to_string(), "previous workspace")));
assert!(workspace_tab.contains(&("unset".to_string(), "next workspace")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "previous agent")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "next agent")));
assert!(workspace_tab.contains(&("unset".to_string(), "rename tab")));
assert!(workspace_tab.contains(&("unset".to_string(), "previous tab")));
assert!(workspace_tab.contains(&("unset".to_string(), "next tab")));
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index 0d555d3d..dbeba345 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -68,6 +68,11 @@ pub(super) fn keybind_help_groups(
optional_keybind_label(&kb.next_workspace_label),
"next workspace",
),
+ (
+ optional_keybind_label(&kb.previous_agent_label),
+ "previous agent",
+ ),
+ (optional_keybind_label(&kb.next_agent_label), "next agent"),
(kb.new_tab_label.clone(), "new tab"),
(optional_keybind_label(&kb.rename_tab_label), "rename tab"),
(
diff --git a/website/css/style.css b/website/css/style.css
index 156a25ae..37f64f0f 100644
--- a/website/css/style.css
+++ b/website/css/style.css
@@ -257,6 +257,23 @@ a:hover {
font-weight: 500;
}
+.mouse-native-line span:not(.mouse-native-pill) {
+ transition: color 0.2s ease;
+}
+
+.mouse-native-line:hover span:not(.mouse-native-pill) {
+ color: var(--white);
+}
+
+.mouse-native-pill {
+ color: var(--green);
+ transition: text-shadow 0.2s ease;
+}
+
+.mouse-native-line:hover .mouse-native-pill {
+ text-shadow: 0 0 8px rgba(166, 227, 161, 0.35);
+}
+
.install-row {
display: flex;
align-items: center;
@@ -447,8 +464,8 @@ a:hover {
}
.access-card {
- display: flex;
- flex-direction: column;
+ display: grid;
+ grid-template-rows: auto 3.2rem 1fr auto;
min-width: 0;
background: var(--mantle);
border: 1px solid var(--border);
@@ -484,7 +501,6 @@ a:hover {
align-content: start;
gap: 0.35rem;
min-height: 3.6rem;
- margin-top: auto;
padding-top: 0.85rem;
border-top: 1px solid var(--border);
font-size: 0.74rem;
diff --git a/website/index.html b/website/index.html
index 278ad173..8588d700 100644
--- a/website/index.html
+++ b/website/index.html
@@ -46,7 +46,7 @@
type="font/woff2"
crossorigin
/>
-
+
@@ -69,15 +69,24 @@
>
- workspaces, tabs, panes. mouse-native: click, drag, split.
- every agent at a glance:
+ workspaces, tabs, panes. every agent at a glance:
blocked ,
working ,
done . detach and reattach,
agents keep running. attach locally, over ssh, or as a thin
- client to a remote server. no gui app, no electron, no
- mac-only native wrapper. you see the agent's own terminal,
- not someone's interpretation of it.
+ client to a remote server.
+
+
+ mouse-native tui :
+ click
+ panes /tabs /workspaces /agents ,
+ drag borders, select text, right-click menus.
+
+
+ no gui app, no electron, no mac-only native wrapper. you see
+ the agent's own terminal, not someone's interpretation of
+ it.
@@ -267,34 +276,29 @@
-
+
-
from anywhere
+ local or remote
- once the session is running, attach to it three ways. keep
- the work on the machine that has the code, the keys, and the
- agents. connect from wherever you are.
+ use herdr where the work lives. most days that is your
+ laptop or desktop. if the code, keys, or agents live on a
+ server, attach there instead.
-
server
-
ssh in, run herdr
+
local
+
run herdr where you work
- use it like tmux on a remote box. herdr starts the
- session there, and your panes keep running after you
- detach.
+ start a local session on your own machine. detach,
+ reattach, split panes, create tabs, and keep agents
+ running while your terminal comes and goes.
-
- $
- ssh
-
-
$
herdr
@@ -302,12 +306,12 @@
-
phone
-
ssh from mobile
+
ssh
+
ssh in, run herdr
- narrow terminals get a responsive tui instead of a
- squeezed desktop layout. spaces, tabs, and agents stay
- reachable from a phone ssh client.
+ use it like tmux on a remote box. herdr starts the
+ session there, and your panes keep running after you
+ detach.