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::model_info_panel::ModelInfoPanel;
use crate::state::{ use crate::state::{
CodeWorkspace, CommandPalette, FileFilterMode, FileNode, FileTreeState, ModelPaletteEntry, CodeWorkspace, CommandPalette, FileFilterMode, FileNode, FileTreeState, ModelPaletteEntry,
PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState,
SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, spawn_repo_search_task, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, spawn_repo_search_task,
spawn_symbol_search_task, spawn_symbol_search_task,
}; };
use crate::ui::format_tool_output; use crate::ui::format_tool_output;
@@ -164,6 +164,7 @@ pub const HELP_TAB_COUNT: usize = 7;
pub struct ChatApp { pub struct ChatApp {
controller: SessionController, controller: SessionController,
pub mode: InputMode, pub mode: InputMode,
mode_flash_until: Option<Instant>,
pub status: String, pub status: String,
pub error: Option<String>, pub error: Option<String>,
models: Vec<ModelInfo>, // All models fetched models: Vec<ModelInfo>, // All models fetched
@@ -383,6 +384,7 @@ impl ChatApp {
let mut app = Self { let mut app = Self {
controller, controller,
mode: InputMode::Normal, mode: InputMode::Normal,
mode_flash_until: None,
status: if show_onboarding { status: if show_onboarding {
ONBOARDING_STATUS_LINE.to_string() ONBOARDING_STATUS_LINE.to_string()
} else { } else {
@@ -743,7 +745,7 @@ impl ChatApp {
self.file_tree_mut().reveal(&absolute); self.file_tree_mut().reveal(&absolute);
self.focused_panel = FocusedPanel::Code; self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid(); self.ensure_focus_valid();
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.status = format!("Opened {}:{}:{column}", display, line_number); self.status = format!("Opened {}:{}:{column}", display, line_number);
self.error = None; self.error = None;
} }
@@ -796,7 +798,7 @@ impl ChatApp {
} }
self.focused_panel = FocusedPanel::Code; self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid(); self.ensure_focus_valid();
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.status = format!("Opened scratch buffer for {title}"); self.status = format!("Opened scratch buffer for {title}");
Ok(()) Ok(())
} }
@@ -917,7 +919,7 @@ impl ChatApp {
self.file_tree_mut().reveal(&entry.file); self.file_tree_mut().reveal(&entry.file);
self.focused_panel = FocusedPanel::Code; self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid(); self.ensure_focus_valid();
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.status = format!( self.status = format!(
"Jumped to {} {}:{}", "Jumped to {} {}:{}",
entry.kind.label(), entry.kind.label(),
@@ -1269,10 +1271,23 @@ impl ChatApp {
self.command_palette.buffer() self.command_palette.buffer()
} }
pub fn command_suggestions(&self) -> &[String] { pub fn command_suggestions(&self) -> &[PaletteSuggestion] {
self.command_palette.suggestions() 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 { pub fn selected_suggestion(&self) -> usize {
self.command_palette.selected_index() self.command_palette.selected_index()
} }
@@ -3189,13 +3204,13 @@ impl ChatApp {
} }
if is_question_mark && matches!(self.mode, InputMode::Normal) { if is_question_mark && matches!(self.mode, InputMode::Normal) {
self.mode = InputMode::Help; self.set_input_mode(InputMode::Help);
self.status = "Help".to_string(); self.status = "Help".to_string();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
if is_repo_search && matches!(self.mode, InputMode::Normal) { 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() { if self.repo_search.query_input().is_empty() {
*self.repo_search.status_mut() = *self.repo_search.status_mut() =
Some("Type a pattern · Enter runs ripgrep".to_string()); 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) { 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.symbol_search.clear_query();
self.status = "Symbol search active".to_string(); self.status = "Symbol search active".to_string();
self.start_symbol_search().await?; self.start_symbol_search().await?;
@@ -3358,7 +3373,7 @@ impl ChatApp {
.to_string(); .to_string();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
self.mode = InputMode::Visual; self.set_input_mode(InputMode::Visual);
match self.focused_panel { match self.focused_panel {
FocusedPanel::Input => { FocusedPanel::Input => {
@@ -3391,45 +3406,54 @@ impl ChatApp {
"-- VISUAL -- (move with j/k, yank with y)".to_string(); "-- VISUAL -- (move with j/k, yank with y)".to_string();
} }
(KeyCode::Char(':'), KeyModifiers::NONE) => { (KeyCode::Char(':'), KeyModifiers::NONE) => {
self.mode = InputMode::Command; self.set_input_mode(InputMode::Command);
self.command_palette.clear(); self.command_palette.clear();
self.command_palette.ensure_suggestions(); self.command_palette.ensure_suggestions();
self.status = ":".to_string(); 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 // Enter editing mode
(KeyCode::Enter, KeyModifiers::NONE) (KeyCode::Enter, KeyModifiers::NONE)
| (KeyCode::Char('i'), KeyModifiers::NONE) => { | (KeyCode::Char('i'), KeyModifiers::NONE) => {
self.mode = InputMode::Editing; self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea(); self.sync_buffer_to_textarea();
} }
(KeyCode::Char('a'), KeyModifiers::NONE) => { (KeyCode::Char('a'), KeyModifiers::NONE) => {
// Append - move right and enter insert mode // Append - move right and enter insert mode
self.mode = InputMode::Editing; self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea(); self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Forward); self.textarea.move_cursor(tui_textarea::CursorMove::Forward);
} }
(KeyCode::Char('A'), KeyModifiers::SHIFT) => { (KeyCode::Char('A'), KeyModifiers::SHIFT) => {
// Append at end of line // Append at end of line
self.mode = InputMode::Editing; self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea(); self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::End); self.textarea.move_cursor(tui_textarea::CursorMove::End);
} }
(KeyCode::Char('I'), KeyModifiers::SHIFT) => { (KeyCode::Char('I'), KeyModifiers::SHIFT) => {
// Insert at start of line // Insert at start of line
self.mode = InputMode::Editing; self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea(); self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Head); self.textarea.move_cursor(tui_textarea::CursorMove::Head);
} }
(KeyCode::Char('o'), KeyModifiers::NONE) => { (KeyCode::Char('o'), KeyModifiers::NONE) => {
// Insert newline below and enter edit mode // Insert newline below and enter edit mode
self.mode = InputMode::Editing; self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea(); self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::End); self.textarea.move_cursor(tui_textarea::CursorMove::End);
self.textarea.insert_newline(); self.textarea.insert_newline();
} }
(KeyCode::Char('O'), KeyModifiers::NONE) => { (KeyCode::Char('O'), KeyModifiers::NONE) => {
// Insert newline above and enter edit mode // Insert newline above and enter edit mode
self.mode = InputMode::Editing; self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea(); self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Head); self.textarea.move_cursor(tui_textarea::CursorMove::Head);
self.textarea.insert_newline(); self.textarea.insert_newline();
@@ -3773,7 +3797,7 @@ impl ChatApp {
} }
(KeyCode::Esc, KeyModifiers::NONE) => { (KeyCode::Esc, KeyModifiers::NONE) => {
self.pending_key = None; self.pending_key = None;
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
_ => { _ => {
self.pending_key = None; self.pending_key = None;
@@ -3782,7 +3806,7 @@ impl ChatApp {
} }
InputMode::RepoSearch => match (key.code, key.modifiers) { InputMode::RepoSearch => match (key.code, key.modifiers) {
(KeyCode::Esc, _) => { (KeyCode::Esc, _) => {
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.status = "Normal mode".to_string(); self.status = "Normal mode".to_string();
} }
(KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::ALT) => { (KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
@@ -3828,9 +3852,7 @@ impl ChatApp {
Some("Press Enter to search".to_string()); Some("Press Enter to search".to_string());
self.status = format!("Query: {}", self.repo_search.query_input()); self.status = format!("Query: {}", self.repo_search.query_input());
} }
(KeyCode::Up, _) (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
| (KeyCode::Char('k'), KeyModifiers::NONE)
| (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
self.repo_search.move_selection(-1); self.repo_search.move_selection(-1);
} }
(KeyCode::Down, _) (KeyCode::Down, _)
@@ -3855,7 +3877,7 @@ impl ChatApp {
}, },
InputMode::SymbolSearch => match (key.code, key.modifiers) { InputMode::SymbolSearch => match (key.code, key.modifiers) {
(KeyCode::Esc, _) => { (KeyCode::Esc, _) => {
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.status = "Normal mode".to_string(); self.status = "Normal mode".to_string();
} }
(KeyCode::Enter, _) => { (KeyCode::Enter, _) => {
@@ -3890,9 +3912,7 @@ impl ChatApp {
self.symbol_search.push_query_char(c); self.symbol_search.push_query_char(c);
self.status = format!("Symbol filter: {}", self.symbol_search.query()); self.status = format!("Symbol filter: {}", self.symbol_search.query());
} }
(KeyCode::Up, _) (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
| (KeyCode::Char('k'), KeyModifiers::NONE)
| (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
self.symbol_search.move_selection(-1); self.symbol_search.move_selection(-1);
} }
(KeyCode::Down, _) (KeyCode::Down, _)
@@ -3909,25 +3929,34 @@ impl ChatApp {
_ => {} _ => {}
}, },
InputMode::Editing => match (key.code, key.modifiers) { 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) (KeyCode::Char('c'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) => if modifiers.contains(KeyModifiers::CONTROL) =>
{ {
let _ = self.cancel_active_generation()?; let _ = self.cancel_active_generation()?;
self.sync_textarea_to_buffer(); self.sync_textarea_to_buffer();
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.reset_status(); self.reset_status();
} }
(KeyCode::Esc, KeyModifiers::NONE) => { (KeyCode::Esc, KeyModifiers::NONE) => {
// Sync textarea content to input buffer before leaving edit mode // Sync textarea content to input buffer before leaving edit mode
self.sync_textarea_to_buffer(); self.sync_textarea_to_buffer();
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.reset_status(); self.reset_status();
} }
(KeyCode::Char('['), modifiers) (KeyCode::Char('['), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) => if modifiers.contains(KeyModifiers::CONTROL) =>
{ {
self.sync_textarea_to_buffer(); self.sync_textarea_to_buffer();
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.reset_status(); self.reset_status();
} }
(KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => { (KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => {
@@ -3940,7 +3969,7 @@ impl ChatApp {
// Clear the textarea by setting it to empty // Clear the textarea by setting it to empty
self.textarea = TextArea::default(); self.textarea = TextArea::default();
configure_textarea_defaults(&mut self.textarea); configure_textarea_defaults(&mut self.textarea);
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
(KeyCode::Enter, _) => { (KeyCode::Enter, _) => {
// Any Enter with modifiers keeps editing and inserts a newline via tui-textarea // 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) { if matches!(self.focused_panel, FocusedPanel::Input) {
self.textarea.cancel_selection(); self.textarea.cancel_selection();
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.visual_start = None; self.visual_start = None;
self.visual_end = None; self.visual_end = None;
self.reset_status(); self.reset_status();
@@ -4028,7 +4057,7 @@ impl ChatApp {
FocusedPanel::Files => {} FocusedPanel::Files => {}
FocusedPanel::Code => {} FocusedPanel::Code => {}
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.visual_start = None; self.visual_start = None;
self.visual_end = None; self.visual_end = None;
} }
@@ -4062,7 +4091,7 @@ impl ChatApp {
FocusedPanel::Files => {} FocusedPanel::Files => {}
FocusedPanel::Code => {} FocusedPanel::Code => {}
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.visual_start = None; self.visual_start = None;
self.visual_end = None; self.visual_end = None;
} }
@@ -4221,7 +4250,7 @@ impl ChatApp {
}, },
InputMode::Command => match (key.code, key.modifiers) { InputMode::Command => match (key.code, key.modifiers) {
(KeyCode::Esc, _) => { (KeyCode::Esc, _) => {
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.command_palette.clear(); self.command_palette.clear();
self.reset_status(); self.reset_status();
} }
@@ -4244,6 +4273,10 @@ impl ChatApp {
let command = parts.first().copied().unwrap_or(""); let command = parts.first().copied().unwrap_or("");
let args = &parts[1..]; let args = &parts[1..];
if !cmd_owned.is_empty() {
self.command_palette.remember(&cmd_owned);
}
match command { match command {
"q" | "quit" => { "q" | "quit" => {
return Ok(AppState::Quit); return Ok(AppState::Quit);
@@ -4302,7 +4335,7 @@ impl ChatApp {
Ok(sessions) => { Ok(sessions) => {
self.saved_sessions = sessions; self.saved_sessions = sessions;
self.selected_session_index = 0; self.selected_session_index = 0;
self.mode = InputMode::SessionBrowser; self.set_input_mode(InputMode::SessionBrowser);
self.command_palette.clear(); self.command_palette.clear();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
@@ -4367,7 +4400,7 @@ impl ChatApp {
Ok(sessions) => { Ok(sessions) => {
self.saved_sessions = sessions; self.saved_sessions = sessions;
self.selected_session_index = 0; self.selected_session_index = 0;
self.mode = InputMode::SessionBrowser; self.set_input_mode(InputMode::SessionBrowser);
self.command_palette.clear(); self.command_palette.clear();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
@@ -4434,7 +4467,7 @@ impl ChatApp {
} }
} }
"h" | "help" => { "h" | "help" => {
self.mode = InputMode::Help; self.set_input_mode(InputMode::Help);
self.command_palette.clear(); self.command_palette.clear();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
@@ -4496,7 +4529,7 @@ impl ChatApp {
Ok(_) => self.error = None, Ok(_) => self.error = None,
Err(err) => self.error = Some(err.to_string()), Err(err) => self.error = Some(err.to_string()),
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.command_palette.clear(); self.command_palette.clear();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
@@ -4509,7 +4542,7 @@ impl ChatApp {
self.error = Some(err.to_string()); self.error = Some(err.to_string());
} }
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.command_palette.clear(); self.command_palette.clear();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
@@ -4581,7 +4614,7 @@ impl ChatApp {
format!("No provider matching '{}'", filter.trim()); format!("No provider matching '{}'", filter.trim());
} }
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.command_palette.clear(); self.command_palette.clear();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
@@ -4603,25 +4636,79 @@ impl ChatApp {
Err(err) => self.error = Some(err.to_string()), Err(err) => self.error = Some(err.to_string()),
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
self.command_palette.clear(); self.command_palette.clear();
return Ok(AppState::Running); return Ok(AppState::Running);
} }
// "run-agent" command removed to break circular dependency on owlen-cli. // "run-agent" command removed to break circular dependency on owlen-cli.
"agent" => { "agent" => {
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 { 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(); self.status = "Agent is already running".to_string();
} else { } else {
self.agent_mode = true; self.agent_mode = true;
self.status = "Agent mode enabled. Next message will be processed by agent.".to_string(); self.status = "Agent mode enabled. Next message will be processed by agent.".to_string();
self.error = None;
} }
} }
"stop-agent" => { "stop-agent" => {
if self.agent_running { if self.agent_running {
self.agent_running = false; self.agent_running = false;
self.agent_mode = false; self.agent_mode = false;
self.status = "Agent execution stopped".to_string();
self.agent_actions = None; 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 { } else {
self.status = "No agent is currently running".to_string(); self.status = "No agent is currently running".to_string();
} }
@@ -4708,10 +4795,47 @@ impl ChatApp {
.position(|name| name == current_theme) .position(|name| name == current_theme)
.unwrap_or(0); .unwrap_or(0);
self.mode = InputMode::ThemeBrowser; self.set_input_mode(InputMode::ThemeBrowser);
self.command_palette.clear(); self.command_palette.clear();
return Ok(AppState::Running); 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" => {
// Reload config // Reload config
match owlen_core::config::Config::load(None) { match owlen_core::config::Config::load(None) {
@@ -4809,7 +4933,7 @@ impl ChatApp {
} }
} }
self.command_palette.clear(); self.command_palette.clear();
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
(KeyCode::Char(c), KeyModifiers::NONE) (KeyCode::Char(c), KeyModifiers::NONE)
| (KeyCode::Char(c), KeyModifiers::SHIFT) => { | (KeyCode::Char(c), KeyModifiers::SHIFT) => {
@@ -4824,7 +4948,7 @@ impl ChatApp {
}, },
InputMode::ProviderSelection => match key.code { InputMode::ProviderSelection => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
KeyCode::Enter => { KeyCode::Enter => {
if let Some(provider) = if let Some(provider) =
@@ -4833,7 +4957,7 @@ impl ChatApp {
self.selected_provider = provider.clone(); self.selected_provider = provider.clone();
// Update model selection based on new provider (await async) // Update model selection based on new provider (await async)
self.sync_selected_model_index().await; // Update model selection based on new provider 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 => { KeyCode::Up => {
@@ -4854,7 +4978,7 @@ impl ChatApp {
self.set_model_info_visible(false); self.set_model_info_visible(false);
self.status = "Closed model info panel".to_string(); self.status = "Closed model info panel".to_string();
} else { } else {
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
} }
KeyCode::Enter => { KeyCode::Enter => {
@@ -4901,7 +5025,7 @@ impl ChatApp {
self.set_model_info_visible(false); self.set_model_info_visible(false);
self.status = "Closed model info panel".to_string(); self.status = "Closed model info panel".to_string();
} else { } else {
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
} }
KeyCode::Char('i') => { KeyCode::Char('i') => {
@@ -5017,7 +5141,7 @@ impl ChatApp {
}, },
InputMode::Help => match key.code { InputMode::Help => match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { 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 self.help_tab_index = 0; // Reset to first tab
} }
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
@@ -5044,7 +5168,7 @@ impl ChatApp {
}, },
InputMode::SessionBrowser => match key.code { InputMode::SessionBrowser => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
KeyCode::Enter => { KeyCode::Enter => {
// Load selected session // Load selected session
@@ -5067,7 +5191,7 @@ impl ChatApp {
} }
} }
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
if self.selected_session_index > 0 { if self.selected_session_index > 0 {
@@ -5106,7 +5230,7 @@ impl ChatApp {
}, },
InputMode::ThemeBrowser => match key.code { InputMode::ThemeBrowser => match key.code {
KeyCode::Esc | KeyCode::Char('q') => { KeyCode::Esc | KeyCode::Char('q') => {
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
KeyCode::Enter => { KeyCode::Enter => {
// Apply selected theme // Apply selected theme
@@ -5124,7 +5248,7 @@ impl ChatApp {
} }
} }
} }
self.mode = InputMode::Normal; self.set_input_mode(InputMode::Normal);
} }
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
if self.selected_theme_index > 0 { if self.selected_theme_index > 0 {
@@ -5965,7 +6089,7 @@ impl ChatApp {
self.error = Some(format!("Failed to save config: {}", err)); 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); self.set_model_info_visible(false);
Ok(()) Ok(())
} }
@@ -5978,10 +6102,10 @@ impl ChatApp {
} }
if self.available_providers.len() <= 1 { if self.available_providers.len() <= 1 {
self.mode = InputMode::ModelSelection; self.set_input_mode(InputMode::ModelSelection);
self.ensure_valid_model_selection(); self.ensure_valid_model_selection();
} else { } else {
self.mode = InputMode::ProviderSelection; self.set_input_mode(InputMode::ProviderSelection);
} }
self.status = "Select a model to use".to_string(); self.status = "Select a model to use".to_string();
Ok(()) Ok(())
@@ -6257,7 +6381,7 @@ impl ChatApp {
); );
self.status = "Model unavailable".to_string(); self.status = "Model unavailable".to_string();
let _ = self.refresh_models().await; let _ = self.refresh_models().await;
self.mode = InputMode::ProviderSelection; self.set_input_mode(InputMode::ProviderSelection);
} else { } else {
self.error = Some(message); self.error = Some(message);
self.status = "Request failed".to_string(); self.status = "Request failed".to_string();

View File

@@ -160,6 +160,26 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "stop-agent", keyword: "stop-agent",
description: "Stop the running 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. /// Return the static catalog of commands.
@@ -168,29 +188,35 @@ pub fn all() -> &'static [CommandSpec] {
} }
/// Return the default suggestion list (all command keywords). /// Return the default suggestion list (all command keywords).
pub fn default_suggestions() -> Vec<String> { pub fn default_suggestions() -> Vec<CommandSpec> {
COMMANDS COMMANDS.to_vec()
.iter()
.map(|spec| spec.keyword.to_string())
.collect()
} }
/// Generate keyword suggestions for the given input. /// Generate keyword suggestions for the given input.
pub fn suggestions(input: &str) -> Vec<String> { pub fn suggestions(input: &str) -> Vec<CommandSpec> {
let trimmed = input.trim(); let trimmed = input.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return default_suggestions(); return default_suggestions();
} }
COMMANDS
let mut matches: Vec<(usize, usize, CommandSpec)> = COMMANDS
.iter() .iter()
.filter_map(|spec| { .filter_map(|spec| {
if spec.keyword.starts_with(trimmed) { match_score(spec.keyword, trimmed).map(|score| (score.0, score.1, *spec))
Some(spec.keyword.to_string())
} else {
None
}
}) })
.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)> { 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 { fn is_subsequence(text: &str, pattern: &str) -> bool {
if pattern.is_empty() { if pattern.is_empty() {
return true; return true;

View File

@@ -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. /// Encapsulates the command-line style palette used in command mode.
/// ///
/// The palette keeps track of the raw buffer, matching suggestions, and the /// The palette keeps track of the raw buffer, matching suggestions, and the
/// currently highlighted suggestion index. It contains no terminal-specific /// currently highlighted suggestion index. It contains no terminal-specific
/// logic which makes it straightforward to unit test. /// 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<String>,
pub group: PaletteGroup,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ModelPaletteEntry { pub struct ModelPaletteEntry {
pub id: String, pub id: String,
@@ -25,10 +47,11 @@ impl ModelPaletteEntry {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct CommandPalette { pub struct CommandPalette {
buffer: String, buffer: String,
suggestions: Vec<String>, suggestions: Vec<PaletteSuggestion>,
selected: usize, selected: usize,
models: Vec<ModelPaletteEntry>, models: Vec<ModelPaletteEntry>,
providers: Vec<String>, providers: Vec<String>,
history: VecDeque<String>,
} }
impl CommandPalette { impl CommandPalette {
@@ -40,7 +63,7 @@ impl CommandPalette {
&self.buffer &self.buffer
} }
pub fn suggestions(&self) -> &[String] { pub fn suggestions(&self) -> &[PaletteSuggestion] {
&self.suggestions &self.suggestions
} }
@@ -54,6 +77,28 @@ impl CommandPalette {
self.selected = 0; self.selected = 0;
} }
pub fn remember(&mut self, value: impl AsRef<str>) {
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<String>) { pub fn set_buffer(&mut self, value: impl Into<String>) {
self.buffer = value.into(); self.buffer = value.into();
self.refresh_suggestions(); self.refresh_suggestions();
@@ -98,11 +143,11 @@ impl CommandPalette {
.get(self.selected) .get(self.selected)
.cloned() .cloned()
.or_else(|| self.suggestions.first().cloned()); .or_else(|| self.suggestions.first().cloned());
if let Some(value) = selected.clone() { if let Some(entry) = selected.clone() {
self.buffer = value; self.buffer = entry.value.clone();
self.refresh_suggestions(); self.refresh_suggestions();
} }
selected selected.map(|entry| entry.value)
} }
pub fn refresh_suggestions(&mut self) { pub fn refresh_suggestions(&mut self) {
@@ -119,40 +164,177 @@ impl CommandPalette {
} }
} }
fn dynamic_suggestions(&self, trimmed: &str) -> Vec<String> { fn dynamic_suggestions(&self, trimmed: &str) -> Vec<PaletteSuggestion> {
if let Some(rest) = trimmed.strip_prefix("model ") { let lowered = trimmed.to_ascii_lowercase();
let suggestions = self.model_suggestions("model", rest.trim()); let mut results: Vec<PaletteSuggestion> = Vec::new();
if suggestions.is_empty() { let mut seen: HashSet<String> = HashSet::new();
commands::suggestions(trimmed)
} else { fn push_entries(
suggestions results: &mut Vec<PaletteSuggestion>,
seen: &mut HashSet<String>,
entries: Vec<PaletteSuggestion>,
) {
for entry in entries {
if seen.insert(entry.value.to_ascii_lowercase()) {
results.push(entry);
} }
} else if let Some(rest) = trimmed.strip_prefix("m ") { if results.len() >= MAX_RESULTS {
let suggestions = self.model_suggestions("m", rest.trim()); break;
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)
} }
} }
fn model_suggestions(&self, keyword: &str, query: &str) -> Vec<String> { 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 history_suggestions(&self, query: &str) -> Vec<PaletteSuggestion> {
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<PaletteSuggestion> {
let specs: Vec<CommandSpec> = 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<PaletteSuggestion> {
if query.is_empty() { if query.is_empty() {
return self return self
.models .models
.iter() .iter()
.take(15) .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(); .collect();
} }
@@ -174,17 +356,27 @@ impl CommandPalette {
matches matches
.into_iter() .into_iter()
.take(15) .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() .collect()
} }
fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec<String> { fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec<PaletteSuggestion> {
if query.is_empty() { if query.is_empty() {
return self return self
.providers .providers
.iter() .iter()
.take(15) .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(); .collect();
} }
@@ -201,7 +393,47 @@ impl CommandPalette {
matches matches
.into_iter() .into_iter()
.take(15) .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() .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");
}
}

View File

@@ -10,7 +10,7 @@ mod file_tree;
mod search; mod search;
mod workspace; mod workspace;
pub use command_palette::{CommandPalette, ModelPaletteEntry}; pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion};
pub use file_tree::{ pub use file_tree::{
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
}; };

View File

@@ -12,7 +12,8 @@ use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind}; use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind};
use crate::state::{ 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::model::DetailedModelInfo;
use owlen_core::theme::Theme; 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), 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() { 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::Chat => ("CHAT", theme.operating_chat_fg, theme.operating_chat_bg),
owlen_core::mode::Mode::Code => ("CODE", theme.operating_code_fg, theme.operating_code_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![ let mut left_spans = vec![
Span::styled( Span::styled(format!(" {} ", mode_label), mode_badge_style),
format!(" {} ", mode_label),
Style::default()
.bg(mode_color)
.fg(theme.background)
.add_modifier(Modifier::BOLD),
),
Span::styled( Span::styled(
"", "",
Style::default() Style::default()
@@ -2868,6 +2875,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" Tab → autocomplete suggestion"), Line::from(" Tab → autocomplete suggestion"),
Line::from(" ↑/↓ → navigate suggestions"), Line::from(" ↑/↓ → navigate suggestions"),
Line::from(" Backspace → delete character"), Line::from(" Backspace → delete character"),
Line::from(" Ctrl+P → open command palette"),
Line::from(""), Line::from(""),
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
"GENERAL", "GENERAL",
@@ -2878,6 +2886,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" :h, :help → show this help"), Line::from(" :h, :help → show this help"),
Line::from(" :q, :quit → quit application"), Line::from(" :q, :quit → quit application"),
Line::from(" :reload → reload configuration and themes"), Line::from(" :reload → reload configuration and themes"),
Line::from(" :layout save/load → persist or restore pane layout"),
Line::from(""), Line::from(""),
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
"CONVERSATION", "CONVERSATION",
@@ -2909,6 +2918,16 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" :load, :o → browse and load saved sessions"), Line::from(" :load, :o → browse and load saved sessions"),
Line::from(" :sessions, :ls → browse saved sessions"), Line::from(" :sessions, :ls → browse saved sessions"),
Line::from(""), 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( Line::from(vec![Span::styled(
"CODE VIEW", "CODE VIEW",
Style::default() Style::default()
@@ -3361,57 +3380,181 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme(); let theme = app.theme();
let suggestions = app.command_suggestions(); let suggestions = app.command_suggestions();
let buffer = app.command_buffer();
let area = frame.area();
// Only show suggestions if there are any if area.width == 0 || area.height == 0 {
if suggestions.is_empty() {
return; return;
} }
// Create a small popup near the status bar (bottom of screen) let visible_count = suggestions.len().clamp(1, 8) as u16;
let frame_height = frame.area().height; let mut height = visible_count.saturating_mul(2).saturating_add(6);
let suggestion_count = suggestions.len().min(8); // Show max 8 suggestions height = height.clamp(6, area.height);
let popup_height = (suggestion_count as u16) + 2; // +2 for borders
// Position the popup above the status bar let mut width = area.width.saturating_sub(10);
let popup_area = Rect { if width < 50 {
x: 1, width = area.width.saturating_sub(4);
y: frame_height.saturating_sub(popup_height + 3), // 3 for status bar height }
width: 40.min(frame.area().width - 2), if width == 0 {
height: popup_height, 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); frame.render_widget(Clear, popup_area);
let items: Vec<ListItem> = suggestions let header = Line::from(vec![
.iter() Span::styled(
.enumerate() " Command Palette ",
.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)
};
ListItem::new(Span::styled(cmd.to_string(), style))
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
" Commands (Tab to complete) ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD), Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
)) ),
Span::styled(
"Ctrl+P",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
),
]);
let block = Block::default()
.title(header)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(theme.info)) .border_style(Style::default().fg(theme.info))
.style(Style::default().bg(theme.background).fg(theme.text)), .style(Style::default().bg(theme.background).fg(theme.text));
);
frame.render_widget(list, popup_area); let inner = block.inner(popup_area);
frame.render_widget(block, 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<ListItem> = Vec::new();
let mut previous_group: Option<PaletteGroup> = None;
for (idx, suggestion) in suggestions.iter().enumerate() {
let mut lines: Vec<Line> = 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) { fn render_repo_search(frame: &mut Frame<'_>, app: &mut ChatApp) {