use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum GlobalAction {
Interrupt,
Submit,
SendNow,
ToggleMultiline,
OpenCommandPalette,
ToggleQueuePanel,
FoldAll,
ScrollUp,
ScrollDown,
Clear,
Help,
ModelPicker,
ToggleThinking,
}
pub const DEFAULT_KEYBINDINGS: &[(GlobalAction, &str)] = &[
(GlobalAction::Interrupt, "Ctrl+c"),
(GlobalAction::Submit, "Enter"),
(GlobalAction::Submit, "Shift+Enter"),
(GlobalAction::SendNow, "Ctrl+Enter"),
(GlobalAction::ToggleMultiline, "Ctrl+m"),
(GlobalAction::OpenCommandPalette, "Ctrl+p"),
(GlobalAction::ToggleQueuePanel, "Ctrl+;"),
(GlobalAction::FoldAll, "Ctrl+e"),
(GlobalAction::ScrollUp, "PageUp"),
(GlobalAction::ScrollDown, "PageDown"),
(GlobalAction::Clear, "Ctrl+l"),
(GlobalAction::Help, "?"),
(GlobalAction::ModelPicker, "Ctrl+g"),
(GlobalAction::ToggleThinking, "Ctrl+t"),
];
impl GlobalAction {
pub fn name(self) -> &'static str {
match self {
GlobalAction::Interrupt => "Interrupt",
GlobalAction::Submit => "Submit",
GlobalAction::SendNow => "SendNow",
GlobalAction::ToggleMultiline => "ToggleMultiline",
GlobalAction::OpenCommandPalette => "OpenCommandPalette",
GlobalAction::ToggleQueuePanel => "ToggleQueuePanel",
GlobalAction::FoldAll => "FoldAll",
GlobalAction::ScrollUp => "ScrollUp",
GlobalAction::ScrollDown => "ScrollDown",
GlobalAction::Clear => "Clear",
GlobalAction::Help => "Help",
GlobalAction::ModelPicker => "ModelPicker",
GlobalAction::ToggleThinking => "ToggleThinking",
}
}
pub fn from_name(s: &str) -> Option<Self> {
DEFAULT_KEYBINDINGS
.iter()
.map(|(a, _)| *a)
.find(|a| a.name() == s)
}
pub fn all() -> impl Iterator<Item = GlobalAction> {
DEFAULT_KEYBINDINGS.iter().map(|(a, _)| *a)
}
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct KeyCombo {
pub code: KeyCode,
pub modifiers: KeyModifiers,
}
impl KeyCombo {
pub fn parse(s: &str) -> Option<Self> {
let mut mods = KeyModifiers::NONE;
let mut code: Option<KeyCode> = None;
for part in s.split('+') {
let part = part.trim();
if part.is_empty() {
return None;
}
match part {
"Ctrl" | "Control" => mods |= KeyModifiers::CONTROL,
"Shift" => mods |= KeyModifiers::SHIFT,
"Alt" => mods |= KeyModifiers::ALT,
other => {
if code.is_some() {
return None;
}
code = Some(parse_key_name(other, mods)?);
}
}
}
Some(KeyCombo {
code: code?,
modifiers: mods,
})
}
#[allow(clippy::inherent_to_string)] pub fn to_string(&self) -> String {
let mut out = String::new();
if self.modifiers.contains(KeyModifiers::CONTROL) {
out.push_str("Ctrl+");
}
if self.modifiers.contains(KeyModifiers::ALT) {
out.push_str("Alt+");
}
if self.modifiers.contains(KeyModifiers::SHIFT) {
out.push_str("Shift+");
}
match self.code {
KeyCode::Enter => out.push_str("Enter"),
KeyCode::Esc => out.push_str("Esc"),
KeyCode::Tab => out.push_str("Tab"),
KeyCode::BackTab => out.push_str("BackTab"),
KeyCode::Backspace => out.push_str("Backspace"),
KeyCode::Delete => out.push_str("Delete"),
KeyCode::Home => out.push_str("Home"),
KeyCode::End => out.push_str("End"),
KeyCode::PageUp => out.push_str("PageUp"),
KeyCode::PageDown => out.push_str("PageDown"),
KeyCode::Up => out.push_str("Up"),
KeyCode::Down => out.push_str("Down"),
KeyCode::Left => out.push_str("Left"),
KeyCode::Right => out.push_str("Right"),
KeyCode::Char(c) => {
out.push(c);
}
_ => return self.code.to_string(),
}
out
}
}
fn parse_key_name(token: &str, mods: KeyModifiers) -> Option<KeyCode> {
let lower = token.to_ascii_lowercase();
if let Some(code) = match lower.as_str() {
"enter" | "return" | "cr" => Some(KeyCode::Enter),
"esc" | "escape" => Some(KeyCode::Esc),
"tab" => Some(KeyCode::Tab),
"backtab" | "shift-tab" | "shift+tab" => Some(KeyCode::BackTab),
"up" => Some(KeyCode::Up),
"down" => Some(KeyCode::Down),
"left" => Some(KeyCode::Left),
"right" => Some(KeyCode::Right),
"home" => Some(KeyCode::Home),
"end" => Some(KeyCode::End),
"pageup" | "page_up" | "pgup" => Some(KeyCode::PageUp),
"pagedown" | "page_down" | "pgdn" => Some(KeyCode::PageDown),
"backspace" | "bs" => Some(KeyCode::Backspace),
"delete" | "del" => Some(KeyCode::Delete),
_ => None,
} {
return Some(code);
}
if token.chars().count() == 1 {
let ch = token.chars().next()?;
let ch = if mods.contains(KeyModifiers::SHIFT) {
ch.to_ascii_uppercase()
} else {
ch
};
return Some(KeyCode::Char(ch));
}
None
}
#[derive(Clone)]
pub struct Keymap {
bindings: HashMap<GlobalAction, Vec<KeyCombo>>,
}
impl Keymap {
pub fn from_settings(overrides: &HashMap<String, Vec<String>>) -> Self {
let mut bindings: HashMap<GlobalAction, Vec<KeyCombo>> = HashMap::new();
for (action, combo) in DEFAULT_KEYBINDINGS {
bindings
.entry(*action)
.or_default()
.push(KeyCombo::parse(combo).expect("DEFAULT_KEYBINDINGS parses"));
}
for (name, combos) in overrides {
let Some(action) = GlobalAction::from_name(name) else {
continue;
};
let parsed: Vec<KeyCombo> = combos
.iter()
.map(String::as_str)
.filter_map(KeyCombo::parse)
.collect();
if !parsed.is_empty() {
bindings.insert(action, parsed);
}
}
Keymap { bindings }
}
pub fn resolve(&self, key: KeyEvent) -> Option<GlobalAction> {
GlobalAction::all().find(|action| {
self.bindings.get(action).is_some_and(|combos| {
combos
.iter()
.any(|c| c.code == key.code && c.modifiers == key.modifiers)
})
})
}
pub fn matches(&self, action: GlobalAction, event: &KeyEvent) -> bool {
self.bindings.get(&action).is_some_and(|combos| {
combos
.iter()
.any(|c| c.code == event.code && c.modifiers == event.modifiers)
})
}
pub fn set_action(&mut self, action: GlobalAction, combos: Vec<KeyCombo>) {
self.bindings.insert(action, combos);
}
pub fn action_combos(&self, action: GlobalAction) -> &[KeyCombo] {
self.bindings.get(&action).map(Vec::as_slice).unwrap_or(&[])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
fn ctrl(code: KeyCode) -> KeyEvent {
press(code, KeyModifiers::CONTROL)
}
#[test]
fn default_resolve_maps_ctrl_p_to_command_palette() {
let km = Keymap::from_settings(&HashMap::new());
let ev = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
assert_eq!(km.resolve(ev), Some(GlobalAction::OpenCommandPalette));
}
#[test]
fn user_override_adds_instead_of_replacing() {
let mut o: HashMap<String, Vec<String>> = HashMap::new();
o.insert("OpenCommandPalette".into(), vec!["Alt+p".into()]);
let km = Keymap::from_settings(&o);
assert_eq!(
km.resolve(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT)),
Some(GlobalAction::OpenCommandPalette),
"override combo Alt+P must resolve",
);
assert_eq!(
km.resolve(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
Some(GlobalAction::Interrupt),
"default Ctrl+C for unrelated action must still resolve",
);
assert_eq!(
km.resolve(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)),
None,
"original Ctrl+P is gone after override",
);
}
#[test]
fn override_keeps_other_actions_default() {
let mut o: HashMap<String, Vec<String>> = HashMap::new();
o.insert("OpenCommandPalette".into(), vec!["Alt+p".into()]);
let km = Keymap::from_settings(&o);
assert_eq!(
km.resolve(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)),
Some(GlobalAction::SendNow),
);
assert_eq!(
km.resolve(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)),
Some(GlobalAction::FoldAll),
);
}
#[test]
fn keycombo_roundtrips() {
for s in [
"Ctrl+c",
"Ctrl+m",
"Ctrl+Shift+E",
"Ctrl+Enter",
"Alt+p",
"PageUp",
"PageDown",
"?",
"Ctrl+l",
"Ctrl+g",
"Ctrl+t",
] {
let parsed = KeyCombo::parse(s).unwrap_or_else(|| panic!("parse failed: {s}"));
assert_eq!(parsed.to_string(), s, "round-trip mismatch for {s}");
}
let lower = KeyCombo::parse("Ctrl+Shift+e").unwrap();
let upper = KeyCombo::parse("Ctrl+Shift+E").unwrap();
assert_eq!(lower, upper, "lowercase Shift+E normalizes to uppercase",);
assert_eq!(lower.to_string(), "Ctrl+Shift+E");
}
#[test]
fn shifted_letter_serializes_uppercase() {
let combo = KeyCombo {
code: KeyCode::Char('E'),
modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
};
assert_eq!(combo.to_string(), "Ctrl+Shift+E");
assert_eq!(KeyCombo::parse("Ctrl+Shift+E").unwrap(), combo);
}
#[test]
fn plain_char_is_not_a_global_action() {
let km = Keymap::from_settings(&HashMap::new());
assert_eq!(
km.resolve(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
None,
);
}
#[test]
fn from_name_and_all_are_inverse() {
for action in GlobalAction::all() {
assert_eq!(GlobalAction::from_name(action.name()), Some(action));
}
assert_eq!(GlobalAction::from_name("NotAnAction"), None);
}
#[test]
fn set_action_replaces_combos() {
let mut km = Keymap::from_settings(&HashMap::new());
let new_combos = vec![KeyCombo::parse("Alt+x").unwrap()];
km.set_action(GlobalAction::OpenCommandPalette, new_combos);
assert_eq!(
km.resolve(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)),
Some(GlobalAction::OpenCommandPalette),
);
assert_eq!(
km.resolve(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)),
None,
"old Ctrl+P must be gone after set_action",
);
}
#[test]
fn parse_rejects_unknown_segments_and_empty_payload() {
assert!(KeyCombo::parse("Ctrl+Foo").is_none());
assert!(KeyCombo::parse("Ctrl+").is_none());
assert!(KeyCombo::parse("Ctrl+Ctrl+p").is_some());
}
#[test]
fn shared_combo_resolves_in_default_bindings_order() {
let mut o: HashMap<String, Vec<String>> = HashMap::new();
o.insert("Interrupt".into(), vec!["Ctrl+c".into(), "Ctrl+p".into()]);
let km = Keymap::from_settings(&o);
let ev = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
assert_eq!(km.resolve(ev), Some(GlobalAction::Interrupt));
}
#[test]
fn defaults_match_unified_hardcoded_keys() {
let km = Keymap::from_settings(&HashMap::new());
assert!(km.matches(GlobalAction::Interrupt, &ctrl(KeyCode::Char('c'))));
assert!(!km.matches(
GlobalAction::Interrupt,
&press(KeyCode::Char('c'), KeyModifiers::NONE),
));
assert!(km.matches(
GlobalAction::Submit,
&press(KeyCode::Enter, KeyModifiers::NONE),
));
assert!(!km.matches(GlobalAction::Submit, &ctrl(KeyCode::Enter)));
assert!(km.matches(GlobalAction::SendNow, &ctrl(KeyCode::Enter)));
assert!(!km.matches(
GlobalAction::SendNow,
&press(KeyCode::Enter, KeyModifiers::NONE),
));
assert!(km.matches(GlobalAction::ToggleQueuePanel, &ctrl(KeyCode::Char(';')),));
assert!(!km.matches(
GlobalAction::ToggleQueuePanel,
&press(KeyCode::Char(';'), KeyModifiers::NONE),
));
assert!(km.matches(
GlobalAction::ScrollUp,
&press(KeyCode::PageUp, KeyModifiers::NONE),
));
assert!(!km.matches(
GlobalAction::ScrollUp,
&press(KeyCode::Up, KeyModifiers::NONE),
));
assert!(km.matches(
GlobalAction::ScrollDown,
&press(KeyCode::PageDown, KeyModifiers::NONE),
));
assert!(!km.matches(
GlobalAction::ScrollDown,
&press(KeyCode::Down, KeyModifiers::NONE),
));
assert!(km.matches(GlobalAction::Clear, &ctrl(KeyCode::Char('l'))));
assert!(!km.matches(
GlobalAction::Clear,
&press(KeyCode::Char('l'), KeyModifiers::NONE),
));
assert!(km.matches(
GlobalAction::Help,
&press(KeyCode::Char('?'), KeyModifiers::NONE),
));
assert!(!km.matches(GlobalAction::Help, &ctrl(KeyCode::Char('?'))));
assert!(km.matches(GlobalAction::ModelPicker, &ctrl(KeyCode::Char('g'))));
assert!(!km.matches(
GlobalAction::ModelPicker,
&press(KeyCode::Char('g'), KeyModifiers::NONE),
));
assert!(km.matches(GlobalAction::ToggleThinking, &ctrl(KeyCode::Char('t')),));
assert!(!km.matches(
GlobalAction::ToggleThinking,
&press(KeyCode::Char('t'), KeyModifiers::NONE),
));
assert!(km.matches(GlobalAction::ToggleMultiline, &ctrl(KeyCode::Char('m')),));
assert!(km.matches(GlobalAction::FoldAll, &ctrl(KeyCode::Char('e'))));
}
#[test]
fn matches_handles_ctrl_enter() {
let km = Keymap::from_settings(&HashMap::new());
let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
assert!(km.matches(GlobalAction::SendNow, &ctrl_enter));
assert!(!km.matches(GlobalAction::Submit, &ctrl_enter));
assert!(km.matches(GlobalAction::Submit, &enter));
assert!(!km.matches(GlobalAction::SendNow, &enter));
}
#[test]
fn parse_rejects_garbage_payload() {
assert!(KeyCombo::parse("Ctrl+").is_none());
assert!(KeyCombo::parse("notakey").is_none());
assert!(KeyCombo::parse("a+b").is_none());
}
}