feat(tui): declarative keymap + command registry
This commit is contained in:
@@ -1584,6 +1584,8 @@ pub struct UiSettings {
|
|||||||
pub show_timestamps: bool,
|
pub show_timestamps: bool,
|
||||||
#[serde(default = "UiSettings::default_icon_mode")]
|
#[serde(default = "UiSettings::default_icon_mode")]
|
||||||
pub icon_mode: IconMode,
|
pub icon_mode: IconMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keymap_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preference for which symbol set to render in the terminal UI.
|
/// 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(),
|
render_markdown: Self::default_render_markdown(),
|
||||||
show_timestamps: Self::default_show_timestamps(),
|
show_timestamps: Self::default_show_timestamps(),
|
||||||
icon_mode: Self::default_icon_mode(),
|
icon_mode: Self::default_icon_mode(),
|
||||||
|
keymap_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// High-level application state reported by the UI loop.
|
/// 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 {
|
pub enum AppState {
|
||||||
Running,
|
Running,
|
||||||
Quit,
|
Quit,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vim-style input modes supported by the TUI.
|
/// Vim-style input modes supported by the TUI.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum InputMode {
|
pub enum InputMode {
|
||||||
Normal,
|
Normal,
|
||||||
Editing,
|
Editing,
|
||||||
@@ -45,7 +45,7 @@ impl fmt::Display for InputMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Represents which panel is currently focused in the TUI layout.
|
/// 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 {
|
pub enum FocusedPanel {
|
||||||
Files,
|
Files,
|
||||||
Chat,
|
Chat,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ toml = { workspace = true }
|
|||||||
syntect = "5.3"
|
syntect = "5.3"
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
owlen-markdown = { path = "../owlen-markdown" }
|
owlen-markdown = { path = "../owlen-markdown" }
|
||||||
|
shellexpand = { workspace = true }
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { workspace = true }
|
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 anyhow::{Context, Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Local, Utc};
|
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::facade::llm_client::LlmClient;
|
||||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
||||||
@@ -38,15 +41,16 @@ use crate::app::{
|
|||||||
MessageState, UiRuntime,
|
MessageState, UiRuntime,
|
||||||
mvu::{self, AppEffect, AppEvent, AppModel, ComposerEvent, SubmissionOutcome},
|
mvu::{self, AppEffect, AppEvent, AppModel, ComposerEvent, SubmissionOutcome},
|
||||||
};
|
};
|
||||||
|
use crate::commands::{AppCommand, CommandRegistry};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::events::Event;
|
use crate::events::Event;
|
||||||
use crate::model_info_panel::ModelInfoPanel;
|
use crate::model_info_panel::ModelInfoPanel;
|
||||||
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState,
|
CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState,
|
||||||
ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage,
|
Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest,
|
||||||
RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot,
|
RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState,
|
||||||
spawn_repo_search_task, spawn_symbol_search_task,
|
WorkspaceSnapshot, spawn_repo_search_task, spawn_symbol_search_task,
|
||||||
};
|
};
|
||||||
use crate::toast::{Toast, ToastLevel, ToastManager};
|
use crate::toast::{Toast, ToastLevel, ToastManager};
|
||||||
use crate::ui::format_tool_output;
|
use crate::ui::format_tool_output;
|
||||||
@@ -313,6 +317,7 @@ pub struct ChatApp {
|
|||||||
stream_tasks: HashMap<Uuid, JoinHandle<()>>,
|
stream_tasks: HashMap<Uuid, JoinHandle<()>>,
|
||||||
textarea: TextArea<'static>, // Advanced text input widget
|
textarea: TextArea<'static>, // Advanced text input widget
|
||||||
mvu_model: AppModel,
|
mvu_model: AppModel,
|
||||||
|
keymap: Keymap,
|
||||||
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
|
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)
|
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
|
loading_animation_frame: usize, // Frame counter for loading animation
|
||||||
@@ -515,7 +520,12 @@ impl ChatApp {
|
|||||||
let render_markdown = config_guard.ui.render_markdown;
|
let render_markdown = config_guard.ui.render_markdown;
|
||||||
let show_timestamps = config_guard.ui.show_timestamps;
|
let show_timestamps = config_guard.ui.show_timestamps;
|
||||||
let icon_mode = config_guard.ui.icon_mode;
|
let icon_mode = config_guard.ui.icon_mode;
|
||||||
|
let keymap_path = config_guard.ui.keymap_path.clone();
|
||||||
drop(config_guard);
|
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(|| {
|
let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| {
|
||||||
eprintln!("Warning: Theme '{}' not found, using default", theme_name);
|
eprintln!("Warning: Theme '{}' not found, using default", theme_name);
|
||||||
Theme::default()
|
Theme::default()
|
||||||
@@ -561,6 +571,7 @@ impl ChatApp {
|
|||||||
stream_tasks: HashMap::new(),
|
stream_tasks: HashMap::new(),
|
||||||
textarea,
|
textarea,
|
||||||
mvu_model: AppModel::default(),
|
mvu_model: AppModel::default(),
|
||||||
|
keymap,
|
||||||
pending_llm_request: false,
|
pending_llm_request: false,
|
||||||
pending_tool_execution: None,
|
pending_tool_execution: None,
|
||||||
loading_animation_frame: 0,
|
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) {
|
pub fn adjust_vertical_split(&mut self, delta: f32) {
|
||||||
if let Some(tab) = self.workspace_mut().active_tab_mut() {
|
if let Some(tab) = self.workspace_mut().active_tab_mut() {
|
||||||
tab.root.nudge_ratio(delta);
|
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!(key.code, KeyCode::F(1)) {
|
||||||
if matches!(self.mode, InputMode::Help) {
|
if matches!(self.mode, InputMode::Help) {
|
||||||
self.set_input_mode(InputMode::Normal);
|
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.
|
/// Metadata describing a single command keyword.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[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 command_palette;
|
||||||
mod file_icons;
|
mod file_icons;
|
||||||
mod file_tree;
|
mod file_tree;
|
||||||
|
mod keymap;
|
||||||
mod search;
|
mod search;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ pub use file_icons::{FileIconResolver, FileIconSet, IconDetection};
|
|||||||
pub use file_tree::{
|
pub use file_tree::{
|
||||||
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
|
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
|
||||||
};
|
};
|
||||||
|
pub use keymap::Keymap;
|
||||||
pub use search::{
|
pub use search::{
|
||||||
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
|
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
|
||||||
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,
|
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use unicode_width::UnicodeWidthStr;
|
|||||||
use crate::chat_app::{ChatApp, ModelAvailabilityState, ModelScope, ModelSelectorItemKind};
|
use crate::chat_app::{ChatApp, ModelAvailabilityState, ModelScope, ModelSelectorItemKind};
|
||||||
|
|
||||||
/// Filtering modes for the model picker popup.
|
/// 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 {
|
pub enum FilterMode {
|
||||||
#[default]
|
#[default]
|
||||||
All,
|
All,
|
||||||
|
|||||||
Reference in New Issue
Block a user