feat(tui): declarative keymap + command registry
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
74
crates/owlen-tui/keymap.toml
Normal file
74
crates/owlen-tui/keymap.toml
Normal 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"
|
||||
@@ -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(), ®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<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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
105
crates/owlen-tui/src/commands/registry.rs
Normal file
105
crates/owlen-tui/src/commands/registry.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
308
crates/owlen-tui/src/state/keymap.rs
Normal file
308
crates/owlen-tui/src/state/keymap.rs
Normal 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, ®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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user