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

@@ -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) {