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:
@@ -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();
|
||||
|
||||
@@ -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<String> {
|
||||
COMMANDS
|
||||
.iter()
|
||||
.map(|spec| spec.keyword.to_string())
|
||||
.collect()
|
||||
pub fn default_suggestions() -> Vec<CommandSpec> {
|
||||
COMMANDS.to_vec()
|
||||
}
|
||||
|
||||
/// 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();
|
||||
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;
|
||||
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
suggestions: Vec<PaletteSuggestion>,
|
||||
selected: usize,
|
||||
models: Vec<ModelPaletteEntry>,
|
||||
providers: Vec<String>,
|
||||
history: VecDeque<String>,
|
||||
}
|
||||
|
||||
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<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>) {
|
||||
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<String> {
|
||||
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<PaletteSuggestion> {
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
let mut results: Vec<PaletteSuggestion> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
|
||||
fn push_entries(
|
||||
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);
|
||||
}
|
||||
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<String> {
|
||||
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() {
|
||||
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<String> {
|
||||
fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec<PaletteSuggestion> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<ListItem> = 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<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) {
|
||||
|
||||
Reference in New Issue
Block a user