diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index bc7baef..552b6c6 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -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, + #[serde(default = "UiSettings::default_keymap_leader")] + pub keymap_leader: String, #[serde(default)] pub keymap_path: Option, #[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 @@ -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(), } diff --git a/crates/owlen-tui/keymap.toml b/crates/owlen-tui/keymap.toml index 0c6e9c4..4ba33fd 100644 --- a/crates/owlen-tui/keymap.toml +++ b/crates/owlen-tui/keymap.toml @@ -1,27 +1,47 @@ [[binding]] modes = ["normal"] -sequence = ["m"] command = "model.open_all" +sequences = [ + ["m"], + ["", "m"] +] [[binding]] modes = ["normal"] -sequence = ["Ctrl+Shift+L"] command = "model.open_local" +sequences = [ + ["Ctrl+Shift+L"], + ["", "m", "l"] +] [[binding]] modes = ["normal"] -sequence = ["Ctrl+Shift+C"] command = "model.open_cloud" +sequences = [ + ["Ctrl+Shift+C"], + ["", "m", "c"] +] [[binding]] modes = ["normal"] -sequence = ["Ctrl+Shift+P"] command = "model.open_available" +sequences = [ + ["Ctrl+Shift+P"], + ["", "m", "a"] +] [[binding]] modes = ["normal", "editing"] -sequence = ["Ctrl+P"] command = "palette.open" +sequences = [ + ["Ctrl+P"], + ["", "t"] +] + +[[binding]] +modes = ["normal"] +command = "provider.switch" +sequence = ["", "p"] [[binding]] modes = ["normal"] @@ -35,38 +55,56 @@ command = "focus.prev" [[binding]] modes = ["normal"] -sequence = ["Ctrl+1"] command = "focus.files" +sequences = [ + ["Ctrl+1"], + ["", "f", "1"] +] [[binding]] modes = ["normal"] -sequence = ["Ctrl+2"] command = "focus.chat" +sequences = [ + ["Ctrl+2"], + ["", "f", "2"] +] [[binding]] modes = ["normal"] -sequence = ["Ctrl+3"] command = "focus.code" +sequences = [ + ["Ctrl+3"], + ["", "f", "3"] +] [[binding]] modes = ["normal"] -sequence = ["Ctrl+4"] command = "focus.thinking" +sequences = [ + ["Ctrl+4"], + ["", "f", "4"] +] [[binding]] modes = ["normal"] -sequence = ["Ctrl+5"] command = "focus.input" +sequences = [ + ["Ctrl+5"], + ["", "f", "5"] +] [[binding]] modes = ["editing"] -sequence = ["Enter"] command = "composer.submit" +sequence = ["Enter"] [[binding]] modes = ["normal"] -sequence = ["Ctrl+;"] command = "mode.command" +sequences = [ + ["Ctrl+;"], + ["", ":"] +] [[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"], + ["", "g", "t"] +] + +[[binding]] +modes = ["normal"] +command = "navigate.bottom" +sequences = [ + ["Shift+G"], + ["", "g", "b"] +] [[binding]] modes = ["normal"] -sequence = ["g", "t"] command = "files.focus_expand" +sequences = [ + ["g", "t"], + ["g", "T"], + ["", "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"], + ["", "f", "h"] +] [[binding]] modes = ["normal"] -sequence = ["g", "H"] -command = "files.toggle_hidden" - -[[binding]] -modes = ["normal"] -sequence = ["d", "d"] command = "input.clear" +sequences = [ + ["d", "d"], + ["", "m", "d"] +] [[binding]] modes = ["normal"] -sequence = ["Ctrl+W", "s"] command = "workspace.split_horizontal" +sequences = [ + ["Ctrl+W", "s"], + ["Ctrl+W", "S"], + ["", "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"], + ["", "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"], + ["", "l", "h"] +] timeout_ms = 1200 [[binding]] modes = ["normal"] -sequence = ["Ctrl+K", "Right"] command = "workspace.focus_right" +sequences = [ + ["Ctrl+K", "Right"], + ["", "l", "l"] +] timeout_ms = 1200 [[binding]] modes = ["normal"] -sequence = ["Ctrl+K", "Up"] command = "workspace.focus_up" +sequences = [ + ["Ctrl+K", "Up"], + ["", "l", "k"] +] timeout_ms = 1200 [[binding]] modes = ["normal"] -sequence = ["Ctrl+K", "Down"] command = "workspace.focus_down" +sequences = [ + ["Ctrl+K", "Down"], + ["", "l", "j"] +] timeout_ms = 1200 diff --git a/crates/owlen-tui/keymap_emacs.toml b/crates/owlen-tui/keymap_emacs.toml index 84bef0c..71d2e8f 100644 --- a/crates/owlen-tui/keymap_emacs.toml +++ b/crates/owlen-tui/keymap_emacs.toml @@ -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 diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 4c6a690..d7d303f 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -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, 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 { 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 diff --git a/crates/owlen-tui/src/commands/registry.rs b/crates/owlen-tui/src/commands/registry.rs index f11fed8..35e7e7c 100644 --- a/crates/owlen-tui/src/commands/registry.rs +++ b/crates/owlen-tui/src/commands/registry.rs @@ -11,6 +11,7 @@ use crate::{ pub enum AppCommand { OpenModelPicker(Option), 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, diff --git a/crates/owlen-tui/src/state/keymap.rs b/crates/owlen-tui/src/state/keymap.rs index 1f78c4e..423213e 100644 --- a/crates/owlen-tui/src/state/keymap.rs +++ b/crates/owlen-tui/src/state/keymap.rs @@ -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) -> 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, default_timeout: Duration, bindings: Vec, + 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::>(); 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, overrides: &KeymapOverrides) -> Vec { + sequence + .into_iter() + .map(|token| { + if token.trim().eq_ignore_ascii_case("") { + 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" + ); + } } diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index af26523..6e03820 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -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, diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 88dbd9a..c164868 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -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 → switch to a specific theme"), - Line::from(" :provider [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 → switch to a specific theme", + )); + lines.push(Line::from( + " :provider [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 → open file in code side panel"), - Line::from(" :create → 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 → 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 → open file in code side panel", + )); + lines.push(Line::from( + " :create → 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 → 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(""),