feat: add bind and unbind actions (#1121)

* feat: add `bind` and `unbind` actions

Add two new actions that allow programmatic (re)binding of keys at
runtime, both through `--bind` keybindings and the IPC/listen socket:

- `bind(key:action[+action][,key:action…])` adds one or more bindings,
  reusing the same parsing/merging logic as the `--bind` CLI option.
  Existing bindings for the same key are replaced.
- `unbind(key[,key…])` removes the bindings for a comma-separated list
  of keys, mirroring fzf's `unbind(...)` semantics.

Because the `Action` enum derives serde when the `listen` feature is
enabled, both actions are drivable over the IPC socket for free.

Covered by unit tests for parsing (`event_tests.rs`) and dispatch
(`app_tests.rs`), plus IPC integration tests (`listen.rs`). Manpage and
ARCHITECTURE.md updated with the new actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYqqomFjYqqXd5NvkVxfbQ

* feat: add `bind` and `unbind` actions

* chore: generate files

* fixes

* fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
LoricAndre 2026-07-18 16:22:41 +02:00 committed by GitHub
parent ff98133bbd
commit ad97bfa7de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 219 additions and 15 deletions

View file

@ -979,6 +979,7 @@ Event::Action(a) → handle_action(a) → Vec<Event>
| Conditional | `IfQueryEmpty(then, else?)`, `IfQueryNotEmpty(then, else?)`, `IfNonMatched(then, else?)` |
| Lifecycle | `Accept(key?)`, `Abort`, `Cancel` |
| UI | `ClearScreen`, `Redraw`, `SetHeader(text?)`, `SelectRow(n)` |
| Bindings | `Bind(spec)` — add `key:action[+action]` bindings at runtime; `Unbind(keys)` — remove bindings for a comma-separated key list |
| Custom | `Custom(ActionCallback)` — async or sync closure receiving `&mut App` |
`Action::Custom(ActionCallback)` is the library extension point: callers can inject arbitrary async logic into the action pipeline without forking skim.

View file

@ -858,6 +858,8 @@ Actions can take arguments, specified either between parentheses `reload(ls)` or
.br
* beginning\-of\-line: ctrl\-a home
.br
* bind(...): *arg is a comma\-separated list of `key:action[+action]` bindings to add (same syntax as \-\-bind)
.br
* clear\-screen: ctrl\-l
.br
* delete\-char: del
@ -952,6 +954,8 @@ Actions can take arguments, specified either between parentheses `reload(ls)` or
.br
* top
.br
* unbind(...): *arg is a comma\-separated list of keys to unbind
.br
* unix\-line\-discard: ctrl\-u
.br
* unix\-word\-rubout: ctrl\-w

View file

@ -30,7 +30,7 @@ impl DerefMut for KeyMap {
impl From<&str> for KeyMap {
fn from(value: &str) -> Self {
parse_keymaps(value.split(','))
parse_keymaps(split_top_level(value, ',').into_iter())
}
}
@ -41,6 +41,11 @@ impl Default for KeyMap {
}
impl KeyMap {
/// Adds keymaps from a comma-separated string.
pub(crate) fn add_keymaps_str(&mut self, source: &str) {
self.add_keymaps(split_top_level(source, ',').into_iter());
}
/// Adds keymaps from the source, parsing them using `parse_keymap`
pub fn add_keymaps<'a, T>(&mut self, source: T)
where
@ -203,13 +208,33 @@ where
res
}
fn split_top_level(value: &str, separator: char) -> Vec<&str> {
let mut depth = 0_u32;
let mut start = 0;
let mut parts = Vec::new();
for (index, ch) in value.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ if ch == separator && depth == 0 => {
parts.push(&value[start..index]);
start = index + ch.len_utf8();
}
_ => {}
}
}
parts.push(&value[start..]);
parts
}
/// Parses an action chain, separated by '+'s into the corresponding actions
///
/// # Errors
/// Returns an error if the action chain is empty or contains only unknown actions.
pub fn parse_action_chain(action_chain: &str) -> Result<Vec<Action>> {
let mut actions: Vec<Action> = vec![];
let mut split = action_chain.split('+');
let mut split = split_top_level(action_chain, '+').into_iter();
while let Some(mut s) = split.next().map(String::from) {
if (s.starts_with("if-") || s.ends_with('{'))

View file

@ -149,6 +149,28 @@ fn keymap_from_str_parses_bindings() {
assert!(keymap.get(&parse_key("enter").unwrap()).is_some());
}
#[test]
fn keymap_from_str_preserves_nested_bind_separators() {
let keymap = KeyMap::from("ctrl-z:bind(ctrl-x:abort+up),ctrl-w:unbind(ctrl-x,ctrl-y)");
assert_eq!(
keymap.get(&parse_key("ctrl-z").unwrap()),
Some(&vec![Bind("ctrl-x:abort+up".into())])
);
assert_eq!(
keymap.get(&parse_key("ctrl-w").unwrap()),
Some(&vec![Unbind("ctrl-x,ctrl-y".into())])
);
}
#[test]
fn parse_action_chain_preserves_nested_bind_chain() {
assert_eq!(
parse_action_chain("bind(ctrl-x:abort+up)").unwrap(),
vec![Bind("ctrl-x:abort+up".into())]
);
}
#[test]
fn parse_keymaps_collects_iterator() {
let keymap = parse_keymaps(["ctrl-x:abort", "up:up"].into_iter());

View file

@ -131,6 +131,7 @@ const ACTIONS_SS: &str = "
* backward-kill-word: alt-bs
* backward-word: alt-b shift-left
* beginning-of-line: ctrl-a home
* bind(...): *arg is a comma-separated list of `key:action[+action]` bindings to add (same syntax as --bind)
* clear-screen: ctrl-l
* delete-char: del
* delete-char/eof: ctrl-d
@ -178,6 +179,7 @@ const ACTIONS_SS: &str = "
* toggle-sort
* toggle+up: btab shift-tab
* top
* unbind(...): *arg is a comma-separated list of keys to unbind
* unix-line-discard: ctrl-u
* unix-word-rubout: ctrl-w
* up: ctrl-k ctrl-p up

View file

@ -1308,7 +1308,7 @@ impl SkimOptions {
}
self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| {
res.add_keymaps(part.split(','));
res.add_keymaps_str(part);
res
});

View file

@ -704,17 +704,8 @@ impl App {
#[allow(clippy::too_many_lines)]
fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
use Action::{
Abort, Accept, AddChar, AppendAndSelect, BackwardChar, BackwardDeleteChar, BackwardDeleteCharEof,
BackwardKillWord, BackwardWord, BeginningOfLine, Cancel, ClearScreen, Custom, DeleteChar, DeleteCharEof,
DeselectAll, Down, EndOfLine, Execute, ExecuteSilent, First, ForwardChar, ForwardWord, HalfPageDown,
HalfPageUp, IfNonMatched, IfQueryEmpty, IfQueryNotEmpty, Ignore, KillLine, KillWord, Last, NextHistory,
PageDown, PageUp, PreviewDown, PreviewLeft, PreviewPageDown, PreviewPageUp, PreviewRight, PreviewUp,
PreviousHistory, Redraw, RefreshCmd, RefreshPreview, Reload, RestartMatcher, RotateMode, ScrollLeft,
ScrollRight, Select, SelectAll, SelectRow, SetHeader, SetPreviewCmd, SetQuery, Toggle, ToggleAll, ToggleIn,
ToggleInteractive, ToggleOut, TogglePreview, TogglePreviewWrap, ToggleSort, Top, UnixLineDiscard,
UnixWordRubout, Up, Yank,
};
#[allow(clippy::enum_glob_use)]
use Action::*;
use ratatui::widgets::ListDirection::{BottomToTop, TopToBottom};
match act {
Abort | Accept(_) => {
@ -771,6 +762,12 @@ impl App {
BeginningOfLine => {
self.input.move_cursor_to(0);
}
Bind(spec) => {
// Bind one or more `key:action[+action]` pairs, reusing the same
// parsing/merging logic as the `--bind` CLI option. Existing
// bindings for the same keys are replaced.
self.options.keymap.add_keymaps_str(spec);
}
Cancel => {
self.matcher_control.kill();
self.preview.kill();
@ -1158,6 +1155,17 @@ impl App {
self.options.no_sort = !self.options.no_sort;
self.restart_matcher(true);
}
Unbind(spec) => {
// Remove the bindings for one or more keys.
for key in spec.split(',') {
match crate::binds::parse_key(key) {
Ok(parsed) => {
self.options.keymap.remove(&parsed);
}
Err(err) => debug!("Failed to unbind key {key}: {err}"),
}
}
}
UnixLineDiscard => {
if !self.input.delete_to_beginning().is_empty() {
return Ok(self.on_query_changed());

View file

@ -643,6 +643,76 @@ fn custom_action_runs_async_callback() {
assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort))));
}
#[test]
fn bind_action_adds_action_chain() {
let mut app = App::default();
let key = crate::binds::parse_key("ctrl-x").unwrap();
// Not bound by default.
app.options.keymap.remove(&key);
assert!(app.options.keymap.get(&key).is_none());
act(&mut app, Action::Bind("ctrl-x:abort+up".to_string()));
assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Abort, Action::Up(1)]));
}
#[test]
fn bind_action_binds_multiple_comma_separated_keys() {
let mut app = App::default();
act(&mut app, Action::Bind("ctrl-x:abort,ctrl-y:select-all".to_string()));
let key_x = crate::binds::parse_key("ctrl-x").unwrap();
let key_y = crate::binds::parse_key("ctrl-y").unwrap();
assert_eq!(app.options.keymap.get(&key_x), Some(&vec![Action::Abort]));
assert_eq!(app.options.keymap.get(&key_y), Some(&vec![Action::SelectAll]));
}
#[test]
fn bind_action_replaces_existing_binding() {
let mut app = App::default();
// Enter is bound to Accept(None) by default.
let key = crate::binds::parse_key("enter").unwrap();
assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Accept(None)]));
act(&mut app, Action::Bind("enter:abort".to_string()));
assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Abort]));
}
#[test]
fn unbind_action_removes_keymap_entry() {
let mut app = App::default();
let key = crate::binds::parse_key("enter").unwrap();
assert!(app.options.keymap.get(&key).is_some());
act(&mut app, Action::Unbind("enter".to_string()));
assert!(app.options.keymap.get(&key).is_none());
}
#[test]
fn unbind_action_removes_multiple_comma_separated_keys() {
let mut app = App::default();
let key_up = crate::binds::parse_key("up").unwrap();
let key_down = crate::binds::parse_key("down").unwrap();
assert!(app.options.keymap.get(&key_up).is_some());
assert!(app.options.keymap.get(&key_down).is_some());
act(&mut app, Action::Unbind("up,down".to_string()));
assert!(app.options.keymap.get(&key_up).is_none());
assert!(app.options.keymap.get(&key_down).is_none());
}
#[test]
fn unbind_action_ignores_unparseable_keys() {
let mut app = App::default();
// A bogus key name is skipped without panicking; valid keys are still removed.
let key = crate::binds::parse_key("enter").unwrap();
act(&mut app, Action::Unbind("not-a-key,enter".to_string()));
assert!(app.options.keymap.get(&key).is_none());
}
#[test]
fn handle_key_maps_plain_char_to_add_char() {
let mut app = App::default();

View file

@ -174,6 +174,8 @@ pub enum Action {
BackwardWord,
/// Move cursor to beginning of line
BeginningOfLine,
/// Bind one or more keys to action chains (`key:action[+action][,key:action…]`)
Bind(String),
/// Cancel current operation
Cancel,
/// Clear the screen
@ -282,6 +284,8 @@ pub enum Action {
ToggleSort,
/// Jump to first item in list (alias for First)
Top,
/// Unbind one or more keys (`key[,key…]`)
Unbind(String),
/// Discard line (unix-style)
UnixLineDiscard,
/// Delete word backward (unix-style)
@ -347,7 +351,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
}
} else if matches!(
action,
"add-char" | "execute" | "execute-silent" | "set-preview-cmd" | "set-query"
"add-char" | "bind" | "execute" | "execute-silent" | "set-preview-cmd" | "set-query" | "unbind"
) && arg.is_none()
{
None
@ -365,6 +369,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
"backward-kill-word" => Some(BackwardKillWord),
"backward-word" => Some(BackwardWord),
"beginning-of-line" => Some(BeginningOfLine),
"bind" => Some(Bind(arg.unwrap_or_default())),
"cancel" => Some(Cancel),
"clear-screen" => Some(ClearScreen),
"delete-char" => Some(DeleteChar),
@ -416,6 +421,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
"toggle-preview-wrap" => Some(TogglePreviewWrap),
"toggle-sort" => Some(ToggleSort),
"top" => Some(Top),
"unbind" => Some(Unbind(arg.unwrap_or_default())),
"unix-line-discard" => Some(UnixLineDiscard),
"unix-word-rubout" => Some(UnixWordRubout),
"up" => Some(Up(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),

View file

@ -112,6 +112,39 @@ fn parse_optional_arg_actions() {
);
}
#[test]
fn parse_bind_and_unbind_actions() {
// `bind` captures the whole `key:action` spec as its string argument, using
// either the paren or colon form.
assert_eq!(
parse_action("bind(ctrl-a:accept)"),
Some(Action::Bind("ctrl-a:accept".to_string()))
);
assert_eq!(
parse_action("bind:ctrl-a:accept"),
Some(Action::Bind("ctrl-a:accept".to_string()))
);
// `unbind` captures a comma-separated list of keys, like fzf's `unbind(...)`.
assert_eq!(
parse_action("unbind(ctrl-a)"),
Some(Action::Unbind("ctrl-a".to_string()))
);
assert_eq!(
parse_action("unbind(ctrl-a,ctrl-b)"),
Some(Action::Unbind("ctrl-a,ctrl-b".to_string()))
);
}
#[test]
fn parse_bind_and_unbind_require_argument() {
// Without an argument both actions are rejected rather than silently
// producing an empty binding.
assert_eq!(parse_action("bind"), None);
assert_eq!(parse_action("bind:"), None);
assert_eq!(parse_action("unbind"), None);
assert_eq!(parse_action("unbind:"), None);
}
#[test]
fn parse_if_chains_then_only() {
assert_eq!(

View file

@ -420,3 +420,36 @@ fn listen_yank() -> std::io::Result<()> {
);
Ok(())
}
// Bind a previously-unbound key over IPC, then trigger it from the keyboard.
#[test]
fn listen_bind() -> std::io::Result<()> {
let (tmux, mut stream) = setup("bind", &[])?;
sk_test!(@expand tmux;
@capture[2]starts_with("> a");
// The header acknowledges that the preceding bind has been processed
// before terminal key events are sent through a separate channel.
send(&mut stream, "bind(ctrl-x:up)+set-header(ready)")?;
@capture[*]trim().eq("ready");
@keys Ctrl(&Key('x')), Ctrl(&Key('x'));
@capture[*]starts_with("> c");
);
Ok(())
}
// Unbind a key over IPC so its keypress becomes a no-op. The trailing `x`
// distinguishes the two cases: if ctrl-u were still bound to unix-line-discard
// the query would read `> x`, but with it unbound the query is preserved.
#[test]
fn listen_unbind() -> std::io::Result<()> {
let (tmux, mut stream) = setup("unbind", &[])?;
sk_test!(@expand tmux;
@keys Str("hello");
@capture[0]trim().eq("> hello");
send(&mut stream, "unbind(ctrl-u)+set-header(ready)")?;
@capture[*]trim().eq("ready");
@keys Ctrl(&Key('u')), Key('x');
@capture[0]trim().eq("> hellox");
);
Ok(())
}