feat(tui): declarative keymap + command registry

This commit is contained in:
2025-10-17 02:47:09 +02:00
parent 7f987737f9
commit 5553e61dbf
10 changed files with 627 additions and 9 deletions

View File

@@ -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<String>,
}
/// 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,
}
}
}

View File

@@ -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,

View File

@@ -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 }

View File

@@ -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"

View File

@@ -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<Uuid, JoinHandle<()>>,
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<owlen_core::types::ToolCall>)>, // 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(), &registry)
};
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<bool> {
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<bool> {
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);

View File

@@ -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)]

View File

@@ -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<String, AppCommand>,
}
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<AppCommand> {
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);
}
}

View File

@@ -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<AppCommand> {
let pattern = KeyPattern::from_event(event)?;
self.bindings.get(&(mode, pattern)).copied()
}
}
#[derive(Debug, Deserialize)]
struct KeymapConfig {
#[serde(default, rename = "binding")]
bindings: Vec<KeyBindingConfig>,
}
#[derive(Debug, Deserialize)]
struct KeyBindingConfig {
mode: String,
command: String,
keys: KeyList,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum KeyList {
Single(String),
Multiple(Vec<String>),
}
impl KeyList {
fn into_iter(self) -> Vec<String> {
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<Self> {
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<Self> {
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<KeyCodeKind> {
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::<u8>().ok()?;
KeyCodeKind::F(num)
}
"space" => KeyCodeKind::Char(' '),
"semicolon" => KeyCodeKind::Char(';'),
"slash" => KeyCodeKind::Char('/'),
_ => {
let chars: Vec<char> = token.chars().collect();
if chars.len() == 1 {
KeyCodeKind::Char(chars[0])
} else {
return None;
}
}
};
Some(code)
}
fn parse_mode(mode: &str) -> Option<InputMode> {
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<PathBuf> {
let config_path = default_config_path();
let dir = config_path.parent()?;
Some(dir.join("keymap.toml"))
}
fn expand_path(path: &str) -> Option<PathBuf> {
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, &registry);
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))
);
}
}

View File

@@ -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,

View File

@@ -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,