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"; pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
/// Current schema version written to `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. /// Provider config key for forcing Ollama provider mode.
pub const OLLAMA_MODE_KEY: &str = "ollama_mode"; pub const OLLAMA_MODE_KEY: &str = "ollama_mode";
@@ -1812,6 +1812,8 @@ pub struct UiSettings {
pub icon_mode: IconMode, pub icon_mode: IconMode,
#[serde(default = "UiSettings::default_keymap_profile")] #[serde(default = "UiSettings::default_keymap_profile")]
pub keymap_profile: Option<String>, pub keymap_profile: Option<String>,
#[serde(default = "UiSettings::default_keymap_leader")]
pub keymap_leader: String,
#[serde(default)] #[serde(default)]
pub keymap_path: Option<String>, pub keymap_path: Option<String>,
#[serde(default)] #[serde(default)]
@@ -1915,6 +1917,10 @@ impl UiSettings {
None None
} }
fn default_keymap_leader() -> String {
"Space".to_string()
}
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>
@@ -1985,6 +1991,7 @@ impl Default for UiSettings {
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_profile: Self::default_keymap_profile(),
keymap_leader: Self::default_keymap_leader(),
keymap_path: None, keymap_path: None,
accessibility: AccessibilitySettings::default(), accessibility: AccessibilitySettings::default(),
} }

View File

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

View File

@@ -1,79 +1,159 @@
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Alt+M"]
command = "model.open_all" command = "model.open_all"
sequences = [
["Alt+M"],
["Ctrl+X", "m"]
]
timeout_ms = 1200
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Ctrl+Alt+L"]
command = "model.open_local" command = "model.open_local"
sequences = [
["Ctrl+Alt+L"],
["Ctrl+X", "l"]
]
timeout_ms = 1200
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Ctrl+Alt+C"]
command = "model.open_cloud" command = "model.open_cloud"
sequences = [
["Ctrl+Alt+C"],
["Ctrl+X", "c"]
]
timeout_ms = 1200
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Ctrl+Alt+A"]
command = "model.open_available" command = "model.open_available"
sequences = [
["Ctrl+Alt+A"],
["Ctrl+X", "a"]
]
timeout_ms = 1200
[[binding]] [[binding]]
mode = "normal" modes = ["normal", "editing"]
keys = ["Alt+x"] sequence = ["Ctrl+Space"]
command = "mode.command"
[[binding]]
mode = "editing"
keys = ["Alt+x"]
command = "mode.command"
[[binding]]
mode = "normal"
keys = ["Ctrl+Space"]
command = "palette.open" command = "palette.open"
[[binding]] [[binding]]
mode = "normal" 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" command = "focus.next"
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Alt+Shift+O"] sequence = ["Alt+Shift+O"]
command = "focus.prev" command = "focus.prev"
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Alt+1"]
command = "focus.files" command = "focus.files"
sequence = ["Alt+1"]
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Alt+2"]
command = "focus.chat" command = "focus.chat"
sequence = ["Alt+2"]
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Alt+3"]
command = "focus.code" command = "focus.code"
sequence = ["Alt+3"]
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Alt+4"]
command = "focus.thinking" command = "focus.thinking"
sequence = ["Alt+4"]
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Alt+5"]
command = "focus.input" command = "focus.input"
sequence = ["Alt+5"]
[[binding]] [[binding]]
mode = "editing" mode = "editing"
keys = ["Ctrl+Enter"]
command = "composer.submit" command = "composer.submit"
sequences = [
["Ctrl+Enter"],
["Ctrl+X", "Ctrl+S"]
]
timeout_ms = 1200
[[binding]] [[binding]]
mode = "normal" mode = "normal"
keys = ["Ctrl+Alt+D"] command = "mode.command"
sequence = ["Alt+x"]
[[binding]]
modes = ["normal", "editing", "visual", "command", "help"]
sequence = ["F12"]
command = "debug.toggle" 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::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, KeymapEventResult, KeymapProfile, KeymapState, FileNode, FileTreeState, Keymap, KeymapEventResult, KeymapOverrides, KeymapProfile,
ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, KeymapState, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest,
RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState,
install_global_logger, spawn_repo_search_task, spawn_symbol_search_task, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, spawn_symbol_search_task,
}; };
use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::toast::{Toast, ToastLevel, ToastManager};
use crate::ui::{format_token_short, format_tool_output}; use crate::ui::{format_token_short, format_tool_output};
@@ -581,6 +581,7 @@ pub struct ChatApp {
mvu_model: AppModel, mvu_model: AppModel,
keymap: Keymap, keymap: Keymap,
current_keymap_profile: KeymapProfile, current_keymap_profile: KeymapProfile,
keymap_leader: String,
keymap_state: KeymapState, keymap_state: KeymapState,
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
@@ -794,13 +795,21 @@ impl ChatApp {
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(); 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(); let accessibility = config_guard.ui.accessibility.clone();
drop(config_guard); drop(config_guard);
let keymap_overrides = KeymapOverrides::new(keymap_leader_raw);
let keymap = { let keymap = {
let registry = CommandRegistry::default(); 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 current_keymap_profile = keymap.profile();
let keymap_leader = keymap_overrides.leader().to_string();
let base_theme_name = theme_name.clone(); let base_theme_name = theme_name.clone();
let mut theme = owlen_core::theme::get_theme(&base_theme_name).unwrap_or_else(|| { let mut theme = owlen_core::theme::get_theme(&base_theme_name).unwrap_or_else(|| {
eprintln!("Warning: Theme '{}' not found, using default", theme_name); eprintln!("Warning: Theme '{}' not found, using default", theme_name);
@@ -867,6 +876,7 @@ impl ChatApp {
mvu_model: AppModel::default(), mvu_model: AppModel::default(),
keymap, keymap,
current_keymap_profile, current_keymap_profile,
keymap_leader,
keymap_state: KeymapState::default(), keymap_state: KeymapState::default(),
controller_event_rx, controller_event_rx,
pending_llm_request: false, pending_llm_request: false,
@@ -2145,15 +2155,27 @@ impl ChatApp {
self.current_keymap_profile self.current_keymap_profile
} }
pub fn keymap_leader(&self) -> &str {
&self.keymap_leader
}
fn reload_keymap_from_config(&mut self) -> Result<()> { fn reload_keymap_from_config(&mut self) -> Result<()> {
let registry = CommandRegistry::default(); let registry = CommandRegistry::default();
let config = self.controller.config(); let config = self.controller.config();
let keymap_path = config.ui.keymap_path.clone(); let keymap_path = config.ui.keymap_path.clone();
let keymap_profile = config.ui.keymap_profile.clone(); let keymap_profile = config.ui.keymap_profile.clone();
let keymap_leader_raw = config.ui.keymap_leader.clone();
drop(config); 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.current_keymap_profile = self.keymap.profile();
self.keymap_leader = overrides.leader().to_string();
Ok(()) 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> { async fn execute_command(&mut self, command: AppCommand) -> Result<bool> {
match command { match command {
AppCommand::OpenModelPicker(filter) => { AppCommand::OpenModelPicker(filter) => {
@@ -3862,6 +4003,18 @@ impl ChatApp {
self.error = None; self.error = None;
Ok(true) 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 => { AppCommand::CycleFocusForward => {
if !matches!(self.mode, InputMode::Normal) { if !matches!(self.mode, InputMode::Normal) {
return Ok(false); return Ok(false);
@@ -3931,6 +4084,17 @@ impl ChatApp {
return Ok(false); return Ok(false);
} }
self.jump_to_top(); 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) Ok(true)
} }
AppCommand::ExpandFilePanel => { AppCommand::ExpandFilePanel => {
@@ -6441,90 +6605,98 @@ impl ChatApp {
} }
_ => {} _ => {}
}, },
InputMode::Editing => match (key.code, key.modifiers) { InputMode::Editing => {
(KeyCode::Char('p'), modifiers) if self.current_keymap_profile == KeymapProfile::Emacs
if modifiers.contains(KeyModifiers::CONTROL) => && 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); return Ok(AppState::Running);
} }
(KeyCode::Enter, _) => {
// Any Enter with modifiers keeps editing and inserts a newline via tui-textarea match (key.code, key.modifiers) {
self.textarea.input(Input::from(key)); (KeyCode::Char('p'), modifiers)
} if modifiers.contains(KeyModifiers::CONTROL) =>
// History navigation {
(KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => { self.sync_textarea_to_buffer();
self.input_buffer_mut().history_previous(); self.set_input_mode(InputMode::Command);
self.sync_buffer_to_textarea(); self.command_palette.clear();
} self.command_palette.ensure_suggestions();
(KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => { self.status = ":".to_string();
self.input_buffer_mut().history_next(); }
self.sync_buffer_to_textarea(); (KeyCode::Char('c'), modifiers)
} if modifiers.contains(KeyModifiers::CONTROL) =>
// Vim-style navigation with Ctrl {
(KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => { let _ = self.cancel_active_generation()?;
self.textarea.move_cursor(tui_textarea::CursorMove::Head); self.sync_textarea_to_buffer();
} self.set_input_mode(InputMode::Normal);
(KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => { self.reset_status();
self.textarea.move_cursor(tui_textarea::CursorMove::End); }
} (KeyCode::Esc, KeyModifiers::NONE) => {
(KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => { // Sync textarea content to input buffer before leaving edit mode
self.textarea self.sync_textarea_to_buffer();
.move_cursor(tui_textarea::CursorMove::WordForward); self.set_input_mode(InputMode::Normal);
} self.reset_status();
(KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => { }
self.textarea (KeyCode::Char('['), modifiers)
.move_cursor(tui_textarea::CursorMove::WordBack); if modifiers.contains(KeyModifiers::CONTROL) =>
} {
(KeyCode::Tab, m) if m.is_empty() => { self.sync_textarea_to_buffer();
if !self.complete_resource_reference() { 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)); 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) { InputMode::Visual => match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('v'), KeyModifiers::NONE) => { (KeyCode::Esc, _) | (KeyCode::Char('v'), KeyModifiers::NONE) => {
// Cancel selection and return to normal mode // Cancel selection and return to normal mode

View File

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

View File

@@ -15,6 +15,37 @@ 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"); const EMACS_KEYMAP: &str = include_str!("../../keymap_emacs.toml");
const DEFAULT_SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1200); 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)] #[derive(Debug, Clone)]
pub struct Keymap { pub struct Keymap {
@@ -22,6 +53,7 @@ pub struct Keymap {
trees: HashMap<InputMode, KeymapNode>, trees: HashMap<InputMode, KeymapNode>,
default_timeout: Duration, default_timeout: Duration,
bindings: Vec<ResolvedBinding>, bindings: Vec<ResolvedBinding>,
overrides: KeymapOverrides,
} }
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@@ -43,6 +75,7 @@ impl Keymap {
custom_path: Option<&str>, custom_path: Option<&str>,
preferred_profile: Option<&str>, preferred_profile: Option<&str>,
registry: &CommandRegistry, registry: &CommandRegistry,
overrides: KeymapOverrides,
) -> Self { ) -> Self {
let mut loader = KeymapLoader::new(preferred_profile); 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) {
@@ -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() { if sequences.is_empty() {
warn!( warn!(
"No key sequence defined for command '{}' (modes: {:?})", "No key sequence defined for command '{}' (modes: {:?})",
@@ -134,9 +171,14 @@ impl Keymap {
trees, trees,
default_timeout: DEFAULT_SEQUENCE_TIMEOUT, default_timeout: DEFAULT_SEQUENCE_TIMEOUT,
bindings, bindings,
overrides,
} }
} }
pub fn leader(&self) -> &str {
self.overrides.leader()
}
pub fn profile(&self) -> KeymapProfile { pub fn profile(&self) -> KeymapProfile {
self.profile self.profile
} }
@@ -342,7 +384,7 @@ impl KeyPattern {
for token in tokens[..tokens.len().saturating_sub(1)].iter() { for token in tokens[..tokens.len().saturating_sub(1)].iter() {
match token.to_ascii_lowercase().as_str() { match token.to_ascii_lowercase().as_str() {
"ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL), "ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL),
"alt" | "option" => modifiers.insert(KeyModifiers::ALT), "alt" | "option" | "meta" => modifiers.insert(KeyModifiers::ALT),
"shift" => modifiers.insert(KeyModifiers::SHIFT), "shift" => modifiers.insert(KeyModifiers::SHIFT),
other => warn!("Unknown modifier '{other}' in key binding '{spec}'"), other => warn!("Unknown modifier '{other}' in key binding '{spec}'"),
} }
@@ -369,6 +411,7 @@ impl KeyPattern {
} }
let key = match self.code { let key = match self.code {
KeyCodeKind::Char(' ') => "Space".to_string(),
KeyCodeKind::Char(c) => c.to_string(), KeyCodeKind::Char(c) => c.to_string(),
KeyCodeKind::Enter => "Enter".to_string(), KeyCodeKind::Enter => "Enter".to_string(),
KeyCodeKind::Tab => "Tab".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( fn insert_sequence(
root: &mut KeymapNode, root: &mut KeymapNode,
sequence: &[KeyPattern], sequence: &[KeyPattern],
@@ -718,7 +774,7 @@ mod tests {
#[test] #[test]
fn resolve_binding_from_default_keymap() { fn resolve_binding_from_default_keymap() {
let registry = CommandRegistry::new(); 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 mut state = KeymapState::default();
let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
@@ -732,7 +788,7 @@ mod tests {
#[test] #[test]
fn resolves_multi_key_sequence() { fn resolves_multi_key_sequence() {
let registry = CommandRegistry::new(); 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 mut state = KeymapState::default();
let first = keymap.step( let first = keymap.step(
@@ -755,7 +811,7 @@ mod tests {
#[test] #[test]
fn emacs_profile_loads_builtin() { fn emacs_profile_loads_builtin() {
let registry = CommandRegistry::new(); 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); assert_eq!(keymap.profile(), KeymapProfile::Emacs);
let mut state = KeymapState::default(); let mut state = KeymapState::default();
let result = keymap.step( let result = keymap.step(
@@ -768,4 +824,27 @@ mod tests {
KeymapEventResult::Matched(AppCommand::EnterCommandMode) 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::{ pub use file_tree::{
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, 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::{ pub use search::{
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind, RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState, RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,

View File

@@ -3981,6 +3981,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
let reduced = app.is_reduced_chrome(); let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced); let palette = GlassPalette::for_theme_with_mode(theme, reduced);
let profile = app.current_keymap_profile(); let profile = app.current_keymap_profile();
let leader = app.keymap_leader();
let area = centered_rect(75, 70, frame.area()); let area = centered_rect(75, 70, frame.area());
if area.width == 0 || area.height == 0 { if area.width == 0 || area.height == 0 {
return; return;
@@ -4077,6 +4078,24 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
" Alt+O → cycle panels forward (Tab also works)", " Alt+O → cycle panels forward (Tab also works)",
)); ));
lines.push(Line::from(" Alt+Shift+O → cycle panels backward")); 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( lines.push(Line::from(
@@ -4090,6 +4109,33 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
" Ctrl/Alt+4 → focus Thinking / Agent Actions", " Ctrl/Alt+4 → focus Thinking / Agent Actions",
)); ));
lines.push(Line::from(" Ctrl/Alt+5 → focus Input editor")); 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 => { 1 => {
let send_line = match profile { let send_line = match profile {
KeymapProfile::Emacs => { 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)", _ => " 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)", KeymapProfile::Emacs => " Shift+Enter → insert newline (alternate)",
_ => " Ctrl+J → insert newline (multiline compose)", _ => " Ctrl+J → insert newline (multiline compose)",
}; };
let palette_binding = match profile { let palette_binding: String = match profile {
KeymapProfile::Emacs => { 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(""),
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
"ENTERING EDIT MODE", "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+↑ / Ctrl+↓ → cycle input history"),
Line::from(" Ctrl+A / Ctrl+E → jump to start / end of line"), 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+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(" Ctrl+C → cancel streaming response and exit editing"),
Line::from(""), 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 lines
} }
2 => vec![ 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(" • Yanked text is available for paste with 'p' in normal mode"),
Line::from(" • Read-only panels (Chat/Thinking) always keep data intact; yank copies"), Line::from(" • Read-only panels (Chat/Thinking) always keep data intact; yank copies"),
], ],
3 => vec![ 3 => {
// Commands let mut lines = vec![
Line::from(""), Line::from(""),
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
"COMMAND MODE", "COMMAND MODE",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info), Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]), )]),
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(" :keymap [vim|emacs] → switch keymap profile"),
Line::from(" :keymap → display the active keymap"), if matches!(profile, KeymapProfile::Emacs) {
Line::from(""), lines.push(Line::from(
Line::from(vec![Span::styled( " Alt+x → enter command mode (Emacs M-x)",
"KEYBINDINGS", ));
Style::default() }
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), lines.extend([
)]), Line::from(""),
Line::from(" Enter → execute command"), Line::from(" :keymap [vim|emacs] → switch keymap profile"),
Line::from(" Escexit command mode"), Line::from(" :keymap display the active keymap"),
Line::from(" Tab → autocomplete suggestion"), Line::from(""),
Line::from(" ↑/↓ → navigate suggestions"), Line::from(vec![Span::styled(
Line::from(" Backspace → delete character"), "KEYBINDINGS",
Line::from(" Ctrl+P → open command palette"), Style::default()
Line::from(""), .add_modifier(Modifier::BOLD)
Line::from(vec![Span::styled( .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", "GENERAL",
Style::default() Style::default()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]));
Line::from(" :h, :help → show this help"), lines.push(Line::from(" :h, :help → show this help"));
Line::from(" F1 or ? → toggle help overlay"), lines.push(Line::from(" F1 or ? → toggle help overlay"));
Line::from(" F12 → toggle debug log panel"), lines.push(Line::from(" F12 → toggle debug log panel"));
Line::from(" :files, :explorer → toggle files panel"), lines.push(Line::from(" :files, :explorer → toggle files panel"));
Line::from(" :markdown [on|off] → toggle markdown rendering"), lines.push(Line::from(
Line::from(" Ctrl+←/→ → resize files panel"), " :markdown [on|off] → toggle markdown rendering",
Line::from(" Ctrl+↑/↓ → resize chat/thinking split"), ));
Line::from(" :quit → quit application"), lines.push(Line::from(" Ctrl+←/→ → resize files panel"));
Line::from(" Ctrl+C twice → quit application"), lines.push(Line::from(
Line::from(" :reload → reload configuration and themes"), " Ctrl+↑/↓ → resize chat/thinking split",
Line::from(" :layout save/load → persist or restore pane layout"), ));
Line::from(""), lines.push(Line::from(" :quit → quit application"));
Line::from(vec![Span::styled( 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", "CONVERSATION",
Style::default() Style::default()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]));
Line::from(" :n, :new → start new conversation"), lines.push(Line::from(" :n, :new → start new conversation"));
Line::from(" :c, :clear → clear current conversation"), lines.push(Line::from(
Line::from(""), " :c, :clear → clear current conversation",
Line::from(vec![Span::styled( ));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"MODEL & THEME", "MODEL & THEME",
Style::default() Style::default()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]));
Line::from(" :m, :model → open model selector"), lines.push(Line::from(" :m, :model → open model selector"));
Line::from(" :themes → open theme selector"), lines.push(Line::from(" :themes → open theme selector"));
Line::from(" :theme <name> → switch to a specific theme"), lines.push(Line::from(
Line::from(" :provider <name> [auto|local|cloud] → switch provider or set mode"), " :theme <name> → switch to a specific theme",
Line::from(" :models --local | --cloud → focus models by scope"), ));
Line::from(" :cloud setup [--force-cloud-base-url] → configure Ollama Cloud"), lines.push(Line::from(
Line::from(" :web on|off|status → manage web_search availability"), " :provider <name> [auto|local|cloud] → switch provider or set mode",
Line::from(" :limits → show hourly/weekly usage totals"), ));
Line::from(""), lines.push(Line::from(
Line::from(vec![Span::styled( " :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", "SESSION MANAGEMENT",
Style::default() Style::default()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]));
Line::from(" :session save [name] → save current session (optional name)"), lines.push(Line::from(
Line::from(" :load, :o → browse and load saved sessions"), " :session save [name] → save current session (optional name)",
Line::from(" :sessions, :ls → browse saved sessions"), ));
Line::from(""), lines.push(Line::from(
Line::from(vec![Span::styled( " :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", "AGENT",
Style::default() Style::default()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]));
Line::from(" :agent start → arm the agent for the next request"), lines.push(Line::from(
Line::from(" :agent stop stop or disarm the agent"), " :agent start → arm the agent for the next request",
Line::from(" :agent status → show current agent state"), ));
Line::from(""), lines.push(Line::from(
Line::from(vec![Span::styled( " :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", "CODE VIEW",
Style::default() Style::default()
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]));
Line::from(" :open <path> → open file in code side panel"), lines.push(Line::from(
Line::from(" :create <path> → create file (makes parent directories)"), " :open <path> → open file in code side panel",
Line::from(" :close → close the code side panel"), ));
Line::from(" :w[!] [path] → write active file (optionally to path)"), lines.push(Line::from(
Line::from(" :q[!] → close active file (append ! to discard)"), " :create <path> → create file (makes parent directories)",
Line::from(" :wq[!] [path] → save then close active file"), ));
// New mode and tool commands added in phases 05 lines.push(Line::from(
Line::from(" :code → switch to code mode (CLI: owlen --code)"), " :close → close the code side panel",
Line::from(" :mode <chat|code> → change current mode explicitly"), ));
Line::from(" :tools install/audit → manage MCP tool presets"), lines.push(Line::from(
Line::from(" :agent status → show agent configuration and iteration info"), " :w[!] [path] → write active file (optionally to path)",
Line::from(" :stop-agent → abort a running ReAct agent loop"), ));
], 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![ 4 => vec![
// Sessions // Sessions
Line::from(""), Line::from(""),