feat(tui): add Emacs keymap profile with runtime switching
- 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.
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub keymap_path: Option<String>,
|
||||
}
|
||||
@@ -1654,6 +1656,10 @@ impl UiSettings {
|
||||
IconMode::Auto
|
||||
}
|
||||
|
||||
fn default_keymap_profile() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn deserialize_role_label_mode<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<RoleLabelDisplay, D::Error>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
79
crates/owlen-tui/keymap_emacs.toml
Normal file
79
crates/owlen-tui/keymap_emacs.toml
Normal file
@@ -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"
|
||||
@@ -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<ControllerEvent>,
|
||||
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)
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<AppCommand> {
|
||||
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<Self> {
|
||||
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<String>,
|
||||
default_path_content: Option<String>,
|
||||
preferred_profile: Option<KeymapProfile>,
|
||||
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<PathBuf>) {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user