From 3741b0113721beaeef524d741b6c1e8958579472 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 9 Aug 2026 12:46:30 +0200 Subject: [PATCH 1/2] feat: add direct pane resize keybindings Add optional keys.resize_pane_left/down/up/right actions so a single chord can resize the focused pane tmux-style without entering resize mode. Unset by default; bindable as prefix or direct shortcuts. --- docs/next/CHANGELOG.md | 1 + .../src/content/docs/configuration.mdx | 10 ++- .../website/src/data/config-reference.json | 24 +++++ src/app/input/navigate.rs | 89 +++++++++++++++++++ src/config/keybinds.rs | 12 +++ src/config/model.rs | 28 ++++++ 6 files changed, 163 insertions(+), 1 deletion(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 72d3b0f52a..dba64077b6 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 ce4db4e2be..a170613f0e 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 a4732ffc12..2147192d51 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -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", diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 641680351d..73dc5299c0 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); @@ -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"]); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 199076f03c..de7321214c 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, } @@ -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 ba700731eb..9bd83a9624 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, #[serde(skip_serializing_if = "Option::is_none")] + resize_pane_left: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resize_pane_down: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resize_pane_up: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resize_pane_right: Option, + #[serde(skip_serializing_if = "Option::is_none")] toggle_sidebar: Option, #[serde(skip_serializing_if = "Option::is_none")] indexed: Option, @@ -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(), From ca74d6c083b3c987f6145b1a4e4b622d17bb7dbd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 9 Aug 2026 17:30:20 +0200 Subject: [PATCH 2/2] fix: list resize keybindings in the default config and keybind help --- src/main.rs | 4 ++++ src/ui/keybind_help.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/main.rs b/src/main.rs index 2db4f8aa5e..02ecb8d35c 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 25c999fa3d..b390ab86bf 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 { 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"),