feat(keymap): add configurable leader and Emacs enhancements
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(), ®istry)
|
||||
Keymap::load(
|
||||
keymap_path.as_deref(),
|
||||
keymap_profile.as_deref(),
|
||||
®istry,
|
||||
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(), ®istry);
|
||||
let overrides = KeymapOverrides::new(keymap_leader_raw);
|
||||
self.keymap = Keymap::load(
|
||||
keymap_path.as_deref(),
|
||||
keymap_profile.as_deref(),
|
||||
®istry,
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ®istry);
|
||||
let keymap = Keymap::load(None, None, ®istry, 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, ®istry);
|
||||
let keymap = Keymap::load(None, None, ®istry, 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"), ®istry);
|
||||
let keymap = Keymap::load(None, Some("emacs"), ®istry, 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, ®istry, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(" Esc → exit 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 0‑5
|
||||
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(""),
|
||||
|
||||
Reference in New Issue
Block a user