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 - you@server -
$ 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.

@@ -627,8 +631,7 @@ >api · - releases