mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
+6
-2
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user