feat: add direct pane resize keybindings (#2558)

This commit is contained in:
David Heinemeier Hansson 2026-08-09 18:10:52 +02:00 committed by GitHub
parent ca1af383a8
commit 4e31084495
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 171 additions and 1 deletions

View File

@ -3,6 +3,7 @@
## Unreleased
### Added
- 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.
- `theme.custom.sidebar_bg` can now give the desktop sidebar its own background without changing built-in theme defaults.

View File

@ -137,7 +137,15 @@ A binding may also be an array when one action needs multiple shortcuts:
next_tab = ["prefix+n", "ctrl+alt+]"]
```
Optional actions are unset by default. Bind them with `prefix+` for prefix-mode behavior, or use an explicit modified chord when you intentionally want a direct shortcut.
Optional actions are unset by default. Bind them with `prefix+` for prefix-mode behavior, or use an explicit modified chord when you intentionally want a direct shortcut. For example, tmux-style one-keystroke pane resizing without entering resize mode:
```toml
[keys]
resize_pane_left = "ctrl+shift+alt+left"
resize_pane_down = "ctrl+shift+alt+down"
resize_pane_up = "ctrl+shift+alt+up"
resize_pane_right = "ctrl+shift+alt+right"
```
Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. The `navigate_workspace_*` and `navigate_pane_*` fields are navigate-mode-only and may use plain keys such as `j` or `k`; they must not use `prefix+`, `esc`, `enter`, `tab`, `shift+tab`, `left`, `right`, or unmodified `1` through `9`. Left and right arrows are permanent aliases for pane-left and pane-right navigation. These navigate-mode shortcuts are independent from general action bindings such as `focus_pane_down = "prefix+j"`; when both use the same key, the navigate-mode shortcut wins while navigate mode is open. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings.

View File

@ -523,6 +523,30 @@
"default": "\"prefix+r\"",
"description": "Enter resize mode."
},
{
"key": "keys.resize_pane_left",
"type": "keybinding",
"default": "unset",
"description": "Resize the focused pane toward the left. Unset by default."
},
{
"key": "keys.resize_pane_down",
"type": "keybinding",
"default": "unset",
"description": "Resize the focused pane downward. Unset by default."
},
{
"key": "keys.resize_pane_up",
"type": "keybinding",
"default": "unset",
"description": "Resize the focused pane upward. Unset by default."
},
{
"key": "keys.resize_pane_right",
"type": "keybinding",
"default": "unset",
"description": "Resize the focused pane toward the right. Unset by default."
},
{
"key": "keys.toggle_sidebar",
"type": "keybinding",

View File

@ -383,6 +383,22 @@ impl App {
leave_navigate_mode(&mut self.state);
}
NavigateAction::EnterResizeMode => self.state.mode = Mode::Resize,
NavigateAction::ResizePaneLeft => {
self.resize_pane_direction_via_api(NavDirection::Left);
leave_navigate_mode(&mut self.state);
}
NavigateAction::ResizePaneDown => {
self.resize_pane_direction_via_api(NavDirection::Down);
leave_navigate_mode(&mut self.state);
}
NavigateAction::ResizePaneUp => {
self.resize_pane_direction_via_api(NavDirection::Up);
leave_navigate_mode(&mut self.state);
}
NavigateAction::ResizePaneRight => {
self.resize_pane_direction_via_api(NavDirection::Right);
leave_navigate_mode(&mut self.state);
}
NavigateAction::ToggleSidebar => {
self.state.sidebar_collapsed = !self.state.sidebar_collapsed;
leave_navigate_mode(&mut self.state);
@ -537,6 +553,17 @@ impl App {
}
}
pub(crate) fn resize_pane_direction_via_api(&mut self, direction: NavDirection) {
self.runtime_pane_resize(
"tui.pane.resize",
crate::api::schema::PaneResizeParams {
pane_id: None,
direction: api_pane_direction(direction),
amount: None,
},
);
}
pub(crate) fn swap_pane_direction_via_api(&mut self, direction: NavDirection) {
if let Some((ws_idx, source, target)) = self.directional_pane_swap_from_view(direction) {
let source_pane_id = self.public_pane_id(ws_idx, source);
@ -1367,6 +1394,10 @@ pub(crate) enum NavigateAction {
CopyMode,
Zoom,
EnterResizeMode,
ResizePaneLeft,
ResizePaneDown,
ResizePaneUp,
ResizePaneRight,
ToggleSidebar,
CyclePaneNext,
CyclePanePrevious,
@ -1511,6 +1542,10 @@ fn non_indexed_action_for_key(
(&kb.close_pane, NavigateAction::ClosePane),
(&kb.zoom, NavigateAction::Zoom),
(&kb.resize_mode, NavigateAction::EnterResizeMode),
(&kb.resize_pane_left, NavigateAction::ResizePaneLeft),
(&kb.resize_pane_down, NavigateAction::ResizePaneDown),
(&kb.resize_pane_up, NavigateAction::ResizePaneUp),
(&kb.resize_pane_right, NavigateAction::ResizePaneRight),
(&kb.toggle_sidebar, NavigateAction::ToggleSidebar),
(&kb.reload_config, NavigateAction::ReloadConfig),
(
@ -1742,6 +1777,22 @@ pub(super) fn execute_navigate_action_in_context(
leave_navigate_mode(state);
}
NavigateAction::EnterResizeMode => state.mode = Mode::Resize,
NavigateAction::ResizePaneLeft => {
state.resize_pane(NavDirection::Left);
leave_navigate_mode(state);
}
NavigateAction::ResizePaneDown => {
state.resize_pane(NavDirection::Down);
leave_navigate_mode(state);
}
NavigateAction::ResizePaneUp => {
state.resize_pane(NavDirection::Up);
leave_navigate_mode(state);
}
NavigateAction::ResizePaneRight => {
state.resize_pane(NavDirection::Right);
leave_navigate_mode(state);
}
NavigateAction::ToggleSidebar => {
state.sidebar_collapsed = !state.sidebar_collapsed;
leave_navigate_mode(state);
@ -2569,6 +2620,44 @@ navigate_pane_right = "ctrl+l"
assert_eq!(action, Some(NavigateAction::SwapPaneRight));
}
#[test]
fn terminal_direct_resize_pane_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
state.keybinds.resize_pane_right =
crate::config::ActionKeybinds::direct("ctrl+shift+alt+right");
let action = terminal_direct_navigation_action(
&state,
TerminalKey::new(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT | KeyModifiers::ALT,
),
);
assert_eq!(action, Some(NavigateAction::ResizePaneRight));
}
#[test]
fn prefix_resize_pane_binding_maps_to_navigation_action() {
let config: Config = toml::from_str(
r#"
[keys]
resize_pane_left = "prefix+shift+left"
"#,
)
.unwrap();
let mut state = state_with_workspaces(&["test"]);
state.keybinds = config.keybinds();
let action = action_for_key(
&state,
TerminalKey::new(KeyCode::Left, KeyModifiers::SHIFT),
BindingDispatch::Prefix,
);
assert_eq!(action, Some(NavigateAction::ResizePaneLeft));
}
#[test]
fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);

View File

@ -350,6 +350,10 @@ pub struct Keybinds {
pub close_pane: ActionKeybinds,
pub zoom: ActionKeybinds,
pub resize_mode: ActionKeybinds,
pub resize_pane_left: ActionKeybinds,
pub resize_pane_down: ActionKeybinds,
pub resize_pane_up: ActionKeybinds,
pub resize_pane_right: ActionKeybinds,
pub toggle_sidebar: ActionKeybinds,
pub custom_commands: Vec<CustomCommandKeybind>,
}
@ -512,6 +516,10 @@ impl Config {
close_pane: empty_action!(),
zoom: empty_action!(),
resize_mode: empty_action!(),
resize_pane_left: empty_action!(),
resize_pane_down: empty_action!(),
resize_pane_up: empty_action!(),
resize_pane_right: empty_action!(),
toggle_sidebar: empty_action!(),
custom_commands: Vec::new(),
};
@ -653,6 +661,10 @@ impl Config {
apply_action!(keybinds.close_pane, close_pane, source);
apply_action!(keybinds.zoom, zoom, source);
apply_action!(keybinds.resize_mode, resize_mode, source);
apply_action!(keybinds.resize_pane_left, resize_pane_left, source);
apply_action!(keybinds.resize_pane_down, resize_pane_down, source);
apply_action!(keybinds.resize_pane_up, resize_pane_up, source);
apply_action!(keybinds.resize_pane_right, resize_pane_right, source);
apply_action!(keybinds.toggle_sidebar, toggle_sidebar, source);
if source == field_source!(indexed) {

View File

@ -438,6 +438,14 @@ pub struct KeysConfig {
pub zoom: BindingConfig,
/// Enter resize mode. Default: "prefix+r"
pub resize_mode: BindingConfig,
/// Resize the focused pane toward the left. Unset by default.
pub resize_pane_left: BindingConfig,
/// Resize the focused pane downward. Unset by default.
pub resize_pane_down: BindingConfig,
/// Resize the focused pane upward. Unset by default.
pub resize_pane_up: BindingConfig,
/// Resize the focused pane toward the right. Unset by default.
pub resize_pane_right: BindingConfig,
/// Toggle sidebar collapse. Default: "prefix+b"
pub toggle_sidebar: BindingConfig,
/// Optional indexed shortcuts expanded over number keys 1-9.
@ -557,6 +565,14 @@ pub(crate) struct KeysConfigOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
resize_mode: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
resize_pane_left: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
resize_pane_down: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
resize_pane_up: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
resize_pane_right: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
toggle_sidebar: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
indexed: Option<IndexedKeysConfig>,
@ -633,6 +649,10 @@ impl<'de> Deserialize<'de> for KeysConfig {
apply_field!(close_pane);
apply_field!(zoom);
apply_field!(resize_mode);
apply_field!(resize_pane_left);
apply_field!(resize_pane_down);
apply_field!(resize_pane_up);
apply_field!(resize_pane_right);
apply_field!(toggle_sidebar);
apply_field!(indexed);
apply_field!(command);
@ -731,6 +751,10 @@ impl KeysConfig {
copy_effective_action_field!(close_pane, keybinds.close_pane);
copy_effective_action_field!(zoom, keybinds.zoom);
copy_effective_action_field!(resize_mode, keybinds.resize_mode);
copy_effective_action_field!(resize_pane_left, keybinds.resize_pane_left);
copy_effective_action_field!(resize_pane_down, keybinds.resize_pane_down);
copy_effective_action_field!(resize_pane_up, keybinds.resize_pane_up);
copy_effective_action_field!(resize_pane_right, keybinds.resize_pane_right);
copy_effective_action_field!(toggle_sidebar, keybinds.toggle_sidebar);
copy_user_field!(indexed);
@ -1019,6 +1043,10 @@ impl Default for KeysConfig {
close_pane: BindingConfig::one("prefix+x"),
zoom: BindingConfig::one("prefix+z"),
resize_mode: BindingConfig::one("prefix+r"),
resize_pane_left: BindingConfig::empty(),
resize_pane_down: BindingConfig::empty(),
resize_pane_up: BindingConfig::empty(),
resize_pane_right: BindingConfig::empty(),
toggle_sidebar: BindingConfig::one("prefix+b"),
indexed: IndexedKeysConfig::default(),
command: Vec::new(),

View File

@ -213,6 +213,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# close_pane = "prefix+x"
# zoom = "prefix+z" # legacy alias: fullscreen
# resize_mode = "prefix+r"
# resize_pane_left = "" # optional, e.g. "ctrl+shift+alt+left" resizes without entering resize mode
# resize_pane_down = "" # optional, e.g. "ctrl+shift+alt+down"
# resize_pane_up = "" # optional, e.g. "ctrl+shift+alt+up"
# resize_pane_right = "" # optional, e.g. "ctrl+shift+alt+right"
# toggle_sidebar = "prefix+b"
# Navigate-mode movement. These local shortcuts win while navigate mode is open.

View File

@ -145,6 +145,10 @@ pub(super) fn keybind_help_groups(app: &AppState) -> Vec<HelpGroup> {
help_entry(keybind_label(&kb.copy_mode), "copy mode"),
help_entry(keybind_label(&kb.zoom), "zoom pane"),
help_entry(keybind_label(&kb.resize_mode), "resize mode"),
help_entry(keybind_label(&kb.resize_pane_left), "resize pane left"),
help_entry(keybind_label(&kb.resize_pane_down), "resize pane down"),
help_entry(keybind_label(&kb.resize_pane_up), "resize pane up"),
help_entry(keybind_label(&kb.resize_pane_right), "resize pane right"),
help_entry(keybind_label(&kb.toggle_sidebar), "toggle sidebar"),
help_entry(keybind_label(&kb.focus_pane_left), "focus pane left"),
help_entry(keybind_label(&kb.focus_pane_down), "focus pane down"),