feat(keymap): add configurable leader and Emacs enhancements

This commit is contained in:
2025-10-25 09:54:24 +02:00
parent 2d45406982
commit cf0a8f21d5
8 changed files with 800 additions and 266 deletions

View File

@@ -17,7 +17,7 @@ use std::time::Duration;
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
/// Current schema version written to `config.toml`.
pub const CONFIG_SCHEMA_VERSION: &str = "1.7.0";
pub const CONFIG_SCHEMA_VERSION: &str = "1.8.0";
/// Provider config key for forcing Ollama provider mode.
pub const OLLAMA_MODE_KEY: &str = "ollama_mode";
@@ -1812,6 +1812,8 @@ pub struct UiSettings {
pub icon_mode: IconMode,
#[serde(default = "UiSettings::default_keymap_profile")]
pub keymap_profile: Option<String>,
#[serde(default = "UiSettings::default_keymap_leader")]
pub keymap_leader: String,
#[serde(default)]
pub keymap_path: Option<String>,
#[serde(default)]
@@ -1915,6 +1917,10 @@ impl UiSettings {
None
}
fn default_keymap_leader() -> String {
"Space".to_string()
}
fn deserialize_role_label_mode<'de, D>(
deserializer: D,
) -> std::result::Result<RoleLabelDisplay, D::Error>
@@ -1985,6 +1991,7 @@ impl Default for UiSettings {
show_timestamps: Self::default_show_timestamps(),
icon_mode: Self::default_icon_mode(),
keymap_profile: Self::default_keymap_profile(),
keymap_leader: Self::default_keymap_leader(),
keymap_path: None,
accessibility: AccessibilitySettings::default(),
}

View File

@@ -1,27 +1,47 @@
[[binding]]
modes = ["normal"]
sequence = ["m"]
command = "model.open_all"
sequences = [
["m"],
["<leader>", "m"]
]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+Shift+L"]
command = "model.open_local"
sequences = [
["Ctrl+Shift+L"],
["<leader>", "m", "l"]
]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+Shift+C"]
command = "model.open_cloud"
sequences = [
["Ctrl+Shift+C"],
["<leader>", "m", "c"]
]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+Shift+P"]
command = "model.open_available"
sequences = [
["Ctrl+Shift+P"],
["<leader>", "m", "a"]
]
[[binding]]
modes = ["normal", "editing"]
sequence = ["Ctrl+P"]
command = "palette.open"
sequences = [
["Ctrl+P"],
["<leader>", "t"]
]
[[binding]]
modes = ["normal"]
command = "provider.switch"
sequence = ["<leader>", "p"]
[[binding]]
modes = ["normal"]
@@ -35,38 +55,56 @@ command = "focus.prev"
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+1"]
command = "focus.files"
sequences = [
["Ctrl+1"],
["<leader>", "f", "1"]
]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+2"]
command = "focus.chat"
sequences = [
["Ctrl+2"],
["<leader>", "f", "2"]
]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+3"]
command = "focus.code"
sequences = [
["Ctrl+3"],
["<leader>", "f", "3"]
]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+4"]
command = "focus.thinking"
sequences = [
["Ctrl+4"],
["<leader>", "f", "4"]
]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+5"]
command = "focus.input"
sequences = [
["Ctrl+5"],
["<leader>", "f", "5"]
]
[[binding]]
modes = ["editing"]
sequence = ["Enter"]
command = "composer.submit"
sequence = ["Enter"]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+;"]
command = "mode.command"
sequences = [
["Ctrl+;"],
["<leader>", ":"]
]
[[binding]]
modes = ["normal", "editing", "visual", "command", "help"]
@@ -75,78 +113,98 @@ command = "debug.toggle"
[[binding]]
modes = ["normal"]
sequence = ["g", "g"]
command = "navigate.top"
sequences = [
["g", "g"],
["<leader>", "g", "t"]
]
[[binding]]
modes = ["normal"]
command = "navigate.bottom"
sequences = [
["Shift+G"],
["<leader>", "g", "b"]
]
[[binding]]
modes = ["normal"]
sequence = ["g", "t"]
command = "files.focus_expand"
sequences = [
["g", "t"],
["g", "T"],
["<leader>", "f", "e"]
]
[[binding]]
modes = ["normal"]
sequence = ["g", "T"]
command = "files.focus_expand"
[[binding]]
modes = ["normal"]
sequence = ["g", "h"]
command = "files.toggle_hidden"
sequences = [
["g", "h"],
["g", "H"],
["<leader>", "f", "h"]
]
[[binding]]
modes = ["normal"]
sequence = ["g", "H"]
command = "files.toggle_hidden"
[[binding]]
modes = ["normal"]
sequence = ["d", "d"]
command = "input.clear"
sequences = [
["d", "d"],
["<leader>", "m", "d"]
]
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+W", "s"]
command = "workspace.split_horizontal"
sequences = [
["Ctrl+W", "s"],
["Ctrl+W", "S"],
["<leader>", "l", "s"]
]
timeout_ms = 1200
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+W", "S"]
command = "workspace.split_horizontal"
timeout_ms = 1200
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+W", "v"]
command = "workspace.split_vertical"
sequences = [
["Ctrl+W", "v"],
["Ctrl+W", "V"],
["<leader>", "l", "v"]
]
timeout_ms = 1200
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+W", "V"]
command = "workspace.split_vertical"
timeout_ms = 1200
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+K", "Left"]
command = "workspace.focus_left"
sequences = [
["Ctrl+K", "Left"],
["<leader>", "l", "h"]
]
timeout_ms = 1200
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+K", "Right"]
command = "workspace.focus_right"
sequences = [
["Ctrl+K", "Right"],
["<leader>", "l", "l"]
]
timeout_ms = 1200
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+K", "Up"]
command = "workspace.focus_up"
sequences = [
["Ctrl+K", "Up"],
["<leader>", "l", "k"]
]
timeout_ms = 1200
[[binding]]
modes = ["normal"]
sequence = ["Ctrl+K", "Down"]
command = "workspace.focus_down"
sequences = [
["Ctrl+K", "Down"],
["<leader>", "l", "j"]
]
timeout_ms = 1200

View File

@@ -1,79 +1,159 @@
[[binding]]
mode = "normal"
keys = ["Alt+M"]
command = "model.open_all"
sequences = [
["Alt+M"],
["Ctrl+X", "m"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
keys = ["Ctrl+Alt+L"]
command = "model.open_local"
sequences = [
["Ctrl+Alt+L"],
["Ctrl+X", "l"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
keys = ["Ctrl+Alt+C"]
command = "model.open_cloud"
sequences = [
["Ctrl+Alt+C"],
["Ctrl+X", "c"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
keys = ["Ctrl+Alt+A"]
command = "model.open_available"
sequences = [
["Ctrl+Alt+A"],
["Ctrl+X", "a"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
keys = ["Alt+x"]
command = "mode.command"
[[binding]]
mode = "editing"
keys = ["Alt+x"]
command = "mode.command"
[[binding]]
mode = "normal"
keys = ["Ctrl+Space"]
modes = ["normal", "editing"]
sequence = ["Ctrl+Space"]
command = "palette.open"
[[binding]]
mode = "normal"
keys = ["Alt+O"]
command = "provider.switch"
sequences = [
["Ctrl+X", "Ctrl+P"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
sequence = ["Alt+O"]
command = "focus.next"
[[binding]]
mode = "normal"
keys = ["Alt+Shift+O"]
sequence = ["Alt+Shift+O"]
command = "focus.prev"
[[binding]]
mode = "normal"
keys = ["Alt+1"]
command = "focus.files"
sequence = ["Alt+1"]
[[binding]]
mode = "normal"
keys = ["Alt+2"]
command = "focus.chat"
sequence = ["Alt+2"]
[[binding]]
mode = "normal"
keys = ["Alt+3"]
command = "focus.code"
sequence = ["Alt+3"]
[[binding]]
mode = "normal"
keys = ["Alt+4"]
command = "focus.thinking"
sequence = ["Alt+4"]
[[binding]]
mode = "normal"
keys = ["Alt+5"]
command = "focus.input"
sequence = ["Alt+5"]
[[binding]]
mode = "editing"
keys = ["Ctrl+Enter"]
command = "composer.submit"
sequences = [
["Ctrl+Enter"],
["Ctrl+X", "Ctrl+S"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
keys = ["Ctrl+Alt+D"]
command = "mode.command"
sequence = ["Alt+x"]
[[binding]]
modes = ["normal", "editing", "visual", "command", "help"]
sequence = ["F12"]
command = "debug.toggle"
[[binding]]
mode = "normal"
command = "files.focus_expand"
sequences = [
["Ctrl+X", "Ctrl+F"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
command = "input.clear"
sequences = [
["Alt+Backspace"],
["Ctrl+X", "k"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
command = "workspace.split_horizontal"
sequence = ["Ctrl+X", "2"]
timeout_ms = 1200
[[binding]]
mode = "normal"
command = "workspace.split_vertical"
sequence = ["Ctrl+X", "3"]
timeout_ms = 1200
[[binding]]
mode = "normal"
command = "workspace.focus_right"
sequence = ["Ctrl+X", "o"]
timeout_ms = 1200
[[binding]]
mode = "normal"
command = "workspace.focus_left"
sequence = ["Ctrl+X", "Shift+O"]
timeout_ms = 1200
[[binding]]
mode = "normal"
command = "navigate.top"
sequences = [
["Alt+G", "g"]
]
timeout_ms = 1200
[[binding]]
mode = "normal"
command = "navigate.bottom"
sequences = [
["Alt+G", "Shift+G"]
]
timeout_ms = 1200

View File

@@ -56,10 +56,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, KeymapEventResult, KeymapProfile, KeymapState,
ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage,
RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot,
install_global_logger, spawn_repo_search_task, spawn_symbol_search_task,
FileNode, FileTreeState, Keymap, KeymapEventResult, KeymapOverrides, KeymapProfile,
KeymapState, 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_token_short, format_tool_output};
@@ -581,6 +581,7 @@ pub struct ChatApp {
mvu_model: AppModel,
keymap: Keymap,
current_keymap_profile: KeymapProfile,
keymap_leader: String,
keymap_state: KeymapState,
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
@@ -794,13 +795,21 @@ impl ChatApp {
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();
let keymap_leader_raw = config_guard.ui.keymap_leader.clone();
let accessibility = config_guard.ui.accessibility.clone();
drop(config_guard);
let keymap_overrides = KeymapOverrides::new(keymap_leader_raw);
let keymap = {
let registry = CommandRegistry::default();
Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), &registry)
Keymap::load(
keymap_path.as_deref(),
keymap_profile.as_deref(),
&registry,
keymap_overrides.clone(),
)
};
let current_keymap_profile = keymap.profile();
let keymap_leader = keymap_overrides.leader().to_string();
let base_theme_name = theme_name.clone();
let mut theme = owlen_core::theme::get_theme(&base_theme_name).unwrap_or_else(|| {
eprintln!("Warning: Theme '{}' not found, using default", theme_name);
@@ -867,6 +876,7 @@ impl ChatApp {
mvu_model: AppModel::default(),
keymap,
current_keymap_profile,
keymap_leader,
keymap_state: KeymapState::default(),
controller_event_rx,
pending_llm_request: false,
@@ -2145,15 +2155,27 @@ impl ChatApp {
self.current_keymap_profile
}
pub fn keymap_leader(&self) -> &str {
&self.keymap_leader
}
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();
let keymap_leader_raw = config.ui.keymap_leader.clone();
drop(config);
self.keymap = Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), &registry);
let overrides = KeymapOverrides::new(keymap_leader_raw);
self.keymap = Keymap::load(
keymap_path.as_deref(),
keymap_profile.as_deref(),
&registry,
overrides.clone(),
);
self.current_keymap_profile = self.keymap.profile();
self.keymap_leader = overrides.leader().to_string();
Ok(())
}
@@ -3837,6 +3859,125 @@ impl ChatApp {
}
}
fn handle_emacs_editing_key(&mut self, key: &KeyEvent) -> bool {
if !matches!(self.focused_panel, FocusedPanel::Input) {
return false;
}
use crossterm::event::{KeyCode, KeyModifiers};
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char(ch) => match (ch.to_ascii_lowercase(), ctrl, alt) {
('y', true, false) => self.emacs_yank(),
('w', true, false) => self.emacs_kill_word_back(),
('w', false, true) => self.emacs_copy_word_back(),
('k', true, false) => self.emacs_kill_line(),
_ => false,
},
_ => false,
}
}
fn emacs_yank(&mut self) -> bool {
if self.clipboard.is_empty() {
self.status = "Kill ring is empty".to_string();
return true;
}
if self.textarea.insert_str(&self.clipboard) {
self.sync_textarea_to_buffer();
self.status = format!("Yanked {} chars", self.clipboard.len());
self.error = None;
} else {
self.status = "Unable to insert clipboard contents".to_string();
}
true
}
fn emacs_kill_word_back(&mut self) -> bool {
self.textarea.cancel_selection();
self.textarea.start_selection();
self.textarea.move_cursor(CursorMove::WordBack);
let killed = if self.textarea.cut() {
let grabbed = self.textarea.yank_text();
if grabbed.is_empty() {
None
} else {
Some(grabbed)
}
} else {
None
};
if let Some(grabbed) = killed {
self.clipboard = grabbed;
self.status = format!("Killed {} chars", self.clipboard.len());
self.error = None;
self.sync_textarea_to_buffer();
} else {
self.status = "Nothing to kill".to_string();
}
self.textarea.cancel_selection();
true
}
fn emacs_copy_word_back(&mut self) -> bool {
self.textarea.cancel_selection();
self.textarea.start_selection();
self.textarea.move_cursor(CursorMove::WordBack);
self.textarea.copy();
let copied = self.textarea.yank_text();
self.textarea.cancel_selection();
self.textarea.move_cursor(CursorMove::WordForward);
if copied.is_empty() {
self.status = "Nothing to copy".to_string();
} else {
self.clipboard = copied;
self.status = format!("Copied {} chars", self.clipboard.len());
self.error = None;
}
true
}
fn emacs_kill_line(&mut self) -> bool {
self.textarea.cancel_selection();
let start_cursor = self.textarea.cursor();
self.textarea.start_selection();
self.textarea.move_cursor(CursorMove::End);
if self.textarea.cursor() == start_cursor {
self.textarea.move_cursor(CursorMove::Forward);
}
let killed = if self.textarea.cut() {
let grabbed = self.textarea.yank_text();
if grabbed.is_empty() {
None
} else {
Some(grabbed)
}
} else {
None
};
if let Some(grabbed) = killed {
self.clipboard = grabbed;
self.status = format!("Killed {} chars", self.clipboard.len());
self.error = None;
self.sync_textarea_to_buffer();
} else {
self.status = "Nothing to kill".to_string();
}
self.textarea.cancel_selection();
true
}
async fn execute_command(&mut self, command: AppCommand) -> Result<bool> {
match command {
AppCommand::OpenModelPicker(filter) => {
@@ -3862,6 +4003,18 @@ impl ChatApp {
self.error = None;
Ok(true)
}
AppCommand::OpenProviderSwitcher => {
if !matches!(self.mode, InputMode::Normal) {
return Ok(false);
}
if let Err(err) = self.show_model_picker(None).await {
self.error = Some(err.to_string());
} else if matches!(self.mode, InputMode::ProviderSelection) {
self.status = "Select a provider to activate".to_string();
self.error = None;
}
Ok(true)
}
AppCommand::CycleFocusForward => {
if !matches!(self.mode, InputMode::Normal) {
return Ok(false);
@@ -3931,6 +4084,17 @@ impl ChatApp {
return Ok(false);
}
self.jump_to_top();
self.status = "Jumped to top".to_string();
self.error = None;
Ok(true)
}
AppCommand::JumpToBottom => {
if !matches!(self.mode, InputMode::Normal) {
return Ok(false);
}
self.jump_to_bottom();
self.status = "Jumped to bottom".to_string();
self.error = None;
Ok(true)
}
AppCommand::ExpandFilePanel => {
@@ -6441,90 +6605,98 @@ impl ChatApp {
}
_ => {}
},
InputMode::Editing => match (key.code, key.modifiers) {
(KeyCode::Char('p'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
InputMode::Editing => {
if self.current_keymap_profile == KeymapProfile::Emacs
&& self.handle_emacs_editing_key(&key)
{
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Command);
self.command_palette.clear();
self.command_palette.ensure_suggestions();
self.status = ":".to_string();
}
(KeyCode::Char('c'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
let _ = self.cancel_active_generation()?;
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Esc, KeyModifiers::NONE) => {
// Sync textarea content to input buffer before leaving edit mode
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Char('['), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.insert_newline();
}
(KeyCode::Enter, KeyModifiers::NONE) => {
self.sync_textarea_to_buffer();
let effects =
self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit));
self.handle_app_effects(effects).await?;
return Ok(AppState::Running);
}
(KeyCode::Enter, _) => {
// Any Enter with modifiers keeps editing and inserts a newline via tui-textarea
self.textarea.input(Input::from(key));
}
// History navigation
(KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().history_previous();
self.sync_buffer_to_textarea();
}
(KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
}
// Vim-style navigation with Ctrl
(KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
}
(KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.move_cursor(tui_textarea::CursorMove::End);
}
(KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordForward);
}
(KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordBack);
}
(KeyCode::Tab, m) if m.is_empty() => {
if !self.complete_resource_reference() {
match (key.code, key.modifiers) {
(KeyCode::Char('p'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Command);
self.command_palette.clear();
self.command_palette.ensure_suggestions();
self.status = ":".to_string();
}
(KeyCode::Char('c'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
let _ = self.cancel_active_generation()?;
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Esc, KeyModifiers::NONE) => {
// Sync textarea content to input buffer before leaving edit mode
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Char('['), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.insert_newline();
}
(KeyCode::Enter, KeyModifiers::NONE) => {
self.sync_textarea_to_buffer();
let effects =
self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit));
self.handle_app_effects(effects).await?;
return Ok(AppState::Running);
}
(KeyCode::Enter, _) => {
// Any Enter with modifiers keeps editing and inserts a newline via tui-textarea
self.textarea.input(Input::from(key));
}
// History navigation
(KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().history_previous();
self.sync_buffer_to_textarea();
}
(KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
}
// Vim-style navigation with Ctrl
(KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
}
(KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.move_cursor(tui_textarea::CursorMove::End);
}
(KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordForward);
}
(KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordBack);
}
(KeyCode::Tab, m) if m.is_empty() => {
if !self.complete_resource_reference() {
self.textarea.input(Input::from(key));
}
}
(KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => {
// Redo - history next
self.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
}
_ => {
// Let tui-textarea handle all other input
self.textarea.input(Input::from(key));
}
}
(KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => {
// Redo - history next
self.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
}
_ => {
// Let tui-textarea handle all other input
self.textarea.input(Input::from(key));
}
},
}
InputMode::Visual => match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('v'), KeyModifiers::NONE) => {
// Cancel selection and return to normal mode

View File

@@ -11,6 +11,7 @@ use crate::{
pub enum AppCommand {
OpenModelPicker(Option<FilterMode>),
OpenCommandPalette,
OpenProviderSwitcher,
CycleFocusForward,
CycleFocusBackward,
FocusPanel(FocusedPanel),
@@ -19,6 +20,7 @@ pub enum AppCommand {
ToggleDebugLog,
SetKeymap(KeymapProfile),
JumpToTop,
JumpToBottom,
ExpandFilePanel,
ToggleHiddenFiles,
SplitPaneHorizontal,
@@ -53,6 +55,10 @@ impl CommandRegistry {
AppCommand::OpenModelPicker(Some(FilterMode::Available)),
);
commands.insert("palette.open".to_string(), AppCommand::OpenCommandPalette);
commands.insert(
"provider.switch".to_string(),
AppCommand::OpenProviderSwitcher,
);
commands.insert("focus.next".to_string(), AppCommand::CycleFocusForward);
commands.insert("focus.prev".to_string(), AppCommand::CycleFocusBackward);
commands.insert(
@@ -87,6 +93,7 @@ impl CommandRegistry {
AppCommand::SetKeymap(KeymapProfile::Emacs),
);
commands.insert("navigate.top".to_string(), AppCommand::JumpToTop);
commands.insert("navigate.bottom".to_string(), AppCommand::JumpToBottom);
commands.insert(
"files.focus_expand".to_string(),
AppCommand::ExpandFilePanel,

View File

@@ -15,6 +15,37 @@ use crate::commands::registry::{AppCommand, CommandRegistry};
const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml");
const EMACS_KEYMAP: &str = include_str!("../../keymap_emacs.toml");
const DEFAULT_SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1200);
const DEFAULT_LEADER_KEY: &str = "Space";
#[derive(Debug, Clone)]
pub struct KeymapOverrides {
leader: String,
}
impl KeymapOverrides {
pub fn new(leader: impl Into<String>) -> Self {
let raw = leader.into();
let trimmed = raw.trim();
let leader = if trimmed.is_empty() {
DEFAULT_LEADER_KEY.to_string()
} else {
trimmed.to_string()
};
Self { leader }
}
pub fn leader(&self) -> &str {
&self.leader
}
}
impl Default for KeymapOverrides {
fn default() -> Self {
Self {
leader: DEFAULT_LEADER_KEY.to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct Keymap {
@@ -22,6 +53,7 @@ pub struct Keymap {
trees: HashMap<InputMode, KeymapNode>,
default_timeout: Duration,
bindings: Vec<ResolvedBinding>,
overrides: KeymapOverrides,
}
#[derive(Debug, Default, Clone)]
@@ -43,6 +75,7 @@ impl Keymap {
custom_path: Option<&str>,
preferred_profile: Option<&str>,
registry: &CommandRegistry,
overrides: KeymapOverrides,
) -> Self {
let mut loader = KeymapLoader::new(preferred_profile);
if let Some(path) = custom_path.and_then(expand_path) {
@@ -86,7 +119,11 @@ impl Keymap {
}
};
let sequences = entry.resolve_sequences();
let sequences = entry
.resolve_sequences()
.into_iter()
.map(|sequence| apply_overrides(sequence, &overrides))
.collect::<Vec<_>>();
if sequences.is_empty() {
warn!(
"No key sequence defined for command '{}' (modes: {:?})",
@@ -134,9 +171,14 @@ impl Keymap {
trees,
default_timeout: DEFAULT_SEQUENCE_TIMEOUT,
bindings,
overrides,
}
}
pub fn leader(&self) -> &str {
self.overrides.leader()
}
pub fn profile(&self) -> KeymapProfile {
self.profile
}
@@ -342,7 +384,7 @@ impl KeyPattern {
for token in tokens[..tokens.len().saturating_sub(1)].iter() {
match token.to_ascii_lowercase().as_str() {
"ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL),
"alt" | "option" => modifiers.insert(KeyModifiers::ALT),
"alt" | "option" | "meta" => modifiers.insert(KeyModifiers::ALT),
"shift" => modifiers.insert(KeyModifiers::SHIFT),
other => warn!("Unknown modifier '{other}' in key binding '{spec}'"),
}
@@ -369,6 +411,7 @@ impl KeyPattern {
}
let key = match self.code {
KeyCodeKind::Char(' ') => "Space".to_string(),
KeyCodeKind::Char(c) => c.to_string(),
KeyCodeKind::Enter => "Enter".to_string(),
KeyCodeKind::Tab => "Tab".to_string(),
@@ -477,6 +520,19 @@ impl KeyList {
}
}
fn apply_overrides(sequence: Vec<String>, overrides: &KeymapOverrides) -> Vec<String> {
sequence
.into_iter()
.map(|token| {
if token.trim().eq_ignore_ascii_case("<leader>") {
overrides.leader().to_string()
} else {
token
}
})
.collect()
}
fn insert_sequence(
root: &mut KeymapNode,
sequence: &[KeyPattern],
@@ -718,7 +774,7 @@ mod tests {
#[test]
fn resolve_binding_from_default_keymap() {
let registry = CommandRegistry::new();
let keymap = Keymap::load(None, None, &registry);
let keymap = Keymap::load(None, None, &registry, KeymapOverrides::default());
let mut state = KeymapState::default();
let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
@@ -732,7 +788,7 @@ mod tests {
#[test]
fn resolves_multi_key_sequence() {
let registry = CommandRegistry::new();
let keymap = Keymap::load(None, None, &registry);
let keymap = Keymap::load(None, None, &registry, KeymapOverrides::default());
let mut state = KeymapState::default();
let first = keymap.step(
@@ -755,7 +811,7 @@ mod tests {
#[test]
fn emacs_profile_loads_builtin() {
let registry = CommandRegistry::new();
let keymap = Keymap::load(None, Some("emacs"), &registry);
let keymap = Keymap::load(None, Some("emacs"), &registry, KeymapOverrides::default());
assert_eq!(keymap.profile(), KeymapProfile::Emacs);
let mut state = KeymapState::default();
let result = keymap.step(
@@ -768,4 +824,27 @@ mod tests {
KeymapEventResult::Matched(AppCommand::EnterCommandMode)
));
}
#[test]
fn leader_override_substitutes_placeholder_tokens() {
let registry = CommandRegistry::new();
let overrides = KeymapOverrides::new("Ctrl+Space");
let keymap = Keymap::load(None, None, &registry, overrides.clone());
assert_eq!(keymap.leader(), overrides.leader());
let mut found_leader_binding = false;
for binding in keymap.describe_bindings() {
if binding.command == "model.open_all"
&& binding.sequence.first().map(String::as_str) == Some("Ctrl+Space")
{
found_leader_binding = true;
break;
}
}
assert!(
found_leader_binding,
"expected leader substitution to produce Ctrl+Space prefix for model.open_all"
);
}
}

View File

@@ -19,7 +19,10 @@ pub use file_icons::{FileIconResolver, FileIconSet, IconDetection};
pub use file_tree::{
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
};
pub use keymap::{Keymap, KeymapBindingDescription, KeymapEventResult, KeymapProfile, KeymapState};
pub use keymap::{
Keymap, KeymapBindingDescription, KeymapEventResult, KeymapOverrides, KeymapProfile,
KeymapState,
};
pub use search::{
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,

View File

@@ -3981,6 +3981,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced);
let profile = app.current_keymap_profile();
let leader = app.keymap_leader();
let area = centered_rect(75, 70, frame.area());
if area.width == 0 || area.height == 0 {
return;
@@ -4077,6 +4078,24 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
" Alt+O → cycle panels forward (Tab also works)",
));
lines.push(Line::from(" Alt+Shift+O → cycle panels backward"));
lines.push(Line::from(" Ctrl+X Ctrl+F → expand Files panel"));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"WINDOW & PROVIDER",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]));
lines.push(Line::from(" Ctrl+X 2 → split horizontal"));
lines.push(Line::from(" Ctrl+X 3 → split vertical"));
lines.push(Line::from(" Ctrl+X o → focus next window"));
lines.push(Line::from(" Ctrl+X Shift+O → focus previous window"));
lines.push(Line::from(" Ctrl+X Ctrl+P → open provider switcher"));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"JUMPS",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]));
lines.push(Line::from(" Alt+G g → jump to top"));
lines.push(Line::from(" Alt+G Shift+G → jump to bottom"));
}
_ => {
lines.push(Line::from(
@@ -4090,6 +4109,33 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
" Ctrl/Alt+4 → focus Thinking / Agent Actions",
));
lines.push(Line::from(" Ctrl/Alt+5 → focus Input editor"));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!("LEADER ({leader})"),
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]));
lines.push(Line::from(format!(
" {leader} m → open model picker"
)));
lines.push(Line::from(format!(
" {leader} m l → focus local models"
)));
lines.push(Line::from(format!(
" {leader} m c → focus cloud models"
)));
lines.push(Line::from(format!(
" {leader} p → provider switcher"
)));
lines.push(Line::from(format!(
" {leader} t → open command palette"
)));
lines.push(Line::from(format!(
" {leader} l s → split horizontal"
)));
lines.push(Line::from(format!(" {leader} l v → split vertical")));
lines.push(Line::from(format!(
" {leader} l h/j/k/l → focus left/down/up/right"
)));
}
}
@@ -4167,7 +4213,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
1 => {
let send_line = match profile {
KeymapProfile::Emacs => {
" Ctrl+Enter → send message (Enter inserts newline first)"
" Ctrl+Enter or Ctrl+X Ctrl+S → send message (Enter inserts newline first)"
}
_ => " Enter → send message (slash commands run before send)",
};
@@ -4181,14 +4227,16 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
KeymapProfile::Emacs => " Shift+Enter → insert newline (alternate)",
_ => " Ctrl+J → insert newline (multiline compose)",
};
let palette_binding = match profile {
let palette_binding: String = match profile {
KeymapProfile::Emacs => {
" Alt+x → open command palette (also works in Normal)"
" Ctrl+Space → open command palette (also works in Normal)".to_string()
}
_ => {
format!(" Ctrl+P / {leader} t → open command palette (also works in Normal)")
}
_ => " Ctrl+P → open command palette (also works in Normal)",
};
let lines = vec![
let mut lines = vec![
Line::from(""),
Line::from(vec![Span::styled(
"ENTERING EDIT MODE",
@@ -4217,29 +4265,44 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
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(palette_binding.clone()),
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",
),
];
if matches!(profile, KeymapProfile::Emacs) {
lines.push(Line::from(vec![Span::styled(
"KILL RING",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]));
lines.push(Line::from(" Ctrl+Y → yank last kill into buffer"));
lines.push(Line::from(" Alt+W → copy previous word"));
lines.push(Line::from(" Ctrl+W → kill previous word"));
lines.push(Line::from(" Ctrl+K → kill to end of line"));
lines.push(Line::from(""));
}
lines.push(Line::from(vec![Span::styled(
"NORMAL MODE SHORTCUTS",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]));
lines.push(Line::from(" dd → clear input buffer"));
lines.push(Line::from(" p → paste clipboard into input"));
lines.push(Line::from(palette_binding.clone()));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"TIPS",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]));
lines.push(Line::from(
" • Slash commands (e.g. :clear, :open) are parsed before sending",
));
lines.push(Line::from(
" • After sending, focus returns to Normal—press i or Ctrl/Alt+5 to edit",
));
lines
}
2 => vec![
@@ -4283,112 +4346,177 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" • Yanked text is available for paste with 'p' in normal mode"),
Line::from(" • Read-only panels (Chat/Thinking) always keep data intact; yank copies"),
],
3 => vec![
// Commands
Line::from(""),
Line::from(vec![Span::styled(
"COMMAND MODE",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
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()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" Enter → execute command"),
Line::from(" Escexit command mode"),
Line::from(" Tab → autocomplete suggestion"),
Line::from(" ↑/↓ → navigate suggestions"),
Line::from(" Backspace → delete character"),
Line::from(" Ctrl+P → open command palette"),
Line::from(""),
Line::from(vec![Span::styled(
3 => {
let mut lines = vec![
Line::from(""),
Line::from(vec![Span::styled(
"COMMAND MODE",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" Press ':' to enter command mode, then type one of:"),
];
if matches!(profile, KeymapProfile::Emacs) {
lines.push(Line::from(
" Alt+x → enter command mode (Emacs M-x)",
));
}
lines.extend([
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()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
]);
lines.push(Line::from(" Enter → execute command"));
lines.push(Line::from(" Esc → exit command mode"));
lines.push(Line::from(" Tab → autocomplete suggestion"));
lines.push(Line::from(" ↑/↓ → navigate suggestions"));
lines.push(Line::from(" Backspace → delete character"));
lines.push(Line::from(" Ctrl+P → open command palette"));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"GENERAL",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :h, :help → show this help"),
Line::from(" F1 or ? → toggle help overlay"),
Line::from(" F12 → toggle debug log panel"),
Line::from(" :files, :explorer → toggle files panel"),
Line::from(" :markdown [on|off] → toggle markdown rendering"),
Line::from(" Ctrl+←/→ → resize files panel"),
Line::from(" Ctrl+↑/↓ → resize chat/thinking split"),
Line::from(" :quit → quit application"),
Line::from(" Ctrl+C twice → quit application"),
Line::from(" :reload → reload configuration and themes"),
Line::from(" :layout save/load → persist or restore pane layout"),
Line::from(""),
Line::from(vec![Span::styled(
)]));
lines.push(Line::from(" :h, :help → show this help"));
lines.push(Line::from(" F1 or ? → toggle help overlay"));
lines.push(Line::from(" F12 → toggle debug log panel"));
lines.push(Line::from(" :files, :explorer → toggle files panel"));
lines.push(Line::from(
" :markdown [on|off] → toggle markdown rendering",
));
lines.push(Line::from(" Ctrl+←/→ → resize files panel"));
lines.push(Line::from(
" Ctrl+↑/↓ → resize chat/thinking split",
));
lines.push(Line::from(" :quit → quit application"));
lines.push(Line::from(" Ctrl+C twice → quit application"));
lines.push(Line::from(
" :reload → reload configuration and themes",
));
lines.push(Line::from(
" :layout save/load → persist or restore pane layout",
));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"CONVERSATION",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :n, :new → start new conversation"),
Line::from(" :c, :clear → clear current conversation"),
Line::from(""),
Line::from(vec![Span::styled(
)]));
lines.push(Line::from(" :n, :new → start new conversation"));
lines.push(Line::from(
" :c, :clear → clear current conversation",
));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"MODEL & THEME",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :m, :model → open model selector"),
Line::from(" :themes → open theme selector"),
Line::from(" :theme <name> → switch to a specific theme"),
Line::from(" :provider <name> [auto|local|cloud] → switch provider or set mode"),
Line::from(" :models --local | --cloud → focus models by scope"),
Line::from(" :cloud setup [--force-cloud-base-url] → configure Ollama Cloud"),
Line::from(" :web on|off|status → manage web_search availability"),
Line::from(" :limits → show hourly/weekly usage totals"),
Line::from(""),
Line::from(vec![Span::styled(
)]));
lines.push(Line::from(" :m, :model → open model selector"));
lines.push(Line::from(" :themes → open theme selector"));
lines.push(Line::from(
" :theme <name> → switch to a specific theme",
));
lines.push(Line::from(
" :provider <name> [auto|local|cloud] → switch provider or set mode",
));
lines.push(Line::from(
" :models --local | --cloud → focus models by scope",
));
lines.push(Line::from(
" :cloud setup [--force-cloud-base-url] → configure Ollama Cloud",
));
lines.push(Line::from(
" :web on|off|status → manage web_search availability",
));
lines.push(Line::from(
" :limits → show hourly/weekly usage totals",
));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"SESSION MANAGEMENT",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :session save [name] → save current session (optional name)"),
Line::from(" :load, :o → browse and load saved sessions"),
Line::from(" :sessions, :ls → browse saved sessions"),
Line::from(""),
Line::from(vec![Span::styled(
)]));
lines.push(Line::from(
" :session save [name] → save current session (optional name)",
));
lines.push(Line::from(
" :load, :o → browse and load saved sessions",
));
lines.push(Line::from(" :sessions, :ls → browse saved sessions"));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"AGENT",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :agent start → arm the agent for the next request"),
Line::from(" :agent stop stop or disarm the agent"),
Line::from(" :agent status → show current agent state"),
Line::from(""),
Line::from(vec![Span::styled(
)]));
lines.push(Line::from(
" :agent start → arm the agent for the next request",
));
lines.push(Line::from(
" :agent stop → stop or disarm the agent",
));
lines.push(Line::from(
" :agent status → show current agent state",
));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"CODE VIEW",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :open <path> → open file in code side panel"),
Line::from(" :create <path> → create file (makes parent directories)"),
Line::from(" :close → close the code side panel"),
Line::from(" :w[!] [path] → write active file (optionally to path)"),
Line::from(" :q[!] → close active file (append ! to discard)"),
Line::from(" :wq[!] [path] → save then close active file"),
// New mode and tool commands added in phases 05
Line::from(" :code → switch to code mode (CLI: owlen --code)"),
Line::from(" :mode <chat|code> → change current mode explicitly"),
Line::from(" :tools install/audit → manage MCP tool presets"),
Line::from(" :agent status → show agent configuration and iteration info"),
Line::from(" :stop-agent → abort a running ReAct agent loop"),
],
)]));
lines.push(Line::from(
" :open <path> → open file in code side panel",
));
lines.push(Line::from(
" :create <path> → create file (makes parent directories)",
));
lines.push(Line::from(
" :close → close the code side panel",
));
lines.push(Line::from(
" :w[!] [path] → write active file (optionally to path)",
));
lines.push(Line::from(
" :q[!] → close active file (append ! to discard)",
));
lines.push(Line::from(
" :wq[!] [path] → save then close active file",
));
lines.push(Line::from(
" :code → switch to code mode (CLI: owlen --code)",
));
lines.push(Line::from(
" :mode <chat|code> → change current mode explicitly",
));
lines.push(Line::from(
" :tools install/audit → manage MCP tool presets",
));
lines.push(Line::from(
" :agent status → show agent configuration and iteration info",
));
lines.push(Line::from(
" :stop-agent → abort a running ReAct agent loop",
));
lines
}
4 => vec![
// Sessions
Line::from(""),