aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 13:16:00 +0200
committeruser <user@clank>2026-08-12 13:16:00 +0200
commit733ac78ca77cc61b52bb627e4c02eafa80282944 (patch)
treef553706a88fa05f8f1ade8efb39b5475ded160d3
parentsl0p(pane-finder): document a single-chord binding (diff)
downloadsl0ppij-733ac78ca77cc61b52bb627e4c02eafa80282944.tar.gz
sl0ppij-733ac78ca77cc61b52bb627e4c02eafa80282944.tar.xz
sl0ppij-733ac78ca77cc61b52bb627e4c02eafa80282944.zip
sl0p: add close_if_focused to LaunchOrFocusPlugin
Upstream's action focuses an already-running plugin, so a chord bound to it can only ever open one -- there is no way to dismiss with the same key. This cannot be fixed in a plugin. Keybindings are resolved into actions in route.rs before a key reaches a pane, so the launching chord never arrives at the plugin. intercept_key_presses does not help either: the intercept lives in screen.rs and only sees keys that already became Action::Write, i.e. keys destined for a pane. Confirmed empirically -- an intercepting build received Ctrl-u but never Ctrl-;. Closes only when the plugin is the focused pane in the client's ACTIVE tab, so the chord cannot close whatever pane the user happens to be on. A plugin that exists but is hidden behind a floating-layer toggle, or is focused in a different tab, is revealed or brought over as before. Defaults to false, so every existing binding is unaffected. Config-only, with no protobuf representation: defaulted at both boundaries and dropped when an action crosses the plugin API, so no prost regeneration is needed. The 24 snapshot lines are the new field appearing in serialised keybind dumps.
-rw-r--r--README.md28
-rw-r--r--zellij-server/src/route.rs2
-rw-r--r--zellij-server/src/screen.rs43
-rw-r--r--zellij-utils/src/input/actions.rs11
-rw-r--r--zellij-utils/src/input/config.rs61
-rw-r--r--zellij-utils/src/ipc/protobuf_conversion.rs4
-rw-r--r--zellij-utils/src/ipc/tests/roundtrip_tests.rs3
-rw-r--r--zellij-utils/src/kdl/mod.rs4
-rw-r--r--zellij-utils/src/plugin_api/action.rs2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap6
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap6
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap6
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap6
13 files changed, 180 insertions, 2 deletions
diff --git a/README.md b/README.md
index 67239fec..13acfaae 100644
--- a/README.md
+++ b/README.md
@@ -317,6 +317,34 @@ without refreshing the `insta` snapshots. If you change anything about how frame
pane titles render, expect to regenerate snapshots — and read the diffs before
accepting them.
+### `close_if_focused` on `LaunchOrFocusPlugin`
+
+Makes one chord both open and dismiss a plugin:
+
+```kdl
+bind "Ctrl ;" {
+ LaunchOrFocusPlugin "file:~/.config/zellij/plugins/pane-finder.wasm" {
+ floating true
+ close_if_focused true
+ };
+}
+```
+
+Upstream's action focuses an already-running plugin, so the launch chord can only
+ever open it. A plugin cannot fix this itself: keybindings are resolved into
+actions in `route.rs` before a key can reach a pane, so the chord that launched a
+plugin never arrives at it — not even via `intercept_key_presses`, which only
+sees keys already destined for a pane.
+
+Closes only when the plugin is the **focused pane in the client's active tab**.
+Hidden behind a floating-layer toggle, or focused in another tab, it is revealed
+or brought over instead — so the chord can never close whatever pane you happen
+to be on.
+
+Defaults to `false`, so existing bindings behave exactly as before. It is a
+config-only option with no protobuf representation, and is dropped when an action
+crosses the plugin API.
+
## Bundled plugin: `sl0p/zj-pane-finder`
A floating fuzzy finder for every pane in the session, filtering on pane
diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs
index 85312048..8783af72 100644
--- a/zellij-server/src/route.rs
+++ b/zellij-server/src/route.rs
@@ -1428,6 +1428,7 @@ pub(crate) fn route_action(
close_replaced_pane,
skip_cache,
tab_id,
+ close_if_focused,
} => {
senders
.send_to_screen(ScreenInstruction::LaunchOrFocusPlugin(
@@ -1441,6 +1442,7 @@ pub(crate) fn route_action(
client_id,
Some(NotificationEnd::new(completion_tx)),
tab_id,
+ close_if_focused,
))
.with_context(err_context)?;
},
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index 0ec911f6..a5d0fd30 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -699,6 +699,7 @@ pub enum ScreenInstruction {
ClientId,
Option<NotificationEnd>,
Option<usize>, // tab_id
+ bool, // close_if_focused
), // bools are: should_float, move_to_focused_tab, should_open_in_place, close_replaced_pane, Option<PaneId> is the pane id to replace, bool following it is skip_cache
LaunchPlugin(
RunPluginOrAlias,
@@ -5747,6 +5748,28 @@ impl Screen {
self.render(None)
}
+ /// Whether a plugin matching `run_plugin` is the client's currently focused
+ /// pane.
+ ///
+ /// Only the client's own active tab is considered: a plugin focused in some
+ /// other tab is not what the user is looking at, so the chord should bring it
+ /// here rather than close it.
+ pub fn plugin_pane_is_focused(
+ &self,
+ run_plugin: &RunPluginOrAlias,
+ client_id: ClientId,
+ ) -> bool {
+ let Ok(active_tab) = self.get_active_tab(client_id) else {
+ return false;
+ };
+ match (
+ active_tab.find_plugin(run_plugin),
+ active_tab.get_active_pane_id(client_id),
+ ) {
+ (Some(plugin_pane_id), Some(focused_pane_id)) => plugin_pane_id == focused_pane_id,
+ _ => false,
+ }
+ }
pub fn focus_plugin_pane(
&mut self,
run_plugin: &RunPluginOrAlias,
@@ -10323,6 +10346,7 @@ pub(crate) fn screen_thread_main(
client_id,
mut completion_tx,
explicit_tab_id,
+ close_if_focused,
) => match pane_id_to_replace {
Some(pane_id_to_replace) if should_open_in_place => {
let resolved_tab_index =
@@ -10374,7 +10398,24 @@ pub(crate) fn screen_thread_main(
.or(client_id_and_focused_tab);
match resolved_tab_and_client {
Some((tab_index, client_id)) => {
- if screen.focus_plugin_pane(
+ // Dismiss on the same chord that opened it, so one
+ // key toggles. Has to happen here rather than in the
+ // plugin: keybindings are resolved into actions
+ // before a key can reach a pane, so the launching
+ // chord never arrives at the plugin at all.
+ if close_if_focused
+ && screen.plugin_pane_is_focused(&run_plugin, client_id)
+ {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .close_focused_pane(client_id, completion_tx),
+ ?
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ } else if screen.focus_plugin_pane(
&run_plugin,
should_float,
move_to_focused_tab,
diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs
index 95409609..27dde42e 100644
--- a/zellij-utils/src/input/actions.rs
+++ b/zellij-utils/src/input/actions.rs
@@ -387,6 +387,15 @@ pub enum Action {
close_replaced_pane: bool,
skip_cache: bool,
tab_id: Option<usize>,
+ /// Close the plugin instead of focusing it when it is ALREADY the focused
+ /// pane, so one chord both opens and dismisses it.
+ ///
+ /// Needed because a plugin cannot do this itself: keybindings are
+ /// resolved into actions before a key can reach a pane, so the chord that
+ /// launched a plugin never arrives at it -- not even through
+ /// intercept_key_presses, which only sees keys already destined for a
+ /// pane.
+ close_if_focused: bool,
},
/// Returns: Plugin pane ID (format: plugin_<id>)
LaunchPlugin {
@@ -1889,6 +1898,8 @@ impl Action {
close_replaced_pane,
skip_cache: skip_plugin_cache,
tab_id,
+ // a one-shot CLI invocation has nothing to toggle
+ close_if_focused: false,
}])
},
CliAction::LaunchPlugin {
diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs
index 08bfa4b0..880b3fa9 100644
--- a/zellij-utils/src/input/config.rs
+++ b/zellij-utils/src/input/config.rs
@@ -680,7 +680,10 @@ pub async fn watch_layout_dir_changes<F, Fut>(
#[cfg(test)]
mod config_test {
use super::*;
- use crate::data::{InputMode, Palette, PaletteColor, StyleDeclaration, Styling};
+ use crate::data::{
+ BareKey, InputMode, KeyWithModifier, Palette, PaletteColor, StyleDeclaration, Styling,
+ };
+ use crate::input::actions::Action;
use crate::input::layout::RunPlugin;
use crate::input::options::{Clipboard, OnForceClose};
use crate::input::theme::{FrameConfig, Theme, Themes, UiConfig};
@@ -1474,6 +1477,62 @@ mod config_test {
}
#[test]
+ fn launch_or_focus_plugin_close_if_focused_is_parsed() {
+ // A keybinding-only option: it lets one chord both open and dismiss a
+ // plugin, which the plugin cannot do itself because keybindings are
+ // resolved into actions before a key can reach a pane.
+ let config = r#"
+ keybinds {
+ normal {
+ bind "Ctrl a" {
+ LaunchOrFocusPlugin "zellij:about" {
+ floating true
+ close_if_focused true
+ };
+ }
+ bind "Ctrl b" {
+ LaunchOrFocusPlugin "zellij:about" {
+ floating true
+ };
+ }
+ }
+ }
+ "#;
+ let config = Config::from_kdl(config, None).unwrap();
+ let actions_for = |key: KeyWithModifier| {
+ config
+ .keybinds
+ .get_actions_for_key_in_mode(&InputMode::Normal, &key)
+ .cloned()
+ .unwrap_or_default()
+ };
+ let with_option = actions_for(KeyWithModifier::new(BareKey::Char('a')).with_ctrl_modifier());
+ let without = actions_for(KeyWithModifier::new(BareKey::Char('b')).with_ctrl_modifier());
+ assert!(
+ matches!(
+ with_option.first(),
+ Some(Action::LaunchOrFocusPlugin {
+ close_if_focused: true,
+ ..
+ })
+ ),
+ "close_if_focused should be parsed as true, got {:?}",
+ with_option
+ );
+ assert!(
+ matches!(
+ without.first(),
+ Some(Action::LaunchOrFocusPlugin {
+ close_if_focused: false,
+ ..
+ })
+ ),
+ "omitting it must default to false, so existing bindings are unchanged, got {:?}",
+ without
+ );
+ }
+
+ #[test]
fn can_define_ui_configuration_in_configfile() {
let config_contents = r#"
ui {
diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs
index 8bd43081..c3cf4d8d 100644
--- a/zellij-utils/src/ipc/protobuf_conversion.rs
+++ b/zellij-utils/src/ipc/protobuf_conversion.rs
@@ -1538,6 +1538,8 @@ impl From<crate::input::actions::Action>
close_replaced_pane,
skip_cache,
tab_id,
+ // close_if_focused is deliberately dropped here: it has no
+ // protobuf representation, and is only meaningful for a keybinding
..
} => ActionType::LaunchOrFocusPlugin(LaunchOrFocusPluginAction {
plugin: Some(plugin.into()),
@@ -2439,6 +2441,8 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action>
close_replaced_pane: launch_plugin_action.close_replaced_pane,
skip_cache: launch_plugin_action.skip_cache,
tab_id: launch_plugin_action.tab_id.map(|t| t as usize),
+ // no protobuf representation; a config-only option
+ close_if_focused: false,
})
},
ActionType::LaunchPlugin(launch_plugin_action) => {
diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs
index 588b465e..fb952d05 100644
--- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs
+++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs
@@ -2378,6 +2378,7 @@ fn test_client_messages() {
close_replaced_pane: false,
skip_cache: true,
tab_id: None,
+ close_if_focused: false,
},
terminal_id: Some(1),
client_id: Some(100),
@@ -2392,6 +2393,7 @@ fn test_client_messages() {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
terminal_id: Some(1),
client_id: Some(100),
@@ -2750,6 +2752,7 @@ fn test_client_messages() {
close_replaced_pane: false,
skip_cache: false,
tab_id: Some(2),
+ close_if_focused: false,
},
terminal_id: Some(1),
client_id: Some(100),
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index 3213b99d..b2b576dc 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -2132,6 +2132,9 @@ impl TryFrom<(&KdlNode, &Options)> for Action {
let skip_plugin_cache = command_metadata
.and_then(|c_m| kdl_child_bool_value_for_entry(c_m, "skip_plugin_cache"))
.unwrap_or(false);
+ let close_if_focused = command_metadata
+ .and_then(|c_m| kdl_child_bool_value_for_entry(c_m, "close_if_focused"))
+ .unwrap_or(false);
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let configuration = KdlLayoutParser::parse_plugin_user_configuration(&kdl_action)?;
let initial_cwd = kdl_get_string_property_or_child_value!(kdl_action, "cwd")
@@ -2158,6 +2161,7 @@ impl TryFrom<(&KdlNode, &Options)> for Action {
close_replaced_pane,
skip_cache: skip_plugin_cache,
tab_id: None,
+ close_if_focused,
})
},
"LaunchPlugin" => {
diff --git a/zellij-utils/src/plugin_api/action.rs b/zellij-utils/src/plugin_api/action.rs
index b660dd96..9766cc59 100644
--- a/zellij-utils/src/plugin_api/action.rs
+++ b/zellij-utils/src/plugin_api/action.rs
@@ -705,6 +705,8 @@ impl TryFrom<ProtobufAction> for Action {
close_replaced_pane: false,
skip_cache: skip_plugin_cache,
tab_id: None,
+ // no protobuf representation; a config-only option
+ close_if_focused: false,
})
},
_ => Err("Wrong payload for Action::LaunchOrFocusPlugin"),
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
index ae91dafc..ba0a450f 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
@@ -4229,6 +4229,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4271,6 +4272,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4404,6 +4406,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4496,6 +4499,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4571,6 +4575,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4625,6 +4630,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
index f7f87561..c1aade01 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
@@ -4229,6 +4229,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4271,6 +4272,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4404,6 +4406,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4496,6 +4499,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4571,6 +4575,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4625,6 +4630,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
index 05b1553e..e39b5a54 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
@@ -4229,6 +4229,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4271,6 +4272,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4404,6 +4406,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4496,6 +4499,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4571,6 +4575,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4625,6 +4630,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
index 67dade83..515d46b1 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
@@ -4229,6 +4229,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4271,6 +4272,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4404,6 +4406,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4496,6 +4499,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4571,6 +4575,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,
@@ -4625,6 +4630,7 @@ Config {
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
+ close_if_focused: false,
},
SwitchToMode {
input_mode: Normal,