From 5553e61dbfd16322e18eb57cf774574b5ebf3dd8 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 17 Oct 2025 02:47:09 +0200 Subject: [PATCH] feat(tui): declarative keymap + command registry --- crates/owlen-core/src/config.rs | 3 + crates/owlen-core/src/state/mod.rs | 6 +- crates/owlen-tui/Cargo.toml | 1 + crates/owlen-tui/keymap.toml | 74 +++++ crates/owlen-tui/src/chat_app.rs | 130 +++++++- crates/owlen-tui/src/commands/mod.rs | 5 +- crates/owlen-tui/src/commands/registry.rs | 105 +++++++ crates/owlen-tui/src/state/keymap.rs | 308 +++++++++++++++++++ crates/owlen-tui/src/state/mod.rs | 2 + crates/owlen-tui/src/widgets/model_picker.rs | 2 +- 10 files changed, 627 insertions(+), 9 deletions(-) create mode 100644 crates/owlen-tui/keymap.toml create mode 100644 crates/owlen-tui/src/commands/registry.rs create mode 100644 crates/owlen-tui/src/state/keymap.rs diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index c4ab39e..280e201 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -1584,6 +1584,8 @@ pub struct UiSettings { pub show_timestamps: bool, #[serde(default = "UiSettings::default_icon_mode")] pub icon_mode: IconMode, + #[serde(default)] + pub keymap_path: Option, } /// Preference for which symbol set to render in the terminal UI. @@ -1721,6 +1723,7 @@ impl Default for UiSettings { render_markdown: Self::default_render_markdown(), show_timestamps: Self::default_show_timestamps(), icon_mode: Self::default_icon_mode(), + keymap_path: None, } } } diff --git a/crates/owlen-core/src/state/mod.rs b/crates/owlen-core/src/state/mod.rs index 8a7e56d..2107e06 100644 --- a/crates/owlen-core/src/state/mod.rs +++ b/crates/owlen-core/src/state/mod.rs @@ -3,14 +3,14 @@ use std::fmt; /// High-level application state reported by the UI loop. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AppState { Running, Quit, } /// Vim-style input modes supported by the TUI. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum InputMode { Normal, Editing, @@ -45,7 +45,7 @@ impl fmt::Display for InputMode { } /// Represents which panel is currently focused in the TUI layout. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FocusedPanel { Files, Chat, diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index 2293bf6..4f52c0b 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -30,6 +30,7 @@ toml = { workspace = true } syntect = "5.3" once_cell = "1.19" owlen-markdown = { path = "../owlen-markdown" } +shellexpand = { workspace = true } # Async runtime tokio = { workspace = true } diff --git a/crates/owlen-tui/keymap.toml b/crates/owlen-tui/keymap.toml new file mode 100644 index 0000000..a37857f --- /dev/null +++ b/crates/owlen-tui/keymap.toml @@ -0,0 +1,74 @@ +[[binding]] +mode = "normal" +keys = ["m"] +command = "model.open_all" + +[[binding]] +mode = "normal" +keys = ["Ctrl+Shift+L"] +command = "model.open_local" + +[[binding]] +mode = "normal" +keys = ["Ctrl+Shift+C"] +command = "model.open_cloud" + +[[binding]] +mode = "normal" +keys = ["Ctrl+Shift+P"] +command = "model.open_available" + +[[binding]] +mode = "normal" +keys = ["Ctrl+P"] +command = "palette.open" + +[[binding]] +mode = "editing" +keys = ["Ctrl+P"] +command = "palette.open" + +[[binding]] +mode = "normal" +keys = ["Tab"] +command = "focus.next" + +[[binding]] +mode = "normal" +keys = ["Shift+Tab"] +command = "focus.prev" + +[[binding]] +mode = "normal" +keys = ["Ctrl+1"] +command = "focus.files" + +[[binding]] +mode = "normal" +keys = ["Ctrl+2"] +command = "focus.chat" + +[[binding]] +mode = "normal" +keys = ["Ctrl+3"] +command = "focus.code" + +[[binding]] +mode = "normal" +keys = ["Ctrl+4"] +command = "focus.thinking" + +[[binding]] +mode = "normal" +keys = ["Ctrl+5"] +command = "focus.input" + +[[binding]] +mode = "editing" +keys = ["Enter"] +command = "composer.submit" + +[[binding]] +mode = "normal" +keys = ["Ctrl+;"] +command = "mode.command" diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 67958ee..4533454 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -1,7 +1,10 @@ use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; use chrono::{DateTime, Local, Utc}; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use crossterm::{ + event::KeyEvent, + terminal::{disable_raw_mode, enable_raw_mode}, +}; use owlen_core::facade::llm_client::LlmClient; use owlen_core::mcp::remote_client::RemoteMcpClient; use owlen_core::mcp::{McpToolDescriptor, McpToolResponse}; @@ -38,15 +41,16 @@ use crate::app::{ MessageState, UiRuntime, mvu::{self, AppEffect, AppEvent, AppModel, ComposerEvent, SubmissionOutcome}, }; +use crate::commands::{AppCommand, CommandRegistry}; use crate::config; use crate::events::Event; use crate::model_info_panel::ModelInfoPanel; use crate::slash::{self, McpSlashCommand, SlashCommand}; use crate::state::{ CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState, - ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, - RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, - spawn_repo_search_task, spawn_symbol_search_task, + Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, + RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, + WorkspaceSnapshot, spawn_repo_search_task, spawn_symbol_search_task, }; use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::ui::format_tool_output; @@ -313,6 +317,7 @@ pub struct ChatApp { stream_tasks: HashMap>, textarea: TextArea<'static>, // Advanced text input widget mvu_model: AppModel, + keymap: Keymap, pending_llm_request: bool, // Flag to indicate LLM request needs to be processed pending_tool_execution: Option<(Uuid, Vec)>, // Pending tool execution (message_id, tool_calls) loading_animation_frame: usize, // Frame counter for loading animation @@ -515,7 +520,12 @@ impl ChatApp { let render_markdown = config_guard.ui.render_markdown; let show_timestamps = config_guard.ui.show_timestamps; let icon_mode = config_guard.ui.icon_mode; + let keymap_path = config_guard.ui.keymap_path.clone(); drop(config_guard); + let keymap = { + let registry = CommandRegistry::default(); + Keymap::load(keymap_path.as_deref(), ®istry) + }; let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { eprintln!("Warning: Theme '{}' not found, using default", theme_name); Theme::default() @@ -561,6 +571,7 @@ impl ChatApp { stream_tasks: HashMap::new(), textarea, mvu_model: AppModel::default(), + keymap, pending_llm_request: false, pending_tool_execution: None, loading_animation_frame: 0, @@ -2860,6 +2871,113 @@ impl ChatApp { } } + async fn try_execute_command(&mut self, key: &KeyEvent) -> Result { + if let Some(command) = self.keymap.resolve(self.mode, key) { + if self.execute_command(command).await? { + return Ok(true); + } + } + Ok(false) + } + + async fn execute_command(&mut self, command: AppCommand) -> Result { + match command { + AppCommand::OpenModelPicker(filter) => { + self.pending_key = None; + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + if let Err(err) = self.show_model_picker(filter).await { + self.error = Some(err.to_string()); + } + Ok(true) + } + AppCommand::OpenCommandPalette | AppCommand::EnterCommandMode => { + self.pending_key = None; + if !matches!( + self.mode, + InputMode::Normal | InputMode::Editing | InputMode::Command + ) { + return Ok(false); + } + self.set_input_mode(InputMode::Command); + self.command_palette.clear(); + self.command_palette.ensure_suggestions(); + self.status = ":".to_string(); + self.error = None; + Ok(true) + } + AppCommand::CycleFocusForward => { + self.pending_key = None; + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + self.cycle_focus_forward(); + self.status = format!("Focus: {}", Self::panel_label(self.focused_panel)); + self.error = None; + Ok(true) + } + AppCommand::CycleFocusBackward => { + self.pending_key = None; + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + self.cycle_focus_backward(); + self.status = format!("Focus: {}", Self::panel_label(self.focused_panel)); + self.error = None; + Ok(true) + } + AppCommand::FocusPanel(target) => { + self.pending_key = None; + if !matches!(self.mode, InputMode::Normal) { + return Ok(false); + } + if self.focus_panel(target) { + self.status = match target { + FocusedPanel::Input => "Focus: Input — press i to edit".to_string(), + _ => format!("Focus: {}", Self::panel_label(target)), + }; + self.error = None; + } else { + self.status = match target { + FocusedPanel::Files => { + if self.is_code_mode() { + "Files panel is collapsed — use :files to reopen".to_string() + } else { + "Unable to focus Files panel".to_string() + } + } + FocusedPanel::Code => "Open a file to focus the code workspace".to_string(), + FocusedPanel::Thinking => "No reasoning panel to focus yet".to_string(), + FocusedPanel::Chat => "Unable to focus Chat panel".to_string(), + FocusedPanel::Input => "Unable to focus Input panel".to_string(), + }; + } + Ok(true) + } + AppCommand::ComposerSubmit => { + if !matches!(self.mode, InputMode::Editing) { + return Ok(false); + } + self.pending_key = None; + self.sync_textarea_to_buffer(); + let effects = self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit)); + self.handle_app_effects(effects).await?; + Ok(true) + } + } + } + + fn panel_label(panel: FocusedPanel) -> &'static str { + match panel { + FocusedPanel::Files => "Files", + FocusedPanel::Chat => "Chat", + FocusedPanel::Thinking => "Thinking", + FocusedPanel::Input => "Input", + FocusedPanel::Code => "Code", + } + } + pub fn adjust_vertical_split(&mut self, delta: f32) { if let Some(tab) = self.workspace_mut().active_tab_mut() { tab.root.nudge_ratio(delta); @@ -4566,6 +4684,10 @@ impl ChatApp { } } + if self.try_execute_command(&key).await? { + return Ok(AppState::Running); + } + if matches!(key.code, KeyCode::F(1)) { if matches!(self.mode, InputMode::Help) { self.set_input_mode(InputMode::Normal); diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index 4632cea..5192915 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -1,4 +1,7 @@ -//! Command catalog and lookup utilities for the command palette. +pub mod registry; +pub use registry::{AppCommand, CommandRegistry}; + +// Command catalog and lookup utilities for the command palette. /// Metadata describing a single command keyword. #[derive(Debug, Clone, Copy)] diff --git a/crates/owlen-tui/src/commands/registry.rs b/crates/owlen-tui/src/commands/registry.rs new file mode 100644 index 0000000..050aa68 --- /dev/null +++ b/crates/owlen-tui/src/commands/registry.rs @@ -0,0 +1,105 @@ +use std::collections::HashMap; + +use owlen_core::ui::FocusedPanel; + +use crate::widgets::model_picker::FilterMode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AppCommand { + OpenModelPicker(FilterMode), + OpenCommandPalette, + CycleFocusForward, + CycleFocusBackward, + FocusPanel(FocusedPanel), + ComposerSubmit, + EnterCommandMode, +} + +#[derive(Debug)] +pub struct CommandRegistry { + commands: HashMap, +} + +impl CommandRegistry { + pub fn new() -> Self { + let mut commands = HashMap::new(); + + commands.insert( + "model.open_all".to_string(), + AppCommand::OpenModelPicker(FilterMode::All), + ); + commands.insert( + "model.open_local".to_string(), + AppCommand::OpenModelPicker(FilterMode::LocalOnly), + ); + commands.insert( + "model.open_cloud".to_string(), + AppCommand::OpenModelPicker(FilterMode::CloudOnly), + ); + commands.insert( + "model.open_available".to_string(), + AppCommand::OpenModelPicker(FilterMode::Available), + ); + commands.insert("palette.open".to_string(), AppCommand::OpenCommandPalette); + commands.insert("focus.next".to_string(), AppCommand::CycleFocusForward); + commands.insert("focus.prev".to_string(), AppCommand::CycleFocusBackward); + commands.insert( + "focus.files".to_string(), + AppCommand::FocusPanel(FocusedPanel::Files), + ); + commands.insert( + "focus.chat".to_string(), + AppCommand::FocusPanel(FocusedPanel::Chat), + ); + commands.insert( + "focus.thinking".to_string(), + AppCommand::FocusPanel(FocusedPanel::Thinking), + ); + commands.insert( + "focus.input".to_string(), + AppCommand::FocusPanel(FocusedPanel::Input), + ); + commands.insert( + "focus.code".to_string(), + AppCommand::FocusPanel(FocusedPanel::Code), + ); + commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit); + commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode); + + Self { commands } + } + + pub fn resolve(&self, command: &str) -> Option { + self.commands.get(command).copied() + } +} + +impl Default for CommandRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_known_command() { + let registry = CommandRegistry::new(); + assert_eq!( + registry.resolve("focus.next"), + Some(AppCommand::CycleFocusForward) + ); + assert_eq!( + registry.resolve("model.open_cloud"), + Some(AppCommand::OpenModelPicker(FilterMode::CloudOnly)) + ); + } + + #[test] + fn resolve_unknown_command() { + let registry = CommandRegistry::new(); + assert_eq!(registry.resolve("does.not.exist"), None); + } +} diff --git a/crates/owlen-tui/src/state/keymap.rs b/crates/owlen-tui/src/state/keymap.rs new file mode 100644 index 0000000..56ecbe3 --- /dev/null +++ b/crates/owlen-tui/src/state/keymap.rs @@ -0,0 +1,308 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use log::warn; +use owlen_core::{config::default_config_path, ui::InputMode}; +use serde::Deserialize; + +use crate::commands::registry::{AppCommand, CommandRegistry}; + +const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml"); + +#[derive(Debug, Clone)] +pub struct Keymap { + bindings: HashMap<(InputMode, KeyPattern), AppCommand>, +} + +impl Keymap { + pub fn load(custom_path: Option<&str>, registry: &CommandRegistry) -> Self { + let mut content = None; + + if let Some(path) = custom_path.and_then(expand_path) { + if let Ok(text) = fs::read_to_string(&path) { + content = Some(text); + } else { + warn!( + "Failed to read keymap from {}. Falling back to defaults.", + path.display() + ); + } + } + + if content.is_none() { + let default_path = default_config_keymap_path(); + if let Some(path) = default_path { + if let Ok(text) = fs::read_to_string(&path) { + content = Some(text); + } + } + } + + let data = content.unwrap_or_else(|| DEFAULT_KEYMAP.to_string()); + let parsed: KeymapConfig = toml::from_str(&data).unwrap_or_else(|err| { + warn!("Failed to parse keymap: {err}. Using built-in defaults."); + toml::from_str(DEFAULT_KEYMAP).expect("embedded keymap should parse successfully") + }); + + let mut bindings = HashMap::new(); + + for entry in parsed.bindings { + let mode = match parse_mode(&entry.mode) { + Some(mode) => mode, + None => { + warn!("Unknown input mode '{}' in keymap binding", entry.mode); + continue; + } + }; + + let command = match registry.resolve(&entry.command) { + Some(cmd) => cmd, + None => { + warn!("Unknown command '{}' in keymap binding", entry.command); + continue; + } + }; + + for key in entry.keys.into_iter() { + match KeyPattern::from_str(&key) { + Some(pattern) => { + bindings.insert((mode, pattern), command); + } + None => warn!( + "Unrecognised key specification '{}' for mode {}", + key, entry.mode + ), + } + } + } + + Self { bindings } + } + + pub fn resolve(&self, mode: InputMode, event: &KeyEvent) -> Option { + let pattern = KeyPattern::from_event(event)?; + self.bindings.get(&(mode, pattern)).copied() + } +} + +#[derive(Debug, Deserialize)] +struct KeymapConfig { + #[serde(default, rename = "binding")] + bindings: Vec, +} + +#[derive(Debug, Deserialize)] +struct KeyBindingConfig { + mode: String, + command: String, + keys: KeyList, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum KeyList { + Single(String), + Multiple(Vec), +} + +impl KeyList { + fn into_iter(self) -> Vec { + match self { + KeyList::Single(key) => vec![key], + KeyList::Multiple(keys) => keys, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct KeyPattern { + code: KeyCodeKind, + modifiers: KeyModifiers, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum KeyCodeKind { + Char(char), + Enter, + Tab, + BackTab, + Backspace, + Esc, + Up, + Down, + Left, + Right, + PageUp, + PageDown, + Home, + End, + F(u8), +} + +impl KeyPattern { + fn from_event(event: &KeyEvent) -> Option { + let code = match event.code { + KeyCode::Char(c) => KeyCodeKind::Char(c), + KeyCode::Enter => KeyCodeKind::Enter, + KeyCode::Tab => KeyCodeKind::Tab, + KeyCode::BackTab => KeyCodeKind::BackTab, + KeyCode::Backspace => KeyCodeKind::Backspace, + KeyCode::Esc => KeyCodeKind::Esc, + KeyCode::Up => KeyCodeKind::Up, + KeyCode::Down => KeyCodeKind::Down, + KeyCode::Left => KeyCodeKind::Left, + KeyCode::Right => KeyCodeKind::Right, + KeyCode::PageUp => KeyCodeKind::PageUp, + KeyCode::PageDown => KeyCodeKind::PageDown, + KeyCode::Home => KeyCodeKind::Home, + KeyCode::End => KeyCodeKind::End, + KeyCode::F(n) => KeyCodeKind::F(n), + _ => return None, + }; + + Some(Self { + code, + modifiers: normalize_modifiers(event.modifiers), + }) + } + + fn from_str(spec: &str) -> Option { + let tokens: Vec<&str> = spec + .split('+') + .map(|token| token.trim()) + .filter(|token| !token.is_empty()) + .collect(); + + if tokens.is_empty() { + return None; + } + + let mut modifiers = KeyModifiers::empty(); + let key_token = tokens.last().copied().unwrap(); + + for token in tokens[..tokens.len().saturating_sub(1)].iter() { + match token.to_ascii_lowercase().as_str() { + "ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL), + "alt" | "option" => modifiers.insert(KeyModifiers::ALT), + "shift" => modifiers.insert(KeyModifiers::SHIFT), + other => warn!("Unknown modifier '{other}' in key binding '{spec}'"), + } + } + + let code = parse_key_token(key_token, &mut modifiers)?; + + Some(Self { + code, + modifiers: normalize_modifiers(modifiers), + }) + } +} + +fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option { + let token_lower = token.to_ascii_lowercase(); + let code = match token_lower.as_str() { + "enter" | "return" => KeyCodeKind::Enter, + "tab" => { + if modifiers.contains(KeyModifiers::SHIFT) { + modifiers.remove(KeyModifiers::SHIFT); + KeyCodeKind::BackTab + } else { + KeyCodeKind::Tab + } + } + "backtab" => KeyCodeKind::BackTab, + "backspace" | "bs" => KeyCodeKind::Backspace, + "esc" | "escape" => KeyCodeKind::Esc, + "up" => KeyCodeKind::Up, + "down" => KeyCodeKind::Down, + "left" => KeyCodeKind::Left, + "right" => KeyCodeKind::Right, + "pageup" | "page_up" | "pgup" => KeyCodeKind::PageUp, + "pagedown" | "page_down" | "pgdn" => KeyCodeKind::PageDown, + "home" => KeyCodeKind::Home, + "end" => KeyCodeKind::End, + token if token.starts_with('f') && token.len() > 1 => { + let num = token[1..].parse::().ok()?; + KeyCodeKind::F(num) + } + "space" => KeyCodeKind::Char(' '), + "semicolon" => KeyCodeKind::Char(';'), + "slash" => KeyCodeKind::Char('/'), + _ => { + let chars: Vec = token.chars().collect(); + if chars.len() == 1 { + KeyCodeKind::Char(chars[0]) + } else { + return None; + } + } + }; + + Some(code) +} + +fn parse_mode(mode: &str) -> Option { + match mode.to_ascii_lowercase().as_str() { + "normal" => Some(InputMode::Normal), + "editing" => Some(InputMode::Editing), + "command" => Some(InputMode::Command), + "visual" => Some(InputMode::Visual), + "provider_selection" | "provider" => Some(InputMode::ProviderSelection), + "model_selection" | "model" => Some(InputMode::ModelSelection), + "help" => Some(InputMode::Help), + "session_browser" | "sessions" => Some(InputMode::SessionBrowser), + "theme_browser" | "themes" => Some(InputMode::ThemeBrowser), + "repo_search" | "search" => Some(InputMode::RepoSearch), + "symbol_search" | "symbols" => Some(InputMode::SymbolSearch), + _ => None, + } +} + +fn default_config_keymap_path() -> Option { + let config_path = default_config_path(); + let dir = config_path.parent()?; + Some(dir.join("keymap.toml")) +} + +fn expand_path(path: &str) -> Option { + if path.trim().is_empty() { + return None; + } + let expanded = shellexpand::tilde(path); + let candidate = Path::new(expanded.as_ref()).to_path_buf(); + Some(candidate) +} + +fn normalize_modifiers(modifiers: KeyModifiers) -> KeyModifiers { + modifiers +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::widgets::model_picker::FilterMode; + use crossterm::event::{KeyCode, KeyModifiers}; + + #[test] + fn resolve_binding_from_default_keymap() { + let registry = CommandRegistry::new(); + assert!(registry.resolve("model.open_all").is_some()); + let parsed: KeymapConfig = toml::from_str(DEFAULT_KEYMAP).unwrap(); + assert!(!parsed.bindings.is_empty()); + let keymap = Keymap::load(None, ®istry); + + let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); + assert!( + !keymap.bindings.is_empty(), + "expected default keymap to provide bindings" + ); + assert_eq!( + keymap.resolve(InputMode::Normal, &event), + Some(AppCommand::OpenModelPicker(FilterMode::All)) + ); + } +} diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index 6112459..3b802e5 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -8,6 +8,7 @@ mod command_palette; mod file_icons; mod file_tree; +mod keymap; mod search; mod workspace; @@ -16,6 +17,7 @@ pub use file_icons::{FileIconResolver, FileIconSet, IconDetection}; pub use file_tree::{ FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, }; +pub use keymap::Keymap; pub use search::{ RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind, RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState, diff --git a/crates/owlen-tui/src/widgets/model_picker.rs b/crates/owlen-tui/src/widgets/model_picker.rs index a16c011..27bb56b 100644 --- a/crates/owlen-tui/src/widgets/model_picker.rs +++ b/crates/owlen-tui/src/widgets/model_picker.rs @@ -15,7 +15,7 @@ use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, ModelAvailabilityState, ModelScope, ModelSelectorItemKind}; /// Filtering modes for the model picker popup. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] pub enum FilterMode { #[default] All,