Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added
- The desktop tab bar now has configurable right-aligned status entries for zoom state, hostname, date/time, literal text, and asynchronously refreshed command output.
- Optional `keys.move_tab_previous` and `keys.move_tab_next` bindings now reorder the active tab in place, wrapping at either end.
- Optional `keys.resize_pane_left`, `keys.resize_pane_down`, `keys.resize_pane_up`, and `keys.resize_pane_right` bindings now resize the focused pane in one keystroke without entering resize mode.
- Devin CLI, Cursor Agent CLI, MastraCode, Hermes Agent, and Grok CLI integrations now install and run natively on Windows.
- Panes can now route normal right-click gestures to mouse-reporting applications through the pane menu, `herdr pane input`, `pane.input.set`, or the `pane split --right-click pane` launch option.
Expand Down
12 changes: 12 additions & 0 deletions docs/next/website/src/data/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,18 @@
"default": "\"prefix+n\"",
"description": "Select the next tab."
},
{
"key": "keys.move_tab_previous",
"type": "keybinding",
"default": "unset",
"description": "Move the active tab one position toward the front. Unset by default."
},
{
"key": "keys.move_tab_next",
"type": "keybinding",
"default": "unset",
"description": "Move the active tab one position toward the back. Unset by default."
},
{
"key": "keys.switch_tab",
"type": "keybinding",
Expand Down
144 changes: 144 additions & 0 deletions src/app/input/navigate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,18 @@ impl App {
leave_navigate_mode(&mut self.state);
}
}
NavigateAction::MoveTabPrevious => {
if let Some((ws_idx, source, insert)) = self.active_tab_move(-1) {
self.move_tab_via_api(ws_idx, source, insert);
}
leave_navigate_mode(&mut self.state);
}
NavigateAction::MoveTabNext => {
if let Some((ws_idx, source, insert)) = self.active_tab_move(1) {
self.move_tab_via_api(ws_idx, source, insert);
}
leave_navigate_mode(&mut self.state);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
NavigateAction::CloseTab => {
if !self.close_active_tab_via_api_requires_confirmation() {
leave_navigate_mode(&mut self.state);
Expand Down Expand Up @@ -749,6 +761,14 @@ impl App {
order.get(next).copied()
}

fn active_tab_move(&self, delta: isize) -> Option<(usize, usize, usize)> {
let ws_idx = self.state.active?;
let ws = self.state.workspaces.get(ws_idx)?;
let source = ws.active_tab;
let insert = tab_move_insert_index(ws.tabs.len(), source, delta)?;
Some((ws_idx, source, insert))
}

fn relative_tab(&self, delta: isize) -> Option<usize> {
let ws = self
.state
Expand Down Expand Up @@ -1377,6 +1397,8 @@ pub(crate) enum NavigateAction {
RenameTab,
PreviousTab,
NextTab,
MoveTabPrevious,
MoveTabNext,
CloseTab,
RenamePane,
FocusPaneLeft,
Expand Down Expand Up @@ -1522,6 +1544,8 @@ fn non_indexed_action_for_key(
(&kb.rename_tab, NavigateAction::RenameTab),
(&kb.previous_tab, NavigateAction::PreviousTab),
(&kb.next_tab, NavigateAction::NextTab),
(&kb.move_tab_previous, NavigateAction::MoveTabPrevious),
(&kb.move_tab_next, NavigateAction::MoveTabNext),
(&kb.close_tab, NavigateAction::CloseTab),
(&kb.rename_pane, NavigateAction::RenamePane),
(&kb.edit_scrollback, NavigateAction::EditScrollback),
Expand Down Expand Up @@ -1723,6 +1747,14 @@ pub(super) fn execute_navigate_action_in_context(
state.next_tab();
leave_navigate_mode(state);
}
NavigateAction::MoveTabPrevious => {
move_active_tab_relative(state, -1);
leave_navigate_mode(state);
}
NavigateAction::MoveTabNext => {
move_active_tab_relative(state, 1);
leave_navigate_mode(state);
}
NavigateAction::CloseTab => {
if !state.close_tab() {
leave_navigate_mode(state);
Expand Down Expand Up @@ -1861,6 +1893,40 @@ fn workspace_can_start_worktree_action(
!git_space.is_some_and(|space| space.is_linked_worktree)
}

// Translate a one-step move into the pre-removal insertion slot that
// Workspace::move_tab expects, wrapping at either end. None when there is
// nothing to move.
fn tab_move_insert_index(len: usize, source: usize, delta: isize) -> Option<usize> {
if len <= 1 {
return None;
}
Some(if delta > 0 {
if source + 1 >= len {
0
} else {
source + 2
}
} else if source == 0 {
len
} else {
source - 1
})
}

#[cfg(test)]
fn move_active_tab_relative(state: &mut AppState, delta: isize) {
let Some(ws) = state
.active
.and_then(|ws_idx| state.workspaces.get_mut(ws_idx))
else {
return;
};
let source = ws.active_tab;
if let Some(insert) = tab_move_insert_index(ws.tabs.len(), source, delta) {
ws.move_tab(source, insert);
}
}

fn leave_navigate_mode(state: &mut AppState) {
if state.active.is_some() {
state.mode = Mode::Terminal;
Expand Down Expand Up @@ -2658,6 +2724,84 @@ resize_pane_left = "prefix+shift+left"
assert_eq!(action, Some(NavigateAction::ResizePaneLeft));
}

#[test]
fn terminal_direct_move_tab_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
state.keybinds.move_tab_next = crate::config::ActionKeybinds::direct("alt+shift+right");

let action = terminal_direct_navigation_action(
&state,
TerminalKey::new(KeyCode::Right, KeyModifiers::ALT | KeyModifiers::SHIFT),
);

assert_eq!(action, Some(NavigateAction::MoveTabNext));
}

fn tab_labels(state: &AppState) -> Vec<String> {
let ws = &state.workspaces[0];
(0..ws.tabs.len())
.map(|tab_idx| ws.tab_display_name(tab_idx).unwrap())
.collect()
}

#[test]
fn move_tab_actions_reorder_and_wrap_the_active_tab() {
let mut state = state_with_workspaces(&["test"]);
{
let ws = &mut state.workspaces[0];
ws.tabs[0].set_custom_name("a".into());
ws.test_add_tab(Some("b"));
ws.test_add_tab(Some("c"));
ws.switch_tab(1);
}

execute_navigate_action(&mut state, NavigateAction::MoveTabNext);
assert_eq!(tab_labels(&state), vec!["a", "c", "b"]);
assert_eq!(state.workspaces[0].active_tab, 2);

execute_navigate_action(&mut state, NavigateAction::MoveTabNext);
assert_eq!(tab_labels(&state), vec!["b", "a", "c"]);
assert_eq!(state.workspaces[0].active_tab, 0);

execute_navigate_action(&mut state, NavigateAction::MoveTabPrevious);
assert_eq!(tab_labels(&state), vec!["a", "c", "b"]);
assert_eq!(state.workspaces[0].active_tab, 2);
state.workspaces[0].assert_invariants_for_test();
}

#[test]
fn move_tab_is_a_noop_with_a_single_tab() {
let mut state = state_with_workspaces(&["test"]);
state.workspaces[0].tabs[0].set_custom_name("only".into());

execute_navigate_action(&mut state, NavigateAction::MoveTabNext);

assert_eq!(tab_labels(&state), vec!["only"]);
assert_eq!(state.workspaces[0].active_tab, 0);
}

#[test]
fn move_tab_with_a_single_tab_still_exits_navigate_mode() {
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = crate::app::App::new(
&crate::config::Config::default(),
true,
None,
api_rx,
event_hub,
);
app.state.workspaces = vec![crate::workspace::Workspace::test_new("solo")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Navigate;

app.execute_tui_navigate_action(NavigateAction::MoveTabNext, ActionContext::Navigate);

assert_eq!(app.state.workspaces[0].tabs.len(), 1);
assert_eq!(app.state.mode, Mode::Terminal);
}

#[test]
fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
Expand Down
6 changes: 6 additions & 0 deletions src/config/keybinds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,8 @@ pub struct Keybinds {
pub rename_tab: ActionKeybinds,
pub previous_tab: ActionKeybinds,
pub next_tab: ActionKeybinds,
pub move_tab_previous: ActionKeybinds,
pub move_tab_next: ActionKeybinds,
pub switch_tab: Vec<IndexedKeybind>,
pub switch_workspace: Vec<IndexedKeybind>,
pub close_tab: ActionKeybinds,
Expand Down Expand Up @@ -494,6 +496,8 @@ impl Config {
rename_tab: empty_action!(),
previous_tab: empty_action!(),
next_tab: empty_action!(),
move_tab_previous: empty_action!(),
move_tab_next: empty_action!(),
switch_tab: Vec::new(),
switch_workspace: Vec::new(),
close_tab: empty_action!(),
Expand Down Expand Up @@ -629,6 +633,8 @@ impl Config {
apply_action!(keybinds.rename_tab, rename_tab, source);
apply_action!(keybinds.previous_tab, previous_tab, source);
apply_action!(keybinds.next_tab, next_tab, source);
apply_action!(keybinds.move_tab_previous, move_tab_previous, source);
apply_action!(keybinds.move_tab_next, move_tab_next, source);
apply_indexed!(
keybinds.switch_tab,
switch_tab,
Expand Down
14 changes: 14 additions & 0 deletions src/config/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,10 @@ pub struct KeysConfig {
pub previous_tab: BindingConfig,
/// Select the next tab. Default: "prefix+n".
pub next_tab: BindingConfig,
/// Move the active tab one position toward the front. Unset by default.
pub move_tab_previous: BindingConfig,
/// Move the active tab one position toward the back. Unset by default.
pub move_tab_next: BindingConfig,
/// Switch to tab 1-9. Default: "prefix+1..9".
pub switch_tab: BindingConfig,
/// Switch to workspace 1-9 from prefix mode. Unset by default.
Expand Down Expand Up @@ -521,6 +525,10 @@ pub(crate) struct KeysConfigOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
next_tab: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
move_tab_previous: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
move_tab_next: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
switch_tab: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
switch_workspace: Option<BindingConfig>,
Expand Down Expand Up @@ -627,6 +635,8 @@ impl<'de> Deserialize<'de> for KeysConfig {
apply_field!(rename_tab);
apply_field!(previous_tab);
apply_field!(next_tab);
apply_field!(move_tab_previous);
apply_field!(move_tab_next);
apply_field!(switch_tab);
apply_field!(switch_workspace);
apply_field!(close_tab);
Expand Down Expand Up @@ -729,6 +739,8 @@ impl KeysConfig {
copy_effective_action_field!(rename_tab, keybinds.rename_tab);
copy_effective_action_field!(previous_tab, keybinds.previous_tab);
copy_effective_action_field!(next_tab, keybinds.next_tab);
copy_effective_action_field!(move_tab_previous, keybinds.move_tab_previous);
copy_effective_action_field!(move_tab_next, keybinds.move_tab_next);
copy_effective_indexed_field!(switch_tab, keybinds.switch_tab);
copy_effective_indexed_field!(switch_workspace, keybinds.switch_workspace);
copy_effective_action_field!(close_tab, keybinds.close_tab);
Expand Down Expand Up @@ -1025,6 +1037,8 @@ impl Default for KeysConfig {
rename_tab: BindingConfig::one("prefix+shift+t"),
previous_tab: BindingConfig::one("prefix+p"),
next_tab: BindingConfig::one("prefix+n"),
move_tab_previous: BindingConfig::empty(),
move_tab_next: BindingConfig::empty(),
switch_tab: BindingConfig::one("prefix+1..9"),
switch_workspace: BindingConfig::empty(),
close_tab: BindingConfig::one("prefix+shift+x"),
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# rename_tab = "prefix+shift+t"
# previous_tab = "prefix+p"
# next_tab = "prefix+n"
# move_tab_previous = "" # optional, e.g. "alt+shift+left" moves the tab toward the front
# move_tab_next = "" # optional, e.g. "alt+shift+right" moves the tab toward the back
# switch_tab = "prefix+1..9"
# switch_workspace = "" # optional indexed binding, e.g. "prefix+shift+1..9"
# close_tab = "prefix+shift+x"
Expand Down
2 changes: 2 additions & 0 deletions src/ui/keybind_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ pub(super) fn keybind_help_groups(app: &AppState) -> Vec<HelpGroup> {
help_entry(keybind_label(&kb.rename_tab), "rename tab"),
help_entry(keybind_label(&kb.previous_tab), "previous tab"),
help_entry(keybind_label(&kb.next_tab), "next tab"),
help_entry(keybind_label(&kb.move_tab_previous), "move tab left"),
help_entry(keybind_label(&kb.move_tab_next), "move tab right"),
help_entry(indexed_label(&kb.switch_tab), "switch tab 1-9"),
help_entry(keybind_label(&kb.close_tab), "close tab"),
];
Expand Down
Loading