aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Heinemeier Hansson <david@hey.com>2026-08-09 18:10:52 +0200
committerGitHub <noreply@github.com>2026-08-09 19:10:52 +0300
commit4e31084495120674a0cf403a27d3d91c5efb34fe (patch)
tree5c47ba87d2cd7fe0bb9a02a360de38c79d8a9d5c
parentca1af383a82733608a0563fce2252058b478498c (diff)
feat: add direct pane resize keybindings (#2558)
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx10
-rw-r--r--docs/next/website/src/data/config-reference.json24
-rw-r--r--src/app/input/navigate.rs89
-rw-r--r--src/config/keybinds.rs12
-rw-r--r--src/config/model.rs28
-rw-r--r--src/main.rs4
-rw-r--r--src/ui/keybind_help.rs4
8 files changed, 171 insertions, 1 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 72d3b0f5..dba64077 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -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.
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index ce4db4e2..a170613f 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -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.
diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json
index a4732ffc..2147192d 100644
--- a/docs/next/website/src/data/config-reference.json
+++ b/docs/next/website/src/data/config-reference.json
@@ -524,6 +524,30 @@
"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",
"default": "\"prefix+b\"",
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 64168035..73dc5299 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -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);
@@ -2570,6 +2621,44 @@ navigate_pane_right = "ctrl+l"
}
#[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"]);
state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l");
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 199076f0..de732121 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -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) {
diff --git a/src/config/model.rs b/src/config/model.rs
index ba700731..9bd83a96 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -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(),
diff --git a/src/main.rs b/src/main.rs
index 2db4f8aa..02ecb8d3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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.
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index 25c999fa..b390ab86 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -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"),