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
- 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`.

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.
- **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.

View File

@@ -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,
}
}

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::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(), &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(|| {
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(), &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 {
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));
}

View File

@@ -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",

View File

@@ -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]

View File

@@ -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, &registry);
let keymap = Keymap::load(None, None, &registry);
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"), &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::{
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,

View File

@@ -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 / bjump 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()

View File

@@ -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.