aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-02-11 12:25:52 -0800
committerGitHub <noreply@github.com>2026-02-11 12:25:52 -0800
commit05ffb902161e6d2111efe70a7bc488cfc550ff21 (patch)
tree0d0b1c7c4670236de8fea375cf619bee0d9618be /crates
parentfix(tui): enter in vim normal mode, shift-tab keybind (#3158) (diff)
downloadatuin-05ffb902161e6d2111efe70a7bc488cfc550ff21.zip
feat: expand keybinding system with vim motions, media keys, and inspector improvements (#3161)
Addresses the keybinding audit (#3157): - Add missing vim-normal bindings: 0, $, w, b, e, x, dd, D, C - Add new CursorWordEnd action for vim 'e' motion - Add VimChangeToEnd action for vim 'C' motion - Add Media key support (play, pause, stop, volume, etc.) - Refactor inspector keymap to be minimal but respect enter_accept and provide vim-style j/k navigation for vim users - Add $ to special character parsing - Replace eprintln! with tracing::warn! for config errors - Document shifted punctuation keys and media keys To be rebased once #3158 is merged
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin/src/command/client/search/cursor.rs62
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs10
-rw-r--r--crates/atuin/src/command/client/search/keybindings/actions.rs6
-rw-r--r--crates/atuin/src/command/client/search/keybindings/defaults.rs52
-rw-r--r--crates/atuin/src/command/client/search/keybindings/key.rs33
5 files changed, 157 insertions, 6 deletions
diff --git a/crates/atuin/src/command/client/search/cursor.rs b/crates/atuin/src/command/client/search/cursor.rs
index ebe8bbe5..c1cdfee4 100644
--- a/crates/atuin/src/command/client/search/cursor.rs
+++ b/crates/atuin/src/command/client/search/cursor.rs
@@ -151,6 +151,68 @@ impl Cursor {
self.index = word_jumper.get_prev_word_pos(&self.source, self.index);
}
+ /// Move cursor to the end of the current/next word (vim `e` motion).
+ ///
+ /// If cursor is in the middle of a word, moves to the end of that word.
+ /// If cursor is at the end of a word (or on whitespace), moves to the
+ /// end of the next word.
+ pub fn word_end(&mut self, word_chars: &str) {
+ let len = self.source.len();
+ if self.index >= len {
+ return;
+ }
+
+ let chars: Vec<char> = self.source.chars().collect();
+ let mut char_idx = self.source[..self.index].chars().count();
+
+ if char_idx >= chars.len() {
+ return;
+ }
+
+ let current = chars[char_idx];
+
+ // Check if we're at a word boundary (end of current word or on whitespace)
+ let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || {
+ let next = chars[char_idx + 1];
+ next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next))
+ };
+
+ // If at word boundary, advance past it and skip whitespace to find next word
+ if at_word_boundary {
+ char_idx += 1;
+ while char_idx < chars.len() && chars[char_idx].is_whitespace() {
+ char_idx += 1;
+ }
+ }
+
+ // If we've gone past end, go to end of string
+ if char_idx >= chars.len() {
+ self.index = len;
+ return;
+ }
+
+ // Find end of word: advance until next char is whitespace or different word type
+ let in_word_chars = word_chars.contains(chars[char_idx]);
+ while char_idx < chars.len() {
+ let next_idx = char_idx + 1;
+ if next_idx >= chars.len() {
+ // At last char, move past it
+ char_idx = next_idx;
+ break;
+ }
+ let next_c = chars[next_idx];
+ if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) {
+ // Next char is start of new word/whitespace, so current char is end
+ char_idx = next_idx;
+ break;
+ }
+ char_idx += 1;
+ }
+
+ // Convert char index back to byte index
+ self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum();
+ }
+
pub fn insert(&mut self, c: char) {
self.source.insert(self.index, c);
self.index += c.len_utf8();
diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs
index 32110144..7cddb163 100644
--- a/crates/atuin/src/command/client/search/interactive.rs
+++ b/crates/atuin/src/command/client/search/interactive.rs
@@ -438,6 +438,10 @@ impl State {
.next_word(&settings.word_chars, settings.word_jump_mode);
InputAction::Continue
}
+ Action::CursorWordEnd => {
+ self.search.input.word_end(&settings.word_chars);
+ InputAction::Continue
+ }
Action::CursorStart => {
self.search.input.start();
InputAction::Continue
@@ -690,6 +694,12 @@ impl State {
self.keymap_mode = KeymapMode::VimInsert;
InputAction::Continue
}
+ Action::VimChangeToEnd => {
+ self.search.input.clear_to_end();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
Action::EnterPrefixMode => {
self.prefix = true;
InputAction::Continue
diff --git a/crates/atuin/src/command/client/search/keybindings/actions.rs b/crates/atuin/src/command/client/search/keybindings/actions.rs
index 5242b383..fae811d6 100644
--- a/crates/atuin/src/command/client/search/keybindings/actions.rs
+++ b/crates/atuin/src/command/client/search/keybindings/actions.rs
@@ -10,6 +10,7 @@ pub enum Action {
CursorRight,
CursorWordLeft,
CursorWordRight,
+ CursorWordEnd,
CursorStart,
CursorEnd,
@@ -60,6 +61,7 @@ pub enum Action {
VimEnterInsertAtStart,
VimEnterInsertAtEnd,
VimSearchInsert,
+ VimChangeToEnd,
EnterPrefixMode,
// Inspector
@@ -92,6 +94,7 @@ impl Action {
"cursor-right" => Ok(Action::CursorRight),
"cursor-word-left" => Ok(Action::CursorWordLeft),
"cursor-word-right" => Ok(Action::CursorWordRight),
+ "cursor-word-end" => Ok(Action::CursorWordEnd),
"cursor-start" => Ok(Action::CursorStart),
"cursor-end" => Ok(Action::CursorEnd),
@@ -134,6 +137,7 @@ impl Action {
"vim-enter-insert-at-start" => Ok(Action::VimEnterInsertAtStart),
"vim-enter-insert-at-end" => Ok(Action::VimEnterInsertAtEnd),
"vim-search-insert" => Ok(Action::VimSearchInsert),
+ "vim-change-to-end" => Ok(Action::VimChangeToEnd),
"enter-prefix-mode" => Ok(Action::EnterPrefixMode),
"inspect-previous" => Ok(Action::InspectPrevious),
@@ -152,6 +156,7 @@ impl Action {
Action::CursorRight => "cursor-right".to_string(),
Action::CursorWordLeft => "cursor-word-left".to_string(),
Action::CursorWordRight => "cursor-word-right".to_string(),
+ Action::CursorWordEnd => "cursor-word-end".to_string(),
Action::CursorStart => "cursor-start".to_string(),
Action::CursorEnd => "cursor-end".to_string(),
@@ -196,6 +201,7 @@ impl Action {
Action::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(),
Action::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(),
Action::VimSearchInsert => "vim-search-insert".to_string(),
+ Action::VimChangeToEnd => "vim-change-to-end".to_string(),
Action::EnterPrefixMode => "enter-prefix-mode".to_string(),
Action::InspectPrevious => "inspect-previous".to_string(),
diff --git a/crates/atuin/src/command/client/search/keybindings/defaults.rs b/crates/atuin/src/command/client/search/keybindings/defaults.rs
index e6d36b80..121c59fe 100644
--- a/crates/atuin/src/command/client/search/keybindings/defaults.rs
+++ b/crates/atuin/src/command/client/search/keybindings/defaults.rs
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use atuin_client::settings::{KeyBindingConfig, Settings};
+use tracing::warn;
use super::actions::Action;
use super::conditions::{ConditionAtom, ConditionExpr};
@@ -272,6 +273,19 @@ pub fn default_vim_normal_keymap(settings: &Settings) -> Keymap {
km.bind(key("h"), Action::CursorLeft);
km.bind(key("l"), Action::CursorRight);
+ // --- Vim cursor movement ---
+ km.bind(key("0"), Action::CursorStart);
+ km.bind(key("$"), Action::CursorEnd);
+ km.bind(key("w"), Action::CursorWordRight);
+ km.bind(key("b"), Action::CursorWordLeft);
+ km.bind(key("e"), Action::CursorWordEnd);
+
+ // --- Vim editing ---
+ km.bind(key("x"), Action::DeleteCharAfter);
+ km.bind(key("d d"), Action::ClearLine);
+ km.bind(key("D"), Action::ClearToEnd);
+ km.bind(key("C"), Action::VimChangeToEnd);
+
// --- Mode switching ---
km.bind(key("?"), Action::VimSearchInsert);
km.bind(key("/"), Action::VimSearchInsert);
@@ -334,10 +348,17 @@ pub fn default_vim_insert_keymap(settings: &Settings) -> Keymap {
// ---------------------------------------------------------------------------
/// Build the default inspector keymap (tab index 1).
-pub fn default_inspector_keymap(_settings: &Settings) -> Keymap {
+///
+/// The inspector shows details about the selected history item and has no
+/// text input, so we build a minimal keymap with only inspector-relevant
+/// bindings. We respect the user's `keymap_mode` to provide vim-style j/k
+/// navigation for vim users.
+pub fn default_inspector_keymap(settings: &Settings) -> Keymap {
+ use atuin_client::settings::KeymapMode;
+
let mut km = Keymap::new();
- // Common bindings
+ // Common bindings (same as search tab)
km.bind(key("ctrl-c"), Action::ReturnOriginal);
km.bind(key("ctrl-g"), Action::ReturnOriginal);
km.bind(key("esc"), Action::Exit);
@@ -345,10 +366,31 @@ pub fn default_inspector_keymap(_settings: &Settings) -> Keymap {
km.bind(key("tab"), Action::ReturnSelection);
km.bind(key("ctrl-o"), Action::ToggleTab);
- // Inspector-specific
+ // Accept behavior respects enter_accept setting
+ let accept = if settings.enter_accept {
+ Action::Accept
+ } else {
+ Action::ReturnSelection
+ };
+ km.bind(key("enter"), accept);
+
+ // Inspector-specific: delete history entry
km.bind(key("ctrl-d"), Action::Delete);
+
+ // Inspector navigation
km.bind(key("up"), Action::InspectPrevious);
km.bind(key("down"), Action::InspectNext);
+ km.bind(key("pageup"), Action::InspectPrevious);
+ km.bind(key("pagedown"), Action::InspectNext);
+
+ // For vim users, add j/k navigation
+ if matches!(
+ settings.keymap_mode,
+ KeymapMode::VimNormal | KeymapMode::VimInsert
+ ) {
+ km.bind(key("j"), Action::InspectNext);
+ km.bind(key("k"), Action::InspectPrevious);
+ }
km
}
@@ -409,7 +451,7 @@ fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBi
let key = match KeyInput::parse(key_str) {
Ok(k) => k,
Err(e) => {
- eprintln!("[atuin] warning: invalid key in keymap config: {key_str:?}: {e}");
+ warn!("invalid key in keymap config: {key_str:?}: {e}");
continue;
}
};
@@ -418,7 +460,7 @@ fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBi
keymap.bindings.insert(key, binding);
}
Err(e) => {
- eprintln!("[atuin] warning: invalid binding for {key_str:?} in keymap config: {e}");
+ warn!("invalid binding for {key_str:?} in keymap config: {e}");
}
}
}
diff --git a/crates/atuin/src/command/client/search/keybindings/key.rs b/crates/atuin/src/command/client/search/keybindings/key.rs
index e9fb777b..717b406d 100644
--- a/crates/atuin/src/command/client/search/keybindings/key.rs
+++ b/crates/atuin/src/command/client/search/keybindings/key.rs
@@ -1,6 +1,6 @@
use std::fmt;
-use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`).
@@ -34,6 +34,7 @@ pub enum KeyCodeValue {
PageDown,
Space,
F(u8),
+ Media(MediaKeyCode),
}
/// A key input that may be a single key or a multi-key sequence (e.g. `g g`).
@@ -93,6 +94,7 @@ impl SingleKey {
KeyCode::PageUp => KeyCodeValue::PageUp,
KeyCode::PageDown => KeyCodeValue::PageDown,
KeyCode::F(n) => KeyCodeValue::F(n),
+ KeyCode::Media(m) => KeyCodeValue::Media(m),
_ => return None,
};
@@ -163,6 +165,20 @@ impl SingleKey {
"]" => KeyCodeValue::Char(']'),
"?" => KeyCodeValue::Char('?'),
"/" => KeyCodeValue::Char('/'),
+ "$" => KeyCodeValue::Char('$'),
+ // Media keys (no dashes - the parser splits on dash for modifiers)
+ "play" => KeyCodeValue::Media(MediaKeyCode::Play),
+ "pause" => KeyCodeValue::Media(MediaKeyCode::Pause),
+ "playpause" => KeyCodeValue::Media(MediaKeyCode::PlayPause),
+ "stop" => KeyCodeValue::Media(MediaKeyCode::Stop),
+ "fastforward" => KeyCodeValue::Media(MediaKeyCode::FastForward),
+ "rewind" => KeyCodeValue::Media(MediaKeyCode::Rewind),
+ "tracknext" => KeyCodeValue::Media(MediaKeyCode::TrackNext),
+ "trackprevious" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious),
+ "record" => KeyCodeValue::Media(MediaKeyCode::Record),
+ "lowervolume" => KeyCodeValue::Media(MediaKeyCode::LowerVolume),
+ "raisevolume" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume),
+ "mutevolume" | "mute" => KeyCodeValue::Media(MediaKeyCode::MuteVolume),
_ => {
let chars: Vec<char> = key_part.chars().collect();
if chars.len() == 1 {
@@ -226,6 +242,21 @@ impl fmt::Display for SingleKey {
KeyCodeValue::PageDown => write!(f, "pagedown"),
KeyCodeValue::Space => write!(f, "space"),
KeyCodeValue::F(n) => write!(f, "f{n}"),
+ KeyCodeValue::Media(m) => match m {
+ MediaKeyCode::Play => write!(f, "play"),
+ MediaKeyCode::Pause => write!(f, "media-pause"),
+ MediaKeyCode::PlayPause => write!(f, "playpause"),
+ MediaKeyCode::Stop => write!(f, "stop"),
+ MediaKeyCode::FastForward => write!(f, "fastforward"),
+ MediaKeyCode::Rewind => write!(f, "rewind"),
+ MediaKeyCode::TrackNext => write!(f, "tracknext"),
+ MediaKeyCode::TrackPrevious => write!(f, "trackprevious"),
+ MediaKeyCode::Record => write!(f, "record"),
+ MediaKeyCode::LowerVolume => write!(f, "lowervolume"),
+ MediaKeyCode::RaiseVolume => write!(f, "raisevolume"),
+ MediaKeyCode::MuteVolume => write!(f, "mutevolume"),
+ MediaKeyCode::Reverse => write!(f, "reverse"),
+ },
}
}
}