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:
2025-10-18 04:51:39 +02:00
parent 02f25b7bec
commit 3722840d2c
11 changed files with 540 additions and 123 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Comprehensive documentation suite including guides for architecture, configuration, testing, and more. - 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`. - Rustdoc examples for core components like `Provider` and `SessionController`.
- Module-level documentation for `owlen-tui`. - Module-level documentation for `owlen-tui`.
- Provider integration tests (`crates/owlen-providers/tests`) covering registration, routing, and health status handling for the new `ProviderManager`. - Provider integration tests (`crates/owlen-providers/tests`) covering registration, routing, and health status handling for the new `ProviderManager`.

View File

@@ -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. - **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. - **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: Model discovery commands worth remembering:
- `:models --local` or `:models --cloud` jump directly to the corresponding section in the picker. - `:models --local` or `:models --cloud` jump directly to the corresponding section in the picker.

View File

@@ -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 = "UiSettings::default_keymap_profile")]
pub keymap_profile: Option<String>,
#[serde(default)] #[serde(default)]
pub keymap_path: Option<String>, pub keymap_path: Option<String>,
} }
@@ -1654,6 +1656,10 @@ impl UiSettings {
IconMode::Auto IconMode::Auto
} }
fn default_keymap_profile() -> Option<String> {
None
}
fn deserialize_role_label_mode<'de, D>( fn deserialize_role_label_mode<'de, D>(
deserializer: D, deserializer: D,
) -> std::result::Result<RoleLabelDisplay, D::Error> ) -> std::result::Result<RoleLabelDisplay, D::Error>
@@ -1723,6 +1729,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_profile: Self::default_keymap_profile(),
keymap_path: None, keymap_path: None,
} }
} }

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

View File

@@ -52,10 +52,10 @@ 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, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver, CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
FileNode, FileTreeState, Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection, FileNode, FileTreeState, Keymap, KeymapProfile, ModelPaletteEntry, PaletteSuggestion,
PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis,
SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, install_global_logger,
spawn_symbol_search_task, 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;
@@ -554,6 +554,7 @@ pub struct ChatApp {
textarea: TextArea<'static>, // Advanced text input widget textarea: TextArea<'static>, // Advanced text input widget
mvu_model: AppModel, mvu_model: AppModel,
keymap: Keymap, keymap: Keymap,
current_keymap_profile: KeymapProfile,
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>, controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
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)
@@ -764,11 +765,13 @@ impl ChatApp {
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(); let keymap_path = config_guard.ui.keymap_path.clone();
let keymap_profile = config_guard.ui.keymap_profile.clone();
drop(config_guard); drop(config_guard);
let keymap = { let keymap = {
let registry = CommandRegistry::default(); let registry = CommandRegistry::default();
Keymap::load(keymap_path.as_deref(), &registry) Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), &registry)
}; };
let current_keymap_profile = keymap.profile();
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()
@@ -822,6 +825,7 @@ impl ChatApp {
textarea, textarea,
mvu_model: AppModel::default(), mvu_model: AppModel::default(),
keymap, keymap,
current_keymap_profile,
controller_event_rx, controller_event_rx,
pending_llm_request: false, pending_llm_request: false,
pending_tool_execution: None, pending_tool_execution: None,
@@ -2001,6 +2005,42 @@ impl ChatApp {
self.last_layout.region_at(column, row) 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(), &registry);
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 { pub fn is_debug_log_visible(&self) -> bool {
self.debug_log.is_visible() self.debug_log.is_visible()
} }
@@ -3473,6 +3513,13 @@ impl ChatApp {
self.toggle_debug_log_panel(); self.toggle_debug_log_panel();
Ok(true) 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)); self.error = Some(format!("Unknown command: {}", cmd_owned));
} }

View File

@@ -235,6 +235,18 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "layout load", keyword: "layout load",
description: "Restore the last saved pane layout", 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 { CommandSpec {
keyword: "files", keyword: "files",
description: "Toggle the files panel", description: "Toggle the files panel",

View File

@@ -2,7 +2,7 @@ use std::collections::HashMap;
use owlen_core::ui::FocusedPanel; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AppCommand { pub enum AppCommand {
@@ -14,6 +14,7 @@ pub enum AppCommand {
ComposerSubmit, ComposerSubmit,
EnterCommandMode, EnterCommandMode,
ToggleDebugLog, ToggleDebugLog,
SetKeymap(KeymapProfile),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -67,6 +68,14 @@ impl CommandRegistry {
commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit); commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit);
commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode); commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode);
commands.insert("debug.toggle".to_string(), AppCommand::ToggleDebugLog); 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 } Self { commands }
} }
@@ -97,6 +106,10 @@ mod tests {
registry.resolve("model.open_cloud"), registry.resolve("model.open_cloud"),
Some(AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly))) Some(AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly)))
); );
assert_eq!(
registry.resolve("keymap.set_emacs"),
Some(AppCommand::SetKeymap(KeymapProfile::Emacs))
);
} }
#[test] #[test]

View File

@@ -12,19 +12,24 @@ use serde::Deserialize;
use crate::commands::registry::{AppCommand, CommandRegistry}; use crate::commands::registry::{AppCommand, CommandRegistry};
const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml"); const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml");
const EMACS_KEYMAP: &str = include_str!("../../keymap_emacs.toml");
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Keymap { pub struct Keymap {
bindings: HashMap<(InputMode, KeyPattern), AppCommand>, bindings: HashMap<(InputMode, KeyPattern), AppCommand>,
profile: KeymapProfile,
} }
impl Keymap { impl Keymap {
pub fn load(custom_path: Option<&str>, registry: &CommandRegistry) -> Self { pub fn load(
let mut content = None; 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 Some(path) = custom_path.and_then(expand_path) {
if let Ok(text) = fs::read_to_string(&path) { if let Ok(text) = fs::read_to_string(&path) {
content = Some(text); loader.with_explicit(text);
} else { } else {
warn!( warn!(
"Failed to read keymap from {}. Falling back to defaults.", "Failed to read keymap from {}. Falling back to defaults.",
@@ -33,20 +38,10 @@ impl Keymap {
} }
} }
if content.is_none() { loader.try_default_path(default_config_keymap_path());
let default_path = default_config_keymap_path(); loader.with_embedded(DEFAULT_KEYMAP.to_string());
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, profile) = loader.finish();
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(); 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> { pub fn resolve(&self, mode: InputMode, event: &KeyEvent) -> Option<AppCommand> {
let pattern = KeyPattern::from_event(event)?; let pattern = KeyPattern::from_event(event)?;
self.bindings.get(&(mode, pattern)).copied() self.bindings.get(&(mode, pattern)).copied()
} }
pub fn profile(&self) -> KeymapProfile {
self.profile
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -281,10 +280,124 @@ fn normalize_modifiers(modifiers: KeyModifiers) -> KeyModifiers {
modifiers 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test] #[test]
fn resolve_binding_from_default_keymap() { fn resolve_binding_from_default_keymap() {
@@ -292,7 +405,7 @@ mod tests {
assert!(registry.resolve("model.open_all").is_some()); assert!(registry.resolve("model.open_all").is_some());
let parsed: KeymapConfig = toml::from_str(DEFAULT_KEYMAP).unwrap(); let parsed: KeymapConfig = toml::from_str(DEFAULT_KEYMAP).unwrap();
assert!(!parsed.bindings.is_empty()); assert!(!parsed.bindings.is_empty());
let keymap = Keymap::load(None, &registry); let keymap = Keymap::load(None, None, &registry);
let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
assert!( assert!(
@@ -304,4 +417,19 @@ mod tests {
Some(AppCommand::OpenModelPicker(None)) Some(AppCommand::OpenModelPicker(None))
); );
} }
#[test]
fn emacs_profile_loads_builtin() {
let registry = CommandRegistry::new();
let keymap = Keymap::load(None, Some("emacs"), &registry);
assert_eq!(keymap.profile(), KeymapProfile::Emacs);
assert!(
keymap
.resolve(
InputMode::Normal,
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)
)
.is_some()
);
}
} }

View File

@@ -19,7 +19,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 keymap::{Keymap, KeymapProfile};
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,

View File

@@ -17,7 +17,7 @@ use crate::chat_app::{
}; };
use crate::highlight; use crate::highlight;
use crate::state::{ use crate::state::{
CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, CodePane, EditorTab, FileFilterMode, FileNode, KeymapProfile, LayoutNode, PaletteGroup, PaneId,
RepoSearchRowKind, SplitAxis, VisibleFileEntry, RepoSearchRowKind, SplitAxis, VisibleFileEntry,
}; };
use crate::toast::{Toast, ToastLevel}; 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) { fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme(); let theme = app.theme();
let profile = app.current_keymap_profile();
let area = centered_rect(75, 70, frame.area()); let area = centered_rect(75, 70, frame.area());
frame.render_widget(Clear, 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 { let mut help_text = match tab_index {
0 => vec![ 0 => {
// Navigation let mut lines = vec![
Line::from(""), Line::from(""),
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
"PANEL FOCUS", "PANEL FOCUS",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), 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)"), match profile {
Line::from(" Ctrl/Alt+4 → focus Thinking / Agent Actions"), KeymapProfile::Emacs => {
Line::from(" Ctrl/Alt+5 → focus Input editor"), lines.push(Line::from(
Line::from(" Tab / Shift+Tab → cycle panels forward/backward"), " Alt+1 → focus Files (opens when available)",
Line::from(""), ));
Line::from(vec![Span::styled( 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", "VISIBLE CUES",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]), )]));
Line::from(" ▌ beacon highlights the active row; brighter when focused"), lines.push(Line::from(
Line::from(" Status bar shows MODE · workspace · focus target + shortcut"), " ▌ beacon highlights the active row; brighter when focused",
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"), lines.push(Line::from(
Line::from(""), " Status bar shows MODE · workspace · focus target + shortcut",
Line::from(vec![Span::styled( ));
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", "LAYOUT CONTROLS",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]), )]));
Line::from(" Ctrl+←/→ → resize files panel"), lines.push(Line::from(" Ctrl+←/→ → resize files panel"));
Line::from(" Ctrl+↑/↓ → adjust chat ↔ thinking split"), lines.push(Line::from(" Ctrl+↑/↓ → adjust chat ↔ thinking split"));
Line::from(" Alt+←/→/↑/↓ → resize focused code pane"), lines.push(Line::from(" Alt+←/→/↑/↓ → resize focused code pane"));
Line::from(" g then t → expand files panel and focus it"), lines.push(Line::from(" F12 → toggle debug log panel"));
Line::from(" F12 → toggle debug log panel"), lines.push(Line::from(" F1 or ? → toggle this help overlay"));
Line::from(" F1 or ? → toggle this help overlay"), lines.push(Line::from(""));
Line::from(""), lines.push(Line::from(vec![Span::styled(
Line::from(vec![Span::styled(
"SCROLLING & MOVEMENT", "SCROLLING & MOVEMENT",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]), )]));
Line::from(" h/← l/→ → move left/right by character"), lines.push(Line::from(" h/← l/→ → move left/right by character"));
Line::from(" j/↓ k/↑ → move down/up by line"), lines.push(Line::from(" j/↓ k/↑ → move down/up by line"));
Line::from(" w / e / b → jump by words (start / end / previous)"), lines.push(Line::from(
Line::from(" 0 / ^ / $line start / first non-blank / line end"), " w / e / bjump by words (start / end / previous)",
Line::from(" gg / G → jump to top / bottom"), ));
Line::from(" Ctrl+d / Ctrl+u → half-page scroll down/up"), lines.push(Line::from(
Line::from(" Ctrl+f / Ctrl+b → full-page scroll down/up"), " 0 / ^ / $ → line start / first non-blank / line end",
Line::from(" PageUp / PageDown → full-page scroll"), ));
Line::from(""), lines.push(Line::from(" gg / G → jump to top / bottom"));
Line::from(vec![Span::styled( 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", "ACCESSIBILITY",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]), )]));
Line::from(" High-contrast defaults keep text legible in low-light terminals"), lines.push(Line::from(
Line::from(" Focus shortcuts avoid chords—great with screen readers"), " High-contrast defaults keep text legible in low-light terminals",
Line::from(" Thinking and Agent Actions share the Ctrl/Alt+4 focus key"), ));
], lines.push(Line::from(
1 => vec![ " Focus shortcuts avoid chords—great with screen readers",
// Editing ));
Line::from(""), lines.push(Line::from(
Line::from(vec![Span::styled( " Thinking and Agent Actions share the Ctrl/Alt+4 focus key",
"ENTERING EDIT MODE", ));
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), lines
)]), }
Line::from(" i or Enter → focus input and begin editing at cursor"), 1 => {
Line::from(" a / A / I → append after cursor · append at end · insert at start"), let send_line = match profile {
Line::from(" o / O → open new line below / above and edit"), KeymapProfile::Emacs => {
Line::from(" Ctrl/Alt+5 → jump to the input panel from any view"), " Ctrl+Enter → send message (Enter inserts newline first)"
Line::from(""), }
Line::from(vec![Span::styled( _ => " Enter → send message (slash commands run before send)",
"SENDING & NEWLINES", };
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), let newline_primary = match profile {
)]), KeymapProfile::Emacs => {
Line::from(" Enter → send message (slash commands run before send)"), " Enter → insert newline without leaving edit mode"
Line::from(" Shift+Enter → insert newline without leaving edit mode"), }
Line::from(" Ctrl+J → insert newline (multiline compose)"), _ => " Shift+Enter → insert newline without leaving edit mode",
Line::from(" Esc / Ctrl+[ → return to normal mode"), };
Line::from(""), let newline_secondary = match profile {
Line::from(vec![Span::styled( KeymapProfile::Emacs => " Shift+Enter → insert newline (alternate)",
"EDITING UTILITIES", _ => " Ctrl+J → insert newline (multiline compose)",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), };
)]), let palette_binding = match profile {
Line::from(" Ctrl+↑ / Ctrl+↓ → cycle input history"), KeymapProfile::Emacs => {
Line::from(" Ctrl+A / Ctrl+E → jump to start / end of line"), " Alt+x → open command palette (also works in Normal)"
Line::from(" Ctrl+W / Ctrl+B → move cursor by words forward/back"), }
Line::from(" Ctrl+P → open command palette without exiting edit mode"), _ => " Ctrl+P → open command palette (also works in Normal)",
Line::from(" Ctrl+C → cancel streaming response and exit editing"), };
Line::from(""),
Line::from(vec![Span::styled( let lines = vec![
"NORMAL MODE SHORTCUTS", Line::from(""),
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), Line::from(vec![Span::styled(
)]), "ENTERING EDIT MODE",
Line::from(" dd → clear input buffer"), Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
Line::from(" p → paste clipboard into input"), )]),
Line::from(" Ctrl+P → open command palette (also works in Normal)"), Line::from(" i or Enter → focus input and begin editing at cursor"),
Line::from(""), Line::from(
Line::from(vec![Span::styled( " a / A / I → append after cursor · append at end · insert at start",
"TIPS", ),
Style::default() Line::from(" o / O → open new line below / above and edit"),
.add_modifier(Modifier::BOLD) Line::from(" Ctrl/Alt+5 → jump to the input panel from any view"),
.fg(theme.user_message_role), Line::from(""),
)]), Line::from(vec![Span::styled(
Line::from(" • Slash commands (e.g. :clear, :open) are parsed before sending"), "SENDING & NEWLINES",
Line::from(" • After sending, focus returns to Normal—press i or Ctrl/Alt+5 to edit"), 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![ 2 => vec![
// Visual // Visual
Line::from(""), 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(" Press ':' to enter command mode, then type one of:"),
Line::from(""), Line::from(""),
Line::from(" :keymap [vim|emacs] → switch keymap profile"),
Line::from(" :keymap → display the active keymap"),
Line::from(""),
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
"KEYBINDINGS", "KEYBINDINGS",
Style::default() Style::default()

View File

@@ -73,6 +73,12 @@ These settings customize the look and feel of the terminal interface.
- `syntax_highlighting` (boolean, default: `false`) - `syntax_highlighting` (boolean, default: `false`)
Enables lightweight syntax highlighting inside fenced code blocks when the terminal supports 256-color output. 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]`) ## Storage Settings (`[storage]`)
These settings control how conversations are saved and loaded. These settings control how conversations are saved and loaded.