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
|
### 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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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::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(), ®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(|| {
|
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(), ®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 {
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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, ®istry);
|
let keymap = Keymap::load(None, None, ®istry);
|
||||||
|
|
||||||
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"), ®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::{
|
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,
|
||||||
|
|||||||
@@ -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 / b → jump 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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user