feat: add agent cycling and tab wheel switching

fixes #92
This commit is contained in:
Ogulcan Celik
2026-05-12 00:24:13 +03:00
parent 8d5dca8cfe
commit 520f4272d0
13 changed files with 437 additions and 62 deletions
+6 -2
View File
@@ -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 |
+1 -1
View File
@@ -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
+167
View File
@@ -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"]);
+99 -10
View File
@@ -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();
+48
View File
@@ -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<NavigateA
{
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 key_matches(key, kb.new_tab.0, kb.new_tab.1) {
return Some(NavigateAction::NewTab);
}
@@ -508,6 +534,14 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
state.next_workspace();
leave_navigate_mode(state);
}
NavigateAction::PreviousAgent => {
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"]);
+4
View File
@@ -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,
+47 -18
View File
@@ -73,6 +73,10 @@ pub struct Keybinds {
pub previous_workspace_label: Option<String>,
pub next_workspace: Option<(KeyCode, KeyModifiers)>,
pub next_workspace_label: Option<String>,
pub previous_agent: Option<(KeyCode, KeyModifiers)>,
pub previous_agent_label: Option<String>,
pub next_agent: Option<(KeyCode, KeyModifiers)>,
pub next_agent_label: Option<String>,
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))
+6
View File
@@ -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(),
+2
View File
@@ -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
+2
View File
@@ -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")));
+5
View File
@@ -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"),
(
+19 -3
View File
@@ -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;
+31 -28
View File
@@ -46,7 +46,7 @@
type="font/woff2"
crossorigin
/>
<link rel="stylesheet" href="/css/style.css?v=d0a150a" />
<link rel="stylesheet" href="/css/style.css?v=37c8e0f6" />
</head>
<body>
<!-- ── Hero ── -->
@@ -69,15 +69,24 @@
>
</p>
<p>
workspaces, tabs, panes. mouse-native: click, drag, split.
every agent at a glance:
workspaces, tabs, panes. every agent at a glance:
<span class="text-blocked">blocked</span>,
<span class="text-working">working</span>,
<span class="text-done">done</span>. 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.
</p>
<p class="mouse-native-line">
<span class="mouse-native-pill">mouse-native tui</span>:
click
<span>panes</span
>/<span>tabs</span>/<span>workspaces</span>/<span>agents</span>,
drag borders, select text, right-click menus.
</p>
<p>
no gui app, no electron, no mac-only native wrapper. you see
the agent's own terminal, not someone's interpretation of
it.
</p>
</div>
<div class="install-row">
@@ -267,34 +276,29 @@
<hr class="section-rule" />
<div class="gap"></div>
<!-- ── From anywhere ── -->
<!-- ── Local or remote ── -->
<section class="section">
<div class="section-heading">
<span class="dot dot-done"></span>
<h2>from anywhere</h2>
<h2>local or remote</h2>
</div>
<div class="prose-block">
<p>
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.
</p>
</div>
<div class="access-grid">
<div class="access-card">
<div class="access-kicker">server</div>
<h3>ssh in, run herdr</h3>
<div class="access-kicker">local</div>
<h3>run herdr where you work</h3>
<p>
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.
</p>
<div class="mini-flow">
<div>
<span class="prompt">$</span>
<span class="cmd">ssh</span>
<span class="comment">you@server</span>
</div>
<div>
<span class="prompt">$</span>
<span class="cmd">herdr</span>
@@ -302,12 +306,12 @@
</div>
</div>
<div class="access-card">
<div class="access-kicker">phone</div>
<h3>ssh from mobile</h3>
<div class="access-kicker">ssh</div>
<h3>ssh in, run herdr</h3>
<p>
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.
</p>
<div class="mini-flow">
<div>
@@ -627,8 +631,7 @@
>api</a
>
·
<a
href="https://github.com/ogulcancelik/herdr/releases"
<a href="https://github.com/ogulcancelik/herdr/releases"
>releases</a
>
</p>