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:
@@ -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