From 3722840d2c393ccde7270e9b6200a3cd7da0e3bc Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 18 Oct 2025 04:51:39 +0200 Subject: [PATCH] feat(tui): add Emacs keymap profile with runtime switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce built‑in Emacs keymap (`keymap_emacs.toml`) alongside existing Vim layout. - Add `ui.keymap_profile` and `ui.keymap_path` configuration options; persist profile changes via `:keymap` command. - Expose `KeymapProfile` enum (Vim, Emacs, Custom) and integrate it throughout state, UI rendering, and help overlay. - Extend command registry with `keymap.set_vim` and `keymap.set_emacs` to allow profile switching. - Update help overlay, command specs, and README to reflect new keybindings and profile commands. - Adjust `Keymap::load` to honor preferred profile, custom paths, and fallback logic. --- CHANGELOG.md | 1 + README.md | 9 + crates/owlen-core/src/config.rs | 7 + crates/owlen-tui/keymap_emacs.toml | 79 ++++++ crates/owlen-tui/src/chat_app.rs | 83 ++++++- crates/owlen-tui/src/commands/mod.rs | 12 + crates/owlen-tui/src/commands/registry.rs | 15 +- crates/owlen-tui/src/state/keymap.rs | 168 +++++++++++-- crates/owlen-tui/src/state/mod.rs | 2 +- crates/owlen-tui/src/ui.rs | 281 ++++++++++++++-------- docs/configuration.md | 6 + 11 files changed, 540 insertions(+), 123 deletions(-) create mode 100644 crates/owlen-tui/keymap_emacs.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index b4536bd..3259bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Comprehensive documentation suite including guides for architecture, configuration, testing, and more. +- Emacs keymap profile alongside runtime `:keymap` switching between Vim and Emacs layouts. - Rustdoc examples for core components like `Provider` and `SessionController`. - Module-level documentation for `owlen-tui`. - Provider integration tests (`crates/owlen-providers/tests`) covering registration, routing, and health status handling for the new `ProviderManager`. diff --git a/README.md b/README.md index 13a840b..861bf57 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,15 @@ OWLEN uses a modal, vim-inspired interface. Press `F1` (available from any mode) - **Tutorial Command**: Type `:tutorial` any time for a quick summary of the most important keybindings. - **MCP Slash Commands**: Owlen auto-registers zero-argument MCP tools as slash commands—type `/mcp__github__list_prs` (for example) to pull remote context directly into the chat log. +### Keymaps + +Two built-in keymaps ship with Owlen: + +- `vim` (default) – the existing modal bindings documented above. +- `emacs` – bindings centred around `Alt+X`, `Ctrl+Space`, and `Alt+O` shortcuts with Emacs-style submit (`Ctrl+Enter`). + +Switch at runtime with `:keymap vim` or `:keymap emacs`. Persist your choice by setting `ui.keymap_profile = "emacs"` (or `"vim"`) in `config.toml`. If you prefer a fully custom layout, point `ui.keymap_path` at a TOML file using the same format as [`crates/owlen-tui/keymap.toml`](crates/owlen-tui/keymap.toml); the new emacs profile file [`crates/owlen-tui/keymap_emacs.toml`](crates/owlen-tui/keymap_emacs.toml) is a useful template. + Model discovery commands worth remembering: - `:models --local` or `:models --cloud` jump directly to the corresponding section in the picker. diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 280e201..8101cfa 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 = "UiSettings::default_keymap_profile")] + pub keymap_profile: Option, #[serde(default)] pub keymap_path: Option, } @@ -1654,6 +1656,10 @@ impl UiSettings { IconMode::Auto } + fn default_keymap_profile() -> Option { + None + } + fn deserialize_role_label_mode<'de, D>( deserializer: D, ) -> std::result::Result @@ -1723,6 +1729,7 @@ impl Default for UiSettings { render_markdown: Self::default_render_markdown(), show_timestamps: Self::default_show_timestamps(), icon_mode: Self::default_icon_mode(), + keymap_profile: Self::default_keymap_profile(), keymap_path: None, } } diff --git a/crates/owlen-tui/keymap_emacs.toml b/crates/owlen-tui/keymap_emacs.toml new file mode 100644 index 0000000..84bef0c --- /dev/null +++ b/crates/owlen-tui/keymap_emacs.toml @@ -0,0 +1,79 @@ +[[binding]] +mode = "normal" +keys = ["Alt+M"] +command = "model.open_all" + +[[binding]] +mode = "normal" +keys = ["Ctrl+Alt+L"] +command = "model.open_local" + +[[binding]] +mode = "normal" +keys = ["Ctrl+Alt+C"] +command = "model.open_cloud" + +[[binding]] +mode = "normal" +keys = ["Ctrl+Alt+A"] +command = "model.open_available" + +[[binding]] +mode = "normal" +keys = ["Alt+x"] +command = "mode.command" + +[[binding]] +mode = "editing" +keys = ["Alt+x"] +command = "mode.command" + +[[binding]] +mode = "normal" +keys = ["Ctrl+Space"] +command = "palette.open" + +[[binding]] +mode = "normal" +keys = ["Alt+O"] +command = "focus.next" + +[[binding]] +mode = "normal" +keys = ["Alt+Shift+O"] +command = "focus.prev" + +[[binding]] +mode = "normal" +keys = ["Alt+1"] +command = "focus.files" + +[[binding]] +mode = "normal" +keys = ["Alt+2"] +command = "focus.chat" + +[[binding]] +mode = "normal" +keys = ["Alt+3"] +command = "focus.code" + +[[binding]] +mode = "normal" +keys = ["Alt+4"] +command = "focus.thinking" + +[[binding]] +mode = "normal" +keys = ["Alt+5"] +command = "focus.input" + +[[binding]] +mode = "editing" +keys = ["Ctrl+Enter"] +command = "composer.submit" + +[[binding]] +mode = "normal" +keys = ["Ctrl+Alt+D"] +command = "debug.toggle" diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index a355e7e..f679e69 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -52,10 +52,10 @@ use crate::model_info_panel::ModelInfoPanel; use crate::slash::{self, McpSlashCommand, SlashCommand}; use crate::state::{ CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver, - FileNode, FileTreeState, Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection, - PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, - SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, - spawn_symbol_search_task, + FileNode, FileTreeState, Keymap, KeymapProfile, ModelPaletteEntry, PaletteSuggestion, + PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, + SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, install_global_logger, + spawn_repo_search_task, spawn_symbol_search_task, }; use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::ui::format_tool_output; @@ -554,6 +554,7 @@ pub struct ChatApp { textarea: TextArea<'static>, // Advanced text input widget mvu_model: AppModel, keymap: Keymap, + current_keymap_profile: KeymapProfile, controller_event_rx: mpsc::UnboundedReceiver, 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) @@ -764,11 +765,13 @@ impl ChatApp { 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(); + let keymap_profile = config_guard.ui.keymap_profile.clone(); drop(config_guard); let keymap = { let registry = CommandRegistry::default(); - Keymap::load(keymap_path.as_deref(), ®istry) + Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), ®istry) }; + let current_keymap_profile = keymap.profile(); let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { eprintln!("Warning: Theme '{}' not found, using default", theme_name); Theme::default() @@ -822,6 +825,7 @@ impl ChatApp { textarea, mvu_model: AppModel::default(), keymap, + current_keymap_profile, controller_event_rx, pending_llm_request: false, pending_tool_execution: None, @@ -2001,6 +2005,42 @@ impl ChatApp { self.last_layout.region_at(column, row) } + pub fn current_keymap_profile(&self) -> KeymapProfile { + self.current_keymap_profile + } + + fn reload_keymap_from_config(&mut self) -> Result<()> { + let registry = CommandRegistry::default(); + let config = self.controller.config(); + let keymap_path = config.ui.keymap_path.clone(); + let keymap_profile = config.ui.keymap_profile.clone(); + drop(config); + + self.keymap = Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), ®istry); + self.current_keymap_profile = self.keymap.profile(); + Ok(()) + } + + async fn switch_keymap_profile(&mut self, profile: KeymapProfile) -> Result<()> { + if self.current_keymap_profile == profile { + self.status = format!("Keymap already set to {}", profile.label()); + self.error = None; + return Ok(()); + } + + { + let mut cfg = self.controller.config_mut(); + cfg.ui.keymap_profile = Some(profile.config_value().to_string()); + cfg.ui.keymap_path = None; + config::save_config(&cfg)?; + } + + self.reload_keymap_from_config()?; + self.status = format!("Keymap switched to {}", profile.label()); + self.error = None; + Ok(()) + } + pub fn is_debug_log_visible(&self) -> bool { self.debug_log.is_visible() } @@ -3473,6 +3513,13 @@ impl ChatApp { self.toggle_debug_log_panel(); Ok(true) } + AppCommand::SetKeymap(profile) => { + self.pending_key = None; + if profile.is_builtin() { + self.switch_keymap_profile(profile).await?; + } + Ok(true) + } } } @@ -7379,6 +7426,32 @@ impl ChatApp { } } } + "keymap" => { + if let Some(arg) = args.first() { + match KeymapProfile::from_str(arg) { + Some(profile) if profile.is_builtin() => { + self.switch_keymap_profile(profile).await?; + } + Some(_) => { + self.error = Some( + "Custom keymaps must be configured via keymap_path".to_string(), + ); + } + None => { + self.error = Some(format!( + "Unknown keymap profile: {}", + arg + )); + } + } + } else { + self.status = format!( + "Active keymap: {}", + self.current_keymap_profile().label() + ); + self.error = None; + } + } _ => { self.error = Some(format!("Unknown command: {}", cmd_owned)); } diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index 50fbdda..c4637b2 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -235,6 +235,18 @@ const COMMANDS: &[CommandSpec] = &[ keyword: "layout load", description: "Restore the last saved pane layout", }, + CommandSpec { + keyword: "keymap", + description: "Show the active keymap profile", + }, + CommandSpec { + keyword: "keymap vim", + description: "Switch to Vim-style key bindings", + }, + CommandSpec { + keyword: "keymap emacs", + description: "Switch to Emacs-style key bindings", + }, CommandSpec { keyword: "files", description: "Toggle the files panel", diff --git a/crates/owlen-tui/src/commands/registry.rs b/crates/owlen-tui/src/commands/registry.rs index 7aa625f..c8d46ba 100644 --- a/crates/owlen-tui/src/commands/registry.rs +++ b/crates/owlen-tui/src/commands/registry.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use owlen_core::ui::FocusedPanel; -use crate::widgets::model_picker::FilterMode; +use crate::{state::KeymapProfile, widgets::model_picker::FilterMode}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AppCommand { @@ -14,6 +14,7 @@ pub enum AppCommand { ComposerSubmit, EnterCommandMode, ToggleDebugLog, + SetKeymap(KeymapProfile), } #[derive(Debug)] @@ -67,6 +68,14 @@ impl CommandRegistry { commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit); commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode); commands.insert("debug.toggle".to_string(), AppCommand::ToggleDebugLog); + commands.insert( + "keymap.set_vim".to_string(), + AppCommand::SetKeymap(KeymapProfile::Vim), + ); + commands.insert( + "keymap.set_emacs".to_string(), + AppCommand::SetKeymap(KeymapProfile::Emacs), + ); Self { commands } } @@ -97,6 +106,10 @@ mod tests { registry.resolve("model.open_cloud"), Some(AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly))) ); + assert_eq!( + registry.resolve("keymap.set_emacs"), + Some(AppCommand::SetKeymap(KeymapProfile::Emacs)) + ); } #[test] diff --git a/crates/owlen-tui/src/state/keymap.rs b/crates/owlen-tui/src/state/keymap.rs index 7d9e60f..b55ae23 100644 --- a/crates/owlen-tui/src/state/keymap.rs +++ b/crates/owlen-tui/src/state/keymap.rs @@ -12,19 +12,24 @@ use serde::Deserialize; use crate::commands::registry::{AppCommand, CommandRegistry}; const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml"); +const EMACS_KEYMAP: &str = include_str!("../../keymap_emacs.toml"); #[derive(Debug, Clone)] pub struct Keymap { bindings: HashMap<(InputMode, KeyPattern), AppCommand>, + profile: KeymapProfile, } impl Keymap { - pub fn load(custom_path: Option<&str>, registry: &CommandRegistry) -> Self { - let mut content = None; - + pub fn load( + custom_path: Option<&str>, + preferred_profile: Option<&str>, + registry: &CommandRegistry, + ) -> Self { + let mut loader = KeymapLoader::new(preferred_profile); if let Some(path) = custom_path.and_then(expand_path) { if let Ok(text) = fs::read_to_string(&path) { - content = Some(text); + loader.with_explicit(text); } else { warn!( "Failed to read keymap from {}. Falling back to defaults.", @@ -33,20 +38,10 @@ impl Keymap { } } - 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); - } - } - } + loader.try_default_path(default_config_keymap_path()); + loader.with_embedded(DEFAULT_KEYMAP.to_string()); - 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 (parsed, profile) = loader.finish(); let mut bindings = HashMap::new(); @@ -80,13 +75,17 @@ impl Keymap { } } - Self { bindings } + Self { bindings, profile } } pub fn resolve(&self, mode: InputMode, event: &KeyEvent) -> Option { let pattern = KeyPattern::from_event(event)?; self.bindings.get(&(mode, pattern)).copied() } + + pub fn profile(&self) -> KeymapProfile { + self.profile + } } #[derive(Debug, Deserialize)] @@ -281,10 +280,124 @@ fn normalize_modifiers(modifiers: KeyModifiers) -> KeyModifiers { modifiers } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeymapProfile { + Vim, + Emacs, + Custom, +} + +impl KeymapProfile { + pub(crate) fn from_str(input: &str) -> Option { + match input.to_ascii_lowercase().as_str() { + "vim" | "default" => Some(Self::Vim), + "emacs" => Some(Self::Emacs), + "custom" => Some(Self::Custom), + _ => None, + } + } + + fn builtin(&self) -> Option<&'static str> { + match self { + Self::Vim => Some(DEFAULT_KEYMAP), + Self::Emacs => Some(EMACS_KEYMAP), + Self::Custom => None, + } + } + + pub(crate) fn config_value(&self) -> &'static str { + match self { + Self::Vim => "vim", + Self::Emacs => "emacs", + Self::Custom => "custom", + } + } + + pub(crate) fn label(&self) -> &'static str { + match self { + Self::Vim => "Vim", + Self::Emacs => "Emacs", + Self::Custom => "Custom", + } + } + + pub(crate) fn is_builtin(&self) -> bool { + matches!(self, Self::Vim | Self::Emacs) + } +} + +struct KeymapLoader { + explicit: Option, + default_path_content: Option, + preferred_profile: Option, + active: KeymapProfile, +} + +impl KeymapLoader { + fn new(preferred_profile: Option<&str>) -> Self { + let preferred = preferred_profile.and_then(KeymapProfile::from_str); + Self { + explicit: None, + default_path_content: None, + preferred_profile: preferred, + active: preferred.unwrap_or(KeymapProfile::Vim), + } + } + + fn with_explicit(&mut self, content: String) { + self.explicit = Some(content); + self.active = KeymapProfile::Custom; + } + + fn try_default_path(&mut self, path: Option) { + if self.explicit.is_some() { + return; + } + + if let Some(path) = path { + if let Ok(text) = fs::read_to_string(&path) { + self.default_path_content = Some(text); + self.active = KeymapProfile::Custom; + } + } + } + + fn with_embedded(&mut self, fallback: String) { + if self.explicit.is_some() || self.default_path_content.is_some() { + return; + } + + if let Some(profile) = self.preferred_profile.and_then(|profile| profile.builtin()) { + self.explicit = Some(profile.to_string()); + self.active = self.preferred_profile.unwrap_or(KeymapProfile::Vim); + } else { + self.explicit = Some(fallback); + self.active = KeymapProfile::Vim; + } + } + + fn finish(self) -> (KeymapConfig, KeymapProfile) { + let data = self + .explicit + .or(self.default_path_content) + .unwrap_or_else(|| DEFAULT_KEYMAP.to_string()); + + match toml::from_str(&data) { + Ok(parsed) => (parsed, self.active), + Err(err) => { + warn!("Failed to parse keymap: {err}. Using built-in defaults."); + let parsed = toml::from_str(DEFAULT_KEYMAP) + .expect("embedded keymap should parse successfully"); + (parsed, KeymapProfile::Vim) + } + } + } +} + #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyCode, KeyModifiers}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; #[test] fn resolve_binding_from_default_keymap() { @@ -292,7 +405,7 @@ mod tests { 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 keymap = Keymap::load(None, None, ®istry); let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); assert!( @@ -304,4 +417,19 @@ mod tests { Some(AppCommand::OpenModelPicker(None)) ); } + + #[test] + fn emacs_profile_loads_builtin() { + let registry = CommandRegistry::new(); + let keymap = Keymap::load(None, Some("emacs"), ®istry); + assert_eq!(keymap.profile(), KeymapProfile::Emacs); + assert!( + keymap + .resolve( + InputMode::Normal, + &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT) + ) + .is_some() + ); + } } diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index d1dd0c3..25c1354 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -19,7 +19,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 keymap::{Keymap, KeymapProfile}; pub use search::{ RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind, RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState, diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 59e6ffe..bf5a794 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -17,7 +17,7 @@ use crate::chat_app::{ }; use crate::highlight; use crate::state::{ - CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, + CodePane, EditorTab, FileFilterMode, FileNode, KeymapProfile, LayoutNode, PaletteGroup, PaneId, RepoSearchRowKind, SplitAxis, VisibleFileEntry, }; use crate::toast::{Toast, ToastLevel}; @@ -3041,6 +3041,7 @@ fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); + let profile = app.current_keymap_profile(); let area = centered_rect(75, 70, frame.area()); frame.render_widget(Clear, area); @@ -3078,109 +3079,194 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { } let mut help_text = match tab_index { - 0 => vec![ - // Navigation - Line::from(""), - Line::from(vec![Span::styled( - "PANEL FOCUS", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Ctrl/Alt+1 → focus Files (opens when available)"), - Line::from(" Ctrl/Alt+2 → focus Chat timeline"), - Line::from(" Ctrl/Alt+3 → focus Code view (requires open file)"), - Line::from(" Ctrl/Alt+4 → focus Thinking / Agent Actions"), - Line::from(" Ctrl/Alt+5 → focus Input editor"), - Line::from(" Tab / Shift+Tab → cycle panels forward/backward"), - Line::from(""), - Line::from(vec![Span::styled( + 0 => { + let mut lines = vec![ + Line::from(""), + Line::from(vec![Span::styled( + "PANEL FOCUS", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), + ]; + + match profile { + KeymapProfile::Emacs => { + lines.push(Line::from( + " Alt+1 → focus Files (opens when available)", + )); + lines.push(Line::from(" Alt+2 → focus Chat timeline")); + lines.push(Line::from( + " Alt+3 → focus Code view (requires open file)", + )); + lines.push(Line::from( + " Alt+4 → focus Thinking / Agent Actions", + )); + lines.push(Line::from(" Alt+5 → focus Input editor")); + lines.push(Line::from( + " Alt+O → cycle panels forward (Tab also works)", + )); + lines.push(Line::from(" Alt+Shift+O → cycle panels backward")); + } + _ => { + lines.push(Line::from( + " Ctrl/Alt+1 → focus Files (opens when available)", + )); + lines.push(Line::from(" Ctrl/Alt+2 → focus Chat timeline")); + lines.push(Line::from( + " Ctrl/Alt+3 → focus Code view (requires open file)", + )); + lines.push(Line::from( + " Ctrl/Alt+4 → focus Thinking / Agent Actions", + )); + lines.push(Line::from(" Ctrl/Alt+5 → focus Input editor")); + } + } + + lines.push(Line::from( + " Tab / Shift+Tab → cycle panels forward/backward", + )); + if matches!(profile, KeymapProfile::Vim) { + lines.push(Line::from( + " g then t → expand files panel and focus it", + )); + } + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( "VISIBLE CUES", Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" ▌ beacon highlights the active row; brighter when focused"), - Line::from(" Status bar shows MODE · workspace · focus target + shortcut"), - Line::from(" Agent badge flips between 🤖 RUN and 🤖 ARM when automation changes"), - Line::from(" Use :themes to swap palettes—default_dark is tuned for contrast"), - Line::from(""), - Line::from(vec![Span::styled( + )])); + lines.push(Line::from( + " ▌ beacon highlights the active row; brighter when focused", + )); + lines.push(Line::from( + " Status bar shows MODE · workspace · focus target + shortcut", + )); + lines.push(Line::from( + " Agent badge flips between 🤖 RUN and 🤖 ARM when automation changes", + )); + lines.push(Line::from( + " Use :themes to swap palettes—default_dark is tuned for contrast", + )); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( "LAYOUT CONTROLS", Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Ctrl+←/→ → resize files panel"), - Line::from(" Ctrl+↑/↓ → adjust chat ↔ thinking split"), - Line::from(" Alt+←/→/↑/↓ → resize focused code pane"), - Line::from(" g then t → expand files panel and focus it"), - Line::from(" F12 → toggle debug log panel"), - Line::from(" F1 or ? → toggle this help overlay"), - Line::from(""), - Line::from(vec![Span::styled( + )])); + lines.push(Line::from(" Ctrl+←/→ → resize files panel")); + lines.push(Line::from(" Ctrl+↑/↓ → adjust chat ↔ thinking split")); + lines.push(Line::from(" Alt+←/→/↑/↓ → resize focused code pane")); + lines.push(Line::from(" F12 → toggle debug log panel")); + lines.push(Line::from(" F1 or ? → toggle this help overlay")); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( "SCROLLING & MOVEMENT", Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" h/← l/→ → move left/right by character"), - Line::from(" j/↓ k/↑ → move down/up by line"), - Line::from(" w / e / b → jump by words (start / end / previous)"), - Line::from(" 0 / ^ / $ → line start / first non-blank / line end"), - Line::from(" gg / G → jump to top / bottom"), - Line::from(" Ctrl+d / Ctrl+u → half-page scroll down/up"), - Line::from(" Ctrl+f / Ctrl+b → full-page scroll down/up"), - Line::from(" PageUp / PageDown → full-page scroll"), - Line::from(""), - Line::from(vec![Span::styled( + )])); + lines.push(Line::from(" h/← l/→ → move left/right by character")); + lines.push(Line::from(" j/↓ k/↑ → move down/up by line")); + lines.push(Line::from( + " w / e / b → jump by words (start / end / previous)", + )); + lines.push(Line::from( + " 0 / ^ / $ → line start / first non-blank / line end", + )); + lines.push(Line::from(" gg / G → jump to top / bottom")); + lines.push(Line::from(" Ctrl+d / Ctrl+u → half-page scroll down/up")); + lines.push(Line::from(" Ctrl+f / Ctrl+b → full-page scroll down/up")); + lines.push(Line::from(" PageUp / PageDown → full-page scroll")); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( "ACCESSIBILITY", Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" High-contrast defaults keep text legible in low-light terminals"), - Line::from(" Focus shortcuts avoid chords—great with screen readers"), - Line::from(" Thinking and Agent Actions share the Ctrl/Alt+4 focus key"), - ], - 1 => vec![ - // Editing - Line::from(""), - Line::from(vec![Span::styled( - "ENTERING EDIT MODE", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" i or Enter → focus input and begin editing at cursor"), - Line::from(" a / A / I → append after cursor · append at end · insert at start"), - Line::from(" o / O → open new line below / above and edit"), - Line::from(" Ctrl/Alt+5 → jump to the input panel from any view"), - Line::from(""), - Line::from(vec![Span::styled( - "SENDING & NEWLINES", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Enter → send message (slash commands run before send)"), - Line::from(" Shift+Enter → insert newline without leaving edit mode"), - Line::from(" Ctrl+J → insert newline (multiline compose)"), - Line::from(" Esc / Ctrl+[ → return to normal mode"), - Line::from(""), - Line::from(vec![Span::styled( - "EDITING UTILITIES", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Ctrl+↑ / Ctrl+↓ → cycle input history"), - Line::from(" Ctrl+A / Ctrl+E → jump to start / end of line"), - Line::from(" Ctrl+W / Ctrl+B → move cursor by words forward/back"), - Line::from(" Ctrl+P → open command palette without exiting edit mode"), - Line::from(" Ctrl+C → cancel streaming response and exit editing"), - Line::from(""), - Line::from(vec![Span::styled( - "NORMAL MODE SHORTCUTS", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" dd → clear input buffer"), - Line::from(" p → paste clipboard into input"), - Line::from(" Ctrl+P → open command palette (also works in Normal)"), - Line::from(""), - Line::from(vec![Span::styled( - "TIPS", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )]), - Line::from(" • Slash commands (e.g. :clear, :open) are parsed before sending"), - Line::from(" • After sending, focus returns to Normal—press i or Ctrl/Alt+5 to edit"), - ], + )])); + lines.push(Line::from( + " High-contrast defaults keep text legible in low-light terminals", + )); + lines.push(Line::from( + " Focus shortcuts avoid chords—great with screen readers", + )); + lines.push(Line::from( + " Thinking and Agent Actions share the Ctrl/Alt+4 focus key", + )); + lines + } + 1 => { + let send_line = match profile { + KeymapProfile::Emacs => { + " Ctrl+Enter → send message (Enter inserts newline first)" + } + _ => " Enter → send message (slash commands run before send)", + }; + let newline_primary = match profile { + KeymapProfile::Emacs => { + " Enter → insert newline without leaving edit mode" + } + _ => " Shift+Enter → insert newline without leaving edit mode", + }; + let newline_secondary = match profile { + KeymapProfile::Emacs => " Shift+Enter → insert newline (alternate)", + _ => " Ctrl+J → insert newline (multiline compose)", + }; + let palette_binding = match profile { + KeymapProfile::Emacs => { + " Alt+x → open command palette (also works in Normal)" + } + _ => " Ctrl+P → open command palette (also works in Normal)", + }; + + let lines = vec![ + Line::from(""), + Line::from(vec![Span::styled( + "ENTERING EDIT MODE", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), + Line::from(" i or Enter → focus input and begin editing at cursor"), + Line::from( + " a / A / I → append after cursor · append at end · insert at start", + ), + Line::from(" o / O → open new line below / above and edit"), + Line::from(" Ctrl/Alt+5 → jump to the input panel from any view"), + Line::from(""), + Line::from(vec![Span::styled( + "SENDING & NEWLINES", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), + Line::from(send_line), + Line::from(newline_primary), + Line::from(newline_secondary), + Line::from(" Esc / Ctrl+[ → return to normal mode"), + Line::from(""), + Line::from(vec![Span::styled( + "EDITING UTILITIES", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), + Line::from(" Ctrl+↑ / Ctrl+↓ → cycle input history"), + Line::from(" Ctrl+A / Ctrl+E → jump to start / end of line"), + Line::from(" Ctrl+W / Ctrl+B → move cursor by words forward/back"), + Line::from(palette_binding), + Line::from(" Ctrl+C → cancel streaming response and exit editing"), + Line::from(""), + Line::from(vec![Span::styled( + "NORMAL MODE SHORTCUTS", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), + Line::from(" dd → clear input buffer"), + Line::from(" p → paste clipboard into input"), + Line::from(palette_binding), + Line::from(""), + Line::from(vec![Span::styled( + "TIPS", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.user_message_role), + )]), + Line::from(" • Slash commands (e.g. :clear, :open) are parsed before sending"), + Line::from( + " • After sending, focus returns to Normal—press i or Ctrl/Alt+5 to edit", + ), + ]; + + lines + } 2 => vec![ // Visual Line::from(""), @@ -3231,6 +3317,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { )]), Line::from(" Press ':' to enter command mode, then type one of:"), Line::from(""), + Line::from(" :keymap [vim|emacs] → switch keymap profile"), + Line::from(" :keymap → display the active keymap"), + Line::from(""), Line::from(vec![Span::styled( "KEYBINDINGS", Style::default() diff --git a/docs/configuration.md b/docs/configuration.md index 57b8491..eae9927 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,6 +73,12 @@ These settings customize the look and feel of the terminal interface. - `syntax_highlighting` (boolean, default: `false`) Enables lightweight syntax highlighting inside fenced code blocks when the terminal supports 256-color output. +- `keymap_profile` (string, optional) + Set to `"vim"` or `"emacs"` to pick a built-in keymap profile. When omitted the default Vim bindings are used. Runtime changes triggered via `:keymap ...` are persisted by updating this field. + +- `keymap_path` (string, optional) + Absolute path to a custom keymap definition. When present it overrides `keymap_profile`. See `crates/owlen-tui/keymap.toml` or `crates/owlen-tui/keymap_emacs.toml` for the expected TOML structure. + ## Storage Settings (`[storage]`) These settings control how conversations are saved and loaded.