diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 0ecb2f4..2c25ae2 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -28,8 +28,8 @@ use crate::events::Event; use crate::model_info_panel::ModelInfoPanel; use crate::state::{ CodeWorkspace, CommandPalette, FileFilterMode, FileNode, FileTreeState, ModelPaletteEntry, - PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, - SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, spawn_repo_search_task, + PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, + SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, spawn_repo_search_task, spawn_symbol_search_task, }; use crate::ui::format_tool_output; @@ -164,6 +164,7 @@ pub const HELP_TAB_COUNT: usize = 7; pub struct ChatApp { controller: SessionController, pub mode: InputMode, + mode_flash_until: Option, pub status: String, pub error: Option, models: Vec, // All models fetched @@ -383,6 +384,7 @@ impl ChatApp { let mut app = Self { controller, mode: InputMode::Normal, + mode_flash_until: None, status: if show_onboarding { ONBOARDING_STATUS_LINE.to_string() } else { @@ -743,7 +745,7 @@ impl ChatApp { self.file_tree_mut().reveal(&absolute); self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.status = format!("Opened {}:{}:{column}", display, line_number); self.error = None; } @@ -796,7 +798,7 @@ impl ChatApp { } self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.status = format!("Opened scratch buffer for {title}"); Ok(()) } @@ -917,7 +919,7 @@ impl ChatApp { self.file_tree_mut().reveal(&entry.file); self.focused_panel = FocusedPanel::Code; self.ensure_focus_valid(); - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.status = format!( "Jumped to {} {}:{}", entry.kind.label(), @@ -1269,10 +1271,23 @@ impl ChatApp { self.command_palette.buffer() } - pub fn command_suggestions(&self) -> &[String] { + pub fn command_suggestions(&self) -> &[PaletteSuggestion] { self.command_palette.suggestions() } + fn set_input_mode(&mut self, mode: InputMode) { + if self.mode != mode { + self.mode_flash_until = Some(Instant::now() + Duration::from_millis(240)); + } + self.mode = mode; + } + + pub fn mode_flash_active(&self) -> bool { + self.mode_flash_until + .map(|deadline| Instant::now() < deadline) + .unwrap_or(false) + } + pub fn selected_suggestion(&self) -> usize { self.command_palette.selected_index() } @@ -3189,13 +3204,13 @@ impl ChatApp { } if is_question_mark && matches!(self.mode, InputMode::Normal) { - self.mode = InputMode::Help; + self.set_input_mode(InputMode::Help); self.status = "Help".to_string(); return Ok(AppState::Running); } if is_repo_search && matches!(self.mode, InputMode::Normal) { - self.mode = InputMode::RepoSearch; + self.set_input_mode(InputMode::RepoSearch); if self.repo_search.query_input().is_empty() { *self.repo_search.status_mut() = Some("Type a pattern · Enter runs ripgrep".to_string()); @@ -3205,7 +3220,7 @@ impl ChatApp { } if is_symbol_search_key && matches!(self.mode, InputMode::Normal) { - self.mode = InputMode::SymbolSearch; + self.set_input_mode(InputMode::SymbolSearch); self.symbol_search.clear_query(); self.status = "Symbol search active".to_string(); self.start_symbol_search().await?; @@ -3358,7 +3373,7 @@ impl ChatApp { .to_string(); return Ok(AppState::Running); } - self.mode = InputMode::Visual; + self.set_input_mode(InputMode::Visual); match self.focused_panel { FocusedPanel::Input => { @@ -3391,45 +3406,54 @@ impl ChatApp { "-- VISUAL -- (move with j/k, yank with y)".to_string(); } (KeyCode::Char(':'), KeyModifiers::NONE) => { - self.mode = InputMode::Command; + self.set_input_mode(InputMode::Command); self.command_palette.clear(); self.command_palette.ensure_suggestions(); self.status = ":".to_string(); } + (KeyCode::Char('p'), modifiers) + if modifiers.contains(KeyModifiers::CONTROL) => + { + self.set_input_mode(InputMode::Command); + self.command_palette.clear(); + self.command_palette.ensure_suggestions(); + self.status = ":".to_string(); + return Ok(AppState::Running); + } // Enter editing mode (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Char('i'), KeyModifiers::NONE) => { - self.mode = InputMode::Editing; + self.set_input_mode(InputMode::Editing); self.sync_buffer_to_textarea(); } (KeyCode::Char('a'), KeyModifiers::NONE) => { // Append - move right and enter insert mode - self.mode = InputMode::Editing; + self.set_input_mode(InputMode::Editing); self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::Forward); } (KeyCode::Char('A'), KeyModifiers::SHIFT) => { // Append at end of line - self.mode = InputMode::Editing; + self.set_input_mode(InputMode::Editing); self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::End); } (KeyCode::Char('I'), KeyModifiers::SHIFT) => { // Insert at start of line - self.mode = InputMode::Editing; + self.set_input_mode(InputMode::Editing); self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::Head); } (KeyCode::Char('o'), KeyModifiers::NONE) => { // Insert newline below and enter edit mode - self.mode = InputMode::Editing; + self.set_input_mode(InputMode::Editing); self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::End); self.textarea.insert_newline(); } (KeyCode::Char('O'), KeyModifiers::NONE) => { // Insert newline above and enter edit mode - self.mode = InputMode::Editing; + self.set_input_mode(InputMode::Editing); self.sync_buffer_to_textarea(); self.textarea.move_cursor(tui_textarea::CursorMove::Head); self.textarea.insert_newline(); @@ -3773,7 +3797,7 @@ impl ChatApp { } (KeyCode::Esc, KeyModifiers::NONE) => { self.pending_key = None; - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } _ => { self.pending_key = None; @@ -3782,7 +3806,7 @@ impl ChatApp { } InputMode::RepoSearch => match (key.code, key.modifiers) { (KeyCode::Esc, _) => { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.status = "Normal mode".to_string(); } (KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::ALT) => { @@ -3828,9 +3852,7 @@ impl ChatApp { Some("Press Enter to search".to_string()); self.status = format!("Query: {}", self.repo_search.query_input()); } - (KeyCode::Up, _) - | (KeyCode::Char('k'), KeyModifiers::NONE) - | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { self.repo_search.move_selection(-1); } (KeyCode::Down, _) @@ -3855,7 +3877,7 @@ impl ChatApp { }, InputMode::SymbolSearch => match (key.code, key.modifiers) { (KeyCode::Esc, _) => { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.status = "Normal mode".to_string(); } (KeyCode::Enter, _) => { @@ -3890,9 +3912,7 @@ impl ChatApp { self.symbol_search.push_query_char(c); self.status = format!("Symbol filter: {}", self.symbol_search.query()); } - (KeyCode::Up, _) - | (KeyCode::Char('k'), KeyModifiers::NONE) - | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { self.symbol_search.move_selection(-1); } (KeyCode::Down, _) @@ -3909,25 +3929,34 @@ impl ChatApp { _ => {} }, InputMode::Editing => 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.mode = InputMode::Normal; + 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.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.reset_status(); } (KeyCode::Char('['), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { self.sync_textarea_to_buffer(); - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.reset_status(); } (KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => { @@ -3940,7 +3969,7 @@ impl ChatApp { // Clear the textarea by setting it to empty self.textarea = TextArea::default(); configure_textarea_defaults(&mut self.textarea); - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } (KeyCode::Enter, _) => { // Any Enter with modifiers keeps editing and inserts a newline via tui-textarea @@ -3986,7 +4015,7 @@ impl ChatApp { if matches!(self.focused_panel, FocusedPanel::Input) { self.textarea.cancel_selection(); } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.visual_start = None; self.visual_end = None; self.reset_status(); @@ -4028,7 +4057,7 @@ impl ChatApp { FocusedPanel::Files => {} FocusedPanel::Code => {} } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.visual_start = None; self.visual_end = None; } @@ -4062,7 +4091,7 @@ impl ChatApp { FocusedPanel::Files => {} FocusedPanel::Code => {} } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.visual_start = None; self.visual_end = None; } @@ -4221,7 +4250,7 @@ impl ChatApp { }, InputMode::Command => match (key.code, key.modifiers) { (KeyCode::Esc, _) => { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.command_palette.clear(); self.reset_status(); } @@ -4244,6 +4273,10 @@ impl ChatApp { let command = parts.first().copied().unwrap_or(""); let args = &parts[1..]; + if !cmd_owned.is_empty() { + self.command_palette.remember(&cmd_owned); + } + match command { "q" | "quit" => { return Ok(AppState::Quit); @@ -4302,7 +4335,7 @@ impl ChatApp { Ok(sessions) => { self.saved_sessions = sessions; self.selected_session_index = 0; - self.mode = InputMode::SessionBrowser; + self.set_input_mode(InputMode::SessionBrowser); self.command_palette.clear(); return Ok(AppState::Running); } @@ -4367,7 +4400,7 @@ impl ChatApp { Ok(sessions) => { self.saved_sessions = sessions; self.selected_session_index = 0; - self.mode = InputMode::SessionBrowser; + self.set_input_mode(InputMode::SessionBrowser); self.command_palette.clear(); return Ok(AppState::Running); } @@ -4434,7 +4467,7 @@ impl ChatApp { } } "h" | "help" => { - self.mode = InputMode::Help; + self.set_input_mode(InputMode::Help); self.command_palette.clear(); return Ok(AppState::Running); } @@ -4496,7 +4529,7 @@ impl ChatApp { Ok(_) => self.error = None, Err(err) => self.error = Some(err.to_string()), } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } @@ -4509,7 +4542,7 @@ impl ChatApp { self.error = Some(err.to_string()); } } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } @@ -4581,7 +4614,7 @@ impl ChatApp { format!("No provider matching '{}'", filter.trim()); } } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } @@ -4603,25 +4636,79 @@ impl ChatApp { Err(err) => self.error = Some(err.to_string()), } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.command_palette.clear(); return Ok(AppState::Running); } // "run-agent" command removed to break circular dependency on owlen-cli. "agent" => { - if self.agent_running { + if let Some(subcommand) = args.first() { + match subcommand.to_lowercase().as_str() { + "status" => { + let armed = + if self.agent_mode { "armed" } else { "idle" }; + let running = if self.agent_running { + "running" + } else { + "stopped" + }; + self.status = + format!("Agent status: {armed} · {running}"); + self.error = None; + } + "start" | "arm" => { + if self.agent_running { + self.status = + "Agent is already running".to_string(); + } else { + self.agent_mode = true; + self.status = "Agent armed. Next message will be processed by the agent.".to_string(); + self.error = None; + } + } + "stop" => { + if self.agent_running { + self.agent_running = false; + self.agent_mode = false; + self.agent_actions = None; + self.status = + "Agent execution stopped".to_string(); + self.error = None; + } else if self.agent_mode { + self.agent_mode = false; + self.agent_actions = None; + self.status = "Agent disarmed".to_string(); + self.error = None; + } else { + self.status = + "No agent is currently running".to_string(); + } + } + other => { + self.error = + Some(format!("Unknown agent command: {other}")); + } + } + } else if self.agent_running { self.status = "Agent is already running".to_string(); } else { self.agent_mode = true; self.status = "Agent mode enabled. Next message will be processed by agent.".to_string(); + self.error = None; } } "stop-agent" => { if self.agent_running { self.agent_running = false; self.agent_mode = false; - self.status = "Agent execution stopped".to_string(); self.agent_actions = None; + self.status = "Agent execution stopped".to_string(); + self.error = None; + } else if self.agent_mode { + self.agent_mode = false; + self.agent_actions = None; + self.status = "Agent disarmed".to_string(); + self.error = None; } else { self.status = "No agent is currently running".to_string(); } @@ -4708,10 +4795,47 @@ impl ChatApp { .position(|name| name == current_theme) .unwrap_or(0); - self.mode = InputMode::ThemeBrowser; + self.set_input_mode(InputMode::ThemeBrowser); self.command_palette.clear(); return Ok(AppState::Running); } + "layout" => { + if let Some(subcommand) = args.first() { + match subcommand.to_lowercase().as_str() { + "save" => { + if self.code_workspace.tabs().is_empty() { + self.status = + "No open panes to save".to_string(); + } else { + self.persist_workspace_layout(); + self.status = + "Workspace layout saved".to_string(); + self.error = None; + } + } + "load" => match self.restore_workspace_layout().await { + Ok(()) => { + self.status = + "Workspace layout restored".to_string(); + self.error = None; + } + Err(err) => { + self.error = Some(err.to_string()); + self.status = + "Failed to restore workspace layout" + .to_string(); + } + }, + other => { + self.error = Some(format!( + "Unknown layout command: {other}" + )); + } + } + } else { + self.status = "Usage: :layout ".to_string(); + } + } "reload" => { // Reload config match owlen_core::config::Config::load(None) { @@ -4809,7 +4933,7 @@ impl ChatApp { } } self.command_palette.clear(); - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } (KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => { @@ -4824,7 +4948,7 @@ impl ChatApp { }, InputMode::ProviderSelection => match key.code { KeyCode::Esc => { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } KeyCode::Enter => { if let Some(provider) = @@ -4833,7 +4957,7 @@ impl ChatApp { self.selected_provider = provider.clone(); // Update model selection based on new provider (await async) self.sync_selected_model_index().await; // Update model selection based on new provider - self.mode = InputMode::ModelSelection; + self.set_input_mode(InputMode::ModelSelection); } } KeyCode::Up => { @@ -4854,7 +4978,7 @@ impl ChatApp { self.set_model_info_visible(false); self.status = "Closed model info panel".to_string(); } else { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } } KeyCode::Enter => { @@ -4901,7 +5025,7 @@ impl ChatApp { self.set_model_info_visible(false); self.status = "Closed model info panel".to_string(); } else { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } } KeyCode::Char('i') => { @@ -5017,7 +5141,7 @@ impl ChatApp { }, InputMode::Help => match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.help_tab_index = 0; // Reset to first tab } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { @@ -5044,7 +5168,7 @@ impl ChatApp { }, InputMode::SessionBrowser => match key.code { KeyCode::Esc => { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } KeyCode::Enter => { // Load selected session @@ -5067,7 +5191,7 @@ impl ChatApp { } } } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } KeyCode::Up | KeyCode::Char('k') => { if self.selected_session_index > 0 { @@ -5106,7 +5230,7 @@ impl ChatApp { }, InputMode::ThemeBrowser => match key.code { KeyCode::Esc | KeyCode::Char('q') => { - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } KeyCode::Enter => { // Apply selected theme @@ -5124,7 +5248,7 @@ impl ChatApp { } } } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); } KeyCode::Up | KeyCode::Char('k') => { if self.selected_theme_index > 0 { @@ -5965,7 +6089,7 @@ impl ChatApp { self.error = Some(format!("Failed to save config: {}", err)); } } - self.mode = InputMode::Normal; + self.set_input_mode(InputMode::Normal); self.set_model_info_visible(false); Ok(()) } @@ -5978,10 +6102,10 @@ impl ChatApp { } if self.available_providers.len() <= 1 { - self.mode = InputMode::ModelSelection; + self.set_input_mode(InputMode::ModelSelection); self.ensure_valid_model_selection(); } else { - self.mode = InputMode::ProviderSelection; + self.set_input_mode(InputMode::ProviderSelection); } self.status = "Select a model to use".to_string(); Ok(()) @@ -6257,7 +6381,7 @@ impl ChatApp { ); self.status = "Model unavailable".to_string(); let _ = self.refresh_models().await; - self.mode = InputMode::ProviderSelection; + self.set_input_mode(InputMode::ProviderSelection); } else { self.error = Some(message); self.status = "Request failed".to_string(); diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index 01c91e4..e7e318b 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -160,6 +160,26 @@ const COMMANDS: &[CommandSpec] = &[ keyword: "stop-agent", description: "Stop the running agent", }, + CommandSpec { + keyword: "agent status", + description: "Show current agent status", + }, + CommandSpec { + keyword: "agent start", + description: "Arm the agent for the next request", + }, + CommandSpec { + keyword: "agent stop", + description: "Stop the running agent", + }, + CommandSpec { + keyword: "layout save", + description: "Persist the current pane layout", + }, + CommandSpec { + keyword: "layout load", + description: "Restore the last saved pane layout", + }, ]; /// Return the static catalog of commands. @@ -168,29 +188,35 @@ pub fn all() -> &'static [CommandSpec] { } /// Return the default suggestion list (all command keywords). -pub fn default_suggestions() -> Vec { - COMMANDS - .iter() - .map(|spec| spec.keyword.to_string()) - .collect() +pub fn default_suggestions() -> Vec { + COMMANDS.to_vec() } /// Generate keyword suggestions for the given input. -pub fn suggestions(input: &str) -> Vec { +pub fn suggestions(input: &str) -> Vec { let trimmed = input.trim(); if trimmed.is_empty() { return default_suggestions(); } - COMMANDS + + let mut matches: Vec<(usize, usize, CommandSpec)> = COMMANDS .iter() .filter_map(|spec| { - if spec.keyword.starts_with(trimmed) { - Some(spec.keyword.to_string()) - } else { - None - } + match_score(spec.keyword, trimmed).map(|score| (score.0, score.1, *spec)) }) - .collect() + .collect(); + + if matches.is_empty() { + return default_suggestions(); + } + + matches.sort_by(|a, b| { + a.0.cmp(&b.0) + .then(a.1.cmp(&b.1)) + .then(a.2.keyword.cmp(b.2.keyword)) + }); + + matches.into_iter().map(|(_, _, spec)| spec).collect() } pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> { @@ -219,6 +245,19 @@ pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suggestions_prioritize_agent_start() { + let results = suggestions("agent st"); + assert!(!results.is_empty()); + assert_eq!(results[0].keyword, "agent start"); + assert!(results.iter().any(|spec| spec.keyword == "agent stop")); + } +} + fn is_subsequence(text: &str, pattern: &str) -> bool { if pattern.is_empty() { return true; diff --git a/crates/owlen-tui/src/state/command_palette.rs b/crates/owlen-tui/src/state/command_palette.rs index f55379b..41cce24 100644 --- a/crates/owlen-tui/src/state/command_palette.rs +++ b/crates/owlen-tui/src/state/command_palette.rs @@ -1,10 +1,32 @@ -use crate::commands; +use crate::commands::{self, CommandSpec}; +use std::collections::{HashSet, VecDeque}; + +const MAX_RESULTS: usize = 12; +const MAX_HISTORY_RESULTS: usize = 4; +const HISTORY_CAPACITY: usize = 20; /// Encapsulates the command-line style palette used in command mode. /// /// The palette keeps track of the raw buffer, matching suggestions, and the /// currently highlighted suggestion index. It contains no terminal-specific /// logic which makes it straightforward to unit test. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaletteGroup { + History, + Command, + Model, + Provider, +} + +#[derive(Debug, Clone)] +pub struct PaletteSuggestion { + pub value: String, + pub label: String, + pub detail: Option, + pub group: PaletteGroup, +} + #[derive(Debug, Clone)] pub struct ModelPaletteEntry { pub id: String, @@ -25,10 +47,11 @@ impl ModelPaletteEntry { #[derive(Debug, Clone, Default)] pub struct CommandPalette { buffer: String, - suggestions: Vec, + suggestions: Vec, selected: usize, models: Vec, providers: Vec, + history: VecDeque, } impl CommandPalette { @@ -40,7 +63,7 @@ impl CommandPalette { &self.buffer } - pub fn suggestions(&self) -> &[String] { + pub fn suggestions(&self) -> &[PaletteSuggestion] { &self.suggestions } @@ -54,6 +77,28 @@ impl CommandPalette { self.selected = 0; } + pub fn remember(&mut self, value: impl AsRef) { + let trimmed = value.as_ref().trim(); + if trimmed.is_empty() { + return; + } + + // Avoid duplicate consecutive entries by removing any existing matching value. + if let Some(pos) = self + .history + .iter() + .position(|entry| entry.eq_ignore_ascii_case(trimmed)) + { + self.history.remove(pos); + } + + self.history.push_back(trimmed.to_string()); + + while self.history.len() > HISTORY_CAPACITY { + self.history.pop_front(); + } + } + pub fn set_buffer(&mut self, value: impl Into) { self.buffer = value.into(); self.refresh_suggestions(); @@ -98,11 +143,11 @@ impl CommandPalette { .get(self.selected) .cloned() .or_else(|| self.suggestions.first().cloned()); - if let Some(value) = selected.clone() { - self.buffer = value; + if let Some(entry) = selected.clone() { + self.buffer = entry.value.clone(); self.refresh_suggestions(); } - selected + selected.map(|entry| entry.value) } pub fn refresh_suggestions(&mut self) { @@ -119,40 +164,177 @@ impl CommandPalette { } } - fn dynamic_suggestions(&self, trimmed: &str) -> Vec { - if let Some(rest) = trimmed.strip_prefix("model ") { - let suggestions = self.model_suggestions("model", rest.trim()); - if suggestions.is_empty() { - commands::suggestions(trimmed) - } else { - suggestions + fn dynamic_suggestions(&self, trimmed: &str) -> Vec { + let lowered = trimmed.to_ascii_lowercase(); + let mut results: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + fn push_entries( + results: &mut Vec, + seen: &mut HashSet, + entries: Vec, + ) { + for entry in entries { + if seen.insert(entry.value.to_ascii_lowercase()) { + results.push(entry); + } + if results.len() >= MAX_RESULTS { + break; + } } - } else if let Some(rest) = trimmed.strip_prefix("m ") { - let suggestions = self.model_suggestions("m", rest.trim()); - if suggestions.is_empty() { - commands::suggestions(trimmed) - } else { - suggestions - } - } else if let Some(rest) = trimmed.strip_prefix("provider ") { - let suggestions = self.provider_suggestions("provider", rest.trim()); - if suggestions.is_empty() { - commands::suggestions(trimmed) - } else { - suggestions - } - } else { - commands::suggestions(trimmed) } + + let history = self.history_suggestions(trimmed); + push_entries(&mut results, &mut seen, history); + if results.len() >= MAX_RESULTS { + return results; + } + + if lowered.starts_with("model ") { + let rest = trimmed[5..].trim(); + push_entries( + &mut results, + &mut seen, + self.model_suggestions("model", rest), + ); + if results.len() < MAX_RESULTS { + push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + } + return results; + } + + if lowered.starts_with("m ") { + let rest = trimmed[2..].trim(); + push_entries(&mut results, &mut seen, self.model_suggestions("m", rest)); + if results.len() < MAX_RESULTS { + push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + } + return results; + } + + if lowered == "model" { + push_entries(&mut results, &mut seen, self.model_suggestions("model", "")); + if results.len() < MAX_RESULTS { + push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + } + return results; + } + + if lowered.starts_with("provider ") { + let rest = trimmed[9..].trim(); + push_entries( + &mut results, + &mut seen, + self.provider_suggestions("provider", rest), + ); + if results.len() < MAX_RESULTS { + push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + } + return results; + } + + if lowered == "provider" { + push_entries( + &mut results, + &mut seen, + self.provider_suggestions("provider", ""), + ); + if results.len() < MAX_RESULTS { + push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + } + return results; + } + + // General query – combine commands, models, and providers using fuzzy order. + push_entries(&mut results, &mut seen, self.command_entries(trimmed)); + if results.len() < MAX_RESULTS { + push_entries( + &mut results, + &mut seen, + self.model_suggestions("model", trimmed), + ); + } + if results.len() < MAX_RESULTS { + push_entries( + &mut results, + &mut seen, + self.provider_suggestions("provider", trimmed), + ); + } + + results } - fn model_suggestions(&self, keyword: &str, query: &str) -> Vec { + fn history_suggestions(&self, query: &str) -> Vec { + if self.history.is_empty() { + return Vec::new(); + } + + if query.trim().is_empty() { + return self + .history + .iter() + .rev() + .take(MAX_HISTORY_RESULTS) + .map(|value| PaletteSuggestion { + value: value.to_string(), + label: value.to_string(), + detail: Some("Recent command".to_string()), + group: PaletteGroup::History, + }) + .collect(); + } + + let mut matches: Vec<(usize, usize, usize, &String)> = self + .history + .iter() + .rev() + .enumerate() + .filter_map(|(recency, value)| { + commands::match_score(value, query) + .map(|(primary, secondary)| (primary, secondary, recency, value)) + }) + .collect(); + + matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); + + matches + .into_iter() + .take(MAX_HISTORY_RESULTS) + .map(|(_, _, _, value)| PaletteSuggestion { + value: value.to_string(), + label: value.to_string(), + detail: Some("Recent command".to_string()), + group: PaletteGroup::History, + }) + .collect() + } + + fn command_entries(&self, query: &str) -> Vec { + let specs: Vec = commands::suggestions(query); + specs + .into_iter() + .map(|spec| PaletteSuggestion { + value: spec.keyword.to_string(), + label: spec.keyword.to_string(), + detail: Some(spec.description.to_string()), + group: PaletteGroup::Command, + }) + .collect() + } + + fn model_suggestions(&self, keyword: &str, query: &str) -> Vec { if query.is_empty() { return self .models .iter() .take(15) - .map(|entry| format!("{keyword} {}", entry.id)) + .map(|entry| PaletteSuggestion { + value: format!("{keyword} {}", entry.id), + label: entry.display_name().to_string(), + detail: Some(format!("Model · {}", entry.provider)), + group: PaletteGroup::Model, + }) .collect(); } @@ -174,17 +356,27 @@ impl CommandPalette { matches .into_iter() .take(15) - .map(|(_, _, entry)| format!("{keyword} {}", entry.id)) + .map(|(_, _, entry)| PaletteSuggestion { + value: format!("{keyword} {}", entry.id), + label: entry.display_name().to_string(), + detail: Some(format!("Model · {}", entry.provider)), + group: PaletteGroup::Model, + }) .collect() } - fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec { + fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec { if query.is_empty() { return self .providers .iter() .take(15) - .map(|provider| format!("{keyword} {}", provider)) + .map(|provider| PaletteSuggestion { + value: format!("{keyword} {}", provider), + label: provider.to_string(), + detail: Some("Provider".to_string()), + group: PaletteGroup::Provider, + }) .collect(); } @@ -201,7 +393,47 @@ impl CommandPalette { matches .into_iter() .take(15) - .map(|(_, _, provider)| format!("{keyword} {}", provider)) + .map(|(_, _, provider)| PaletteSuggestion { + value: format!("{keyword} {}", provider), + label: provider.to_string(), + detail: Some("Provider".to_string()), + group: PaletteGroup::Provider, + }) .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_entries_are_prioritized() { + let mut palette = CommandPalette::new(); + palette.remember("open foo.rs"); + palette.remember("model llama"); + palette.ensure_suggestions(); + + let suggestions = palette.suggestions(); + assert!(!suggestions.is_empty()); + assert_eq!(suggestions[0].value, "model llama"); + assert!(matches!(suggestions[0].group, PaletteGroup::History)); + } + + #[test] + fn history_deduplicates_case_insensitively() { + let mut palette = CommandPalette::new(); + palette.remember("open foo.rs"); + palette.remember("OPEN FOO.RS"); + palette.ensure_suggestions(); + + let history_entries: Vec<_> = palette + .suggestions() + .iter() + .filter(|entry| matches!(entry.group, PaletteGroup::History)) + .collect(); + + assert_eq!(history_entries.len(), 1); + assert_eq!(history_entries[0].value, "OPEN FOO.RS"); + } +} diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index e7f5916..8625926 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -10,7 +10,7 @@ mod file_tree; mod search; mod workspace; -pub use command_palette::{CommandPalette, ModelPaletteEntry}; +pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion}; pub use file_tree::{ FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, }; diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 21f19ac..3e0dc18 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -12,7 +12,8 @@ use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind}; use crate::state::{ - CodePane, EditorTab, FileFilterMode, LayoutNode, PaneId, RepoSearchRowKind, SplitAxis, + CodePane, EditorTab, FileFilterMode, LayoutNode, PaletteGroup, PaneId, RepoSearchRowKind, + SplitAxis, }; use owlen_core::model::DetailedModelInfo; use owlen_core::theme::Theme; @@ -1696,6 +1697,18 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { InputMode::SymbolSearch => ("SYMBOLS", theme.mode_command), }; + let mode_badge_style = if app.mode_flash_active() { + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .bg(mode_color) + .fg(theme.background) + .add_modifier(Modifier::BOLD) + }; + let (op_label, op_fg, op_bg) = match app.get_mode() { owlen_core::mode::Mode::Chat => ("CHAT", theme.operating_chat_fg, theme.operating_chat_bg), owlen_core::mode::Mode::Code => ("CODE", theme.operating_code_fg, theme.operating_code_bg), @@ -1710,13 +1723,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { }; let mut left_spans = vec![ - Span::styled( - format!(" {} ", mode_label), - Style::default() - .bg(mode_color) - .fg(theme.background) - .add_modifier(Modifier::BOLD), - ), + Span::styled(format!(" {} ", mode_label), mode_badge_style), Span::styled( " │ ", Style::default() @@ -2868,6 +2875,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { 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( "GENERAL", @@ -2878,6 +2886,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" :h, :help → show this help"), Line::from(" :q, :quit → 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( "CONVERSATION", @@ -2909,6 +2918,16 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" :load, :o → browse and load saved sessions"), Line::from(" :sessions, :ls → browse saved sessions"), Line::from(""), + 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( "CODE VIEW", Style::default() @@ -3361,57 +3380,181 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); let suggestions = app.command_suggestions(); + let buffer = app.command_buffer(); + let area = frame.area(); - // Only show suggestions if there are any - if suggestions.is_empty() { + if area.width == 0 || area.height == 0 { return; } - // Create a small popup near the status bar (bottom of screen) - let frame_height = frame.area().height; - let suggestion_count = suggestions.len().min(8); // Show max 8 suggestions - let popup_height = (suggestion_count as u16) + 2; // +2 for borders + let visible_count = suggestions.len().clamp(1, 8) as u16; + let mut height = visible_count.saturating_mul(2).saturating_add(6); + height = height.clamp(6, area.height); - // Position the popup above the status bar - let popup_area = Rect { - x: 1, - y: frame_height.saturating_sub(popup_height + 3), // 3 for status bar height - width: 40.min(frame.area().width - 2), - height: popup_height, - }; + let mut width = area.width.saturating_sub(10); + if width < 50 { + width = area.width.saturating_sub(4); + } + if width == 0 { + width = area.width; + } + let width = width.min(area.width); + + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 3; + let popup_area = Rect::new(x, y, width, height); frame.render_widget(Clear, popup_area); - let items: Vec = suggestions - .iter() - .enumerate() - .map(|(idx, cmd)| { - let is_selected = idx == app.selected_suggestion(); - let style = if is_selected { - Style::default() - .fg(theme.selection_fg) - .bg(theme.selection_bg) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.text) - }; + let header = Line::from(vec![ + Span::styled( + " Command Palette ", + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + ), + Span::styled( + "Ctrl+P", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + ), + ]); - ListItem::new(Span::styled(cmd.to_string(), style)) - }) - .collect(); + let block = Block::default() + .title(header) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.info)) + .style(Style::default().bg(theme.background).fg(theme.text)); - let list = List::new(items).block( - Block::default() - .title(Span::styled( - " Commands (Tab to complete) ", - Style::default().fg(theme.info).add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.info)) - .style(Style::default().bg(theme.background).fg(theme.text)), - ); + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); - frame.render_widget(list, popup_area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(2), + ]) + .split(inner); + + let input = Paragraph::new(Line::from(vec![ + Span::styled( + ":", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::BOLD), + ), + Span::raw(buffer), + ])) + .style(Style::default().bg(theme.background).fg(theme.text)); + frame.render_widget(input, layout[0]); + + let selected_index = if suggestions.is_empty() { + None + } else { + Some( + app.selected_suggestion() + .min(suggestions.len().saturating_sub(1)), + ) + }; + + if suggestions.is_empty() { + let placeholder = Paragraph::new(Line::from(Span::styled( + "No matches — keep typing", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::ITALIC), + ))) + .alignment(Alignment::Center) + .style(Style::default().bg(theme.background).fg(theme.placeholder)); + frame.render_widget(placeholder, layout[1]); + } else { + let highlight = Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg); + + let mut items: Vec = Vec::new(); + let mut previous_group: Option = None; + + for (idx, suggestion) in suggestions.iter().enumerate() { + let mut lines: Vec = Vec::new(); + if previous_group != Some(suggestion.group) { + lines.push(Line::from(Span::styled( + palette_group_label(suggestion.group), + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::BOLD), + ))); + previous_group = Some(suggestion.group); + } + + let label_line = Line::from(vec![ + Span::styled( + if Some(idx) == selected_index { + "›" + } else { + " " + }, + Style::default().fg(theme.placeholder), + ), + Span::raw(" "), + Span::styled( + suggestion.label.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]); + lines.push(label_line); + + if let Some(detail) = &suggestion.detail { + lines.push(Line::from(Span::styled( + format!(" {}", detail), + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + ))); + } + + let item = ListItem::new(lines); + items.push(item); + } + + let mut list_state = ListState::default(); + list_state.select(selected_index); + + let list = List::new(items) + .highlight_style(highlight) + .style(Style::default().bg(theme.background).fg(theme.text)); + + frame.render_stateful_widget(list, layout[1], &mut list_state); + } + + let instructions = "Enter: run · Tab: autocomplete · Esc: cancel"; + let detail_text = selected_index + .and_then(|idx| suggestions.get(idx)) + .and_then(|item| item.detail.as_deref()) + .map(|detail| format!("{detail} — {instructions}")) + .unwrap_or_else(|| instructions.to_string()); + + let footer = Paragraph::new(Line::from(Span::styled( + detail_text, + Style::default().fg(theme.placeholder), + ))) + .alignment(Alignment::Center) + .style(Style::default().bg(theme.background).fg(theme.placeholder)); + frame.render_widget(footer, layout[2]); +} + +fn palette_group_label(group: PaletteGroup) -> &'static str { + match group { + PaletteGroup::History => "History", + PaletteGroup::Command => "Commands", + PaletteGroup::Model => "Models", + PaletteGroup::Provider => "Providers", + } } fn render_repo_search(frame: &mut Frame<'_>, app: &mut ChatApp) {