feat(command-palette): add grouped suggestions, history tracking, and model/provider fuzzy matching

- Export `PaletteGroup` and `PaletteSuggestion` to represent suggestion metadata.
- Implement command history with deduplication, capacity limit, and recent‑command suggestions.
- Enhance dynamic suggestion logic to include history, commands, models, and providers with fuzzy ranking.
- Add UI rendering for grouped suggestions, header with command palette label, and footer instructions.
- Update help text with new shortcuts (Ctrl+P, layout save/load) and expose new agent/layout commands.
This commit is contained in:
2025-10-12 23:03:00 +02:00
parent f413a63c5a
commit b80db89391
5 changed files with 693 additions and 155 deletions

View File

@@ -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<Instant>,
pub status: String,
pub error: Option<String>,
models: Vec<ModelInfo>, // 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 <save|load>".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();