feat(commands): add metadata-driven palette with tag filters

This commit is contained in:
2025-10-25 10:30:47 +02:00
parent cf0a8f21d5
commit e89da02d49
6 changed files with 1111 additions and 386 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
use crate::commands::{self, CommandSpec};
use crate::commands;
use std::collections::{HashSet, VecDeque};
const MAX_RESULTS: usize = 12;
@@ -25,6 +25,17 @@ pub struct PaletteSuggestion {
pub label: String,
pub detail: Option<String>,
pub group: PaletteGroup,
pub category: Option<String>,
pub modes: Vec<String>,
pub keybinding: Option<String>,
pub tags: Vec<String>,
pub preview: Option<PalettePreview>,
}
#[derive(Debug, Clone)]
pub struct PalettePreview {
pub title: String,
pub body: Vec<String>,
}
#[derive(Debug, Clone)]
@@ -165,7 +176,8 @@ impl CommandPalette {
}
fn dynamic_suggestions(&self, trimmed: &str) -> Vec<PaletteSuggestion> {
let lowered = trimmed.to_ascii_lowercase();
let query = QueryParts::from_input(trimmed);
let lowered = query.text.to_ascii_lowercase();
let mut results: Vec<PaletteSuggestion> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
@@ -184,30 +196,36 @@ impl CommandPalette {
}
}
let history = self.history_suggestions(trimmed);
let history = self.history_suggestions(&query);
push_entries(&mut results, &mut seen, history);
if results.len() >= MAX_RESULTS {
return results;
}
if !query.tags.is_empty() && query.terms.is_empty() {
// Only tag filters are active; restrict results to matching commands.
push_entries(&mut results, &mut seen, self.command_entries(&query));
return results;
}
if lowered.starts_with("model ") {
let rest = trimmed[5..].trim();
let rest = query.text.get(5..).unwrap_or_default().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));
push_entries(&mut results, &mut seen, self.command_entries(&query));
}
return results;
}
if lowered.starts_with("m ") {
let rest = trimmed[2..].trim();
let rest = query.text.get(2..).unwrap_or_default().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));
push_entries(&mut results, &mut seen, self.command_entries(&query));
}
return results;
}
@@ -215,20 +233,20 @@ impl CommandPalette {
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));
push_entries(&mut results, &mut seen, self.command_entries(&query));
}
return results;
}
if lowered.starts_with("provider ") {
let rest = trimmed[9..].trim();
let rest = query.text.get(9..).unwrap_or_default().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));
push_entries(&mut results, &mut seen, self.command_entries(&query));
}
return results;
}
@@ -240,37 +258,41 @@ impl CommandPalette {
self.provider_suggestions("provider", ""),
);
if results.len() < MAX_RESULTS {
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
push_entries(&mut results, &mut seen, self.command_entries(&query));
}
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.command_entries(&query));
if results.len() < MAX_RESULTS && query.tags.is_empty() {
push_entries(
&mut results,
&mut seen,
self.model_suggestions("model", trimmed),
self.model_suggestions("model", query.text.trim()),
);
}
if results.len() < MAX_RESULTS {
if results.len() < MAX_RESULTS && query.tags.is_empty() {
push_entries(
&mut results,
&mut seen,
self.provider_suggestions("provider", trimmed),
self.provider_suggestions("provider", query.text.trim()),
);
}
results
}
fn history_suggestions(&self, query: &str) -> Vec<PaletteSuggestion> {
fn history_suggestions(&self, query: &QueryParts) -> Vec<PaletteSuggestion> {
if self.history.is_empty() {
return Vec::new();
}
if query.trim().is_empty() {
if !query.tags.is_empty() && query.terms.is_empty() {
return Vec::new();
}
if query.text.trim().is_empty() {
return self
.history
.iter()
@@ -281,6 +303,11 @@ impl CommandPalette {
label: value.to_string(),
detail: Some("Recent command".to_string()),
group: PaletteGroup::History,
category: None,
modes: vec![],
keybinding: None,
tags: vec!["history".to_string()],
preview: None,
})
.collect();
}
@@ -291,7 +318,7 @@ impl CommandPalette {
.rev()
.enumerate()
.filter_map(|(recency, value)| {
commands::match_score(value, query)
commands::match_score(value, query.text.as_str())
.map(|(primary, secondary)| (primary, secondary, recency, value))
})
.collect();
@@ -306,19 +333,45 @@ impl CommandPalette {
label: value.to_string(),
detail: Some("Recent command".to_string()),
group: PaletteGroup::History,
category: None,
modes: vec![],
keybinding: None,
tags: vec!["history".to_string()],
preview: None,
})
.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,
fn command_entries(&self, query: &QueryParts) -> Vec<PaletteSuggestion> {
let term_refs: Vec<&str> = query.terms.iter().map(|s| s.as_str()).collect();
let tag_refs: Vec<&str> = query.tags.iter().map(|s| s.as_str()).collect();
let hits = commands::search(&term_refs, &tag_refs);
hits.into_iter()
.map(|hit| {
let descriptor = hit.descriptor;
PaletteSuggestion {
value: hit.keyword.to_string(),
label: hit.keyword.to_string(),
detail: Some(descriptor.description.to_string()),
group: PaletteGroup::Command,
category: Some(descriptor.category.label().to_string()),
modes: descriptor
.modes
.iter()
.map(|mode| mode.to_string())
.collect(),
keybinding: descriptor.keybinding.map(|binding| binding.to_string()),
tags: descriptor.tags.iter().map(|tag| tag.to_string()).collect(),
preview: descriptor.preview.map(|preview| PalettePreview {
title: preview.title.to_string(),
body: preview
.body
.iter()
.map(|line| (*line).to_string())
.collect(),
}),
}
})
.collect()
}
@@ -334,6 +387,11 @@ impl CommandPalette {
label: entry.display_name().to_string(),
detail: Some(format!("Model · {}", entry.provider)),
group: PaletteGroup::Model,
category: Some("Models".to_string()),
modes: vec!["Command".to_string()],
keybinding: None,
tags: vec!["model".to_string()],
preview: None,
})
.collect();
}
@@ -361,6 +419,11 @@ impl CommandPalette {
label: entry.display_name().to_string(),
detail: Some(format!("Model · {}", entry.provider)),
group: PaletteGroup::Model,
category: Some("Models".to_string()),
modes: vec!["Command".to_string()],
keybinding: None,
tags: vec!["model".to_string()],
preview: None,
})
.collect()
}
@@ -376,6 +439,11 @@ impl CommandPalette {
label: provider.to_string(),
detail: Some("Provider".to_string()),
group: PaletteGroup::Provider,
category: Some("Providers".to_string()),
modes: vec!["Command".to_string()],
keybinding: None,
tags: vec!["provider".to_string()],
preview: None,
})
.collect();
}
@@ -398,6 +466,11 @@ impl CommandPalette {
label: provider.to_string(),
detail: Some("Provider".to_string()),
group: PaletteGroup::Provider,
category: Some("Providers".to_string()),
modes: vec!["Command".to_string()],
keybinding: None,
tags: vec!["provider".to_string()],
preview: None,
})
.collect()
}
@@ -437,3 +510,33 @@ mod tests {
assert_eq!(history_entries[0].value, "OPEN FOO.RS");
}
}
#[derive(Debug, Default)]
struct QueryParts {
text: String,
terms: Vec<String>,
tags: Vec<String>,
}
impl QueryParts {
fn from_input(input: &str) -> Self {
let mut raw_terms: Vec<&str> = Vec::new();
let mut terms: Vec<String> = Vec::new();
let mut tags: Vec<String> = Vec::new();
for token in input.split_whitespace() {
if let Some(stripped) = token.strip_prefix('/') {
let tag = stripped.trim();
if !tag.is_empty() {
tags.push(tag.to_ascii_lowercase());
}
} else {
raw_terms.push(token);
terms.push(token.to_ascii_lowercase());
}
}
let text = raw_terms.join(" ");
Self { text, terms, tags }
}
}

View File

@@ -5381,13 +5381,27 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
.style(Style::default().bg(palette.highlight).fg(palette.label));
frame.render_widget(input, layout[0]);
let selected_index = if suggestions.is_empty() {
None
let (selected_index, selected_preview) = if suggestions.is_empty() {
(None, None)
} else {
Some(
app.selected_suggestion()
.min(suggestions.len().saturating_sub(1)),
)
let idx = app
.selected_suggestion()
.min(suggestions.len().saturating_sub(1));
let preview = suggestions
.get(idx)
.and_then(|suggestion| suggestion.preview.as_ref());
(Some(idx), preview)
};
let show_preview = selected_preview.is_some() && layout[1].width > 60;
let (list_area, preview_area) = if show_preview {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
.split(layout[1]);
(columns[0], Some(columns[1]))
} else {
(layout[1], None)
};
if suggestions.is_empty() {
@@ -5407,6 +5421,7 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
let mut items: Vec<ListItem> = Vec::new();
let mut previous_group: Option<PaletteGroup> = None;
let accent = Style::default().fg(theme.info);
for (idx, suggestion) in suggestions.iter().enumerate() {
let mut lines: Vec<Line> = Vec::new();
@@ -5420,7 +5435,7 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
previous_group = Some(suggestion.group);
}
let label_line = Line::from(vec![
let mut label_spans = vec![
Span::styled(
if Some(idx) == selected_index {
""
@@ -5436,8 +5451,13 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
suggestion.label.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
]);
lines.push(label_line);
];
if let Some(binding) = &suggestion.keybinding {
label_spans.push(Span::raw(" "));
label_spans.push(Span::styled(binding.clone(), accent));
}
lines.push(Line::from(label_spans));
if let Some(detail) = &suggestion.detail {
lines.push(Line::from(Span::styled(
@@ -5448,6 +5468,40 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
)));
}
let mut meta_spans: Vec<Span> = vec![Span::raw(" ")];
let mut has_meta = false;
if let Some(category) = &suggestion.category {
meta_spans.push(Span::styled(category.clone(), accent));
has_meta = true;
}
if !suggestion.modes.is_empty() {
if has_meta {
meta_spans.push(Span::raw(" · "));
}
meta_spans.push(Span::styled(
format!("Modes: {}", suggestion.modes.join(", ")),
Style::default()
.fg(palette.label)
.add_modifier(Modifier::ITALIC),
));
has_meta = true;
}
if !suggestion.tags.is_empty() {
if has_meta {
meta_spans.push(Span::raw(" · "));
}
meta_spans.push(Span::styled(
format!("#{}", suggestion.tags.join(" #")),
Style::default()
.fg(palette.label)
.add_modifier(Modifier::DIM),
));
has_meta = true;
}
if has_meta {
lines.push(Line::from(meta_spans));
}
let item =
ListItem::new(lines).style(Style::default().bg(palette.active).fg(palette.label));
items.push(item);
@@ -5460,18 +5514,38 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
.highlight_style(highlight)
.style(Style::default().bg(palette.active).fg(palette.label));
frame.render_stateful_widget(list, layout[1], &mut list_state);
frame.render_stateful_widget(list, list_area, &mut list_state);
if let (Some(area), Some(preview)) = (preview_area, selected_preview) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.focused_panel_border))
.title(Span::styled(
format!(" {} ", preview.title),
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(palette.active).fg(palette.label));
frame.render_widget(block.clone(), area);
let inner = block.inner(area);
if inner.width > 0 && inner.height > 0 {
let lines: Vec<Line> = preview
.body
.iter()
.map(|line| Line::from(Span::raw(line.clone())))
.collect();
let preview_paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: true })
.style(Style::default().bg(palette.active).fg(palette.label));
frame.render_widget(preview_paragraph, inner);
}
}
}
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 instructions = "Enter: run · Tab: autocomplete · /tag filter · Esc: cancel";
let footer = Paragraph::new(Line::from(Span::styled(
detail_text,
instructions,
Style::default().fg(palette.label),
)))
.alignment(Alignment::Center)

View File

@@ -1,6 +1,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use insta::{assert_snapshot, with_settings};
use owlen_core::{
Config, Mode, Provider,
@@ -10,6 +11,7 @@ use owlen_core::{
ui::{NoOpUiController, UiController},
};
use owlen_tui::ChatApp;
use owlen_tui::events::Event;
use owlen_tui::ui::render_chat;
use ratatui::{Terminal, backend::TestBackend};
use tempfile::tempdir;
@@ -197,3 +199,34 @@ async fn render_chat_tool_call_snapshot() {
assert_snapshot!("chat_tool_call_snapshot", snapshot);
});
}
#[tokio::test(flavor = "multi_thread")]
async fn render_command_palette_focus_snapshot() {
let mut app = build_chat_app(|_| {}).await;
app.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(':'),
KeyModifiers::NONE,
)))
.await
.expect("enter command mode");
for ch in ['f', 'o', 'c', 'u', 's'] {
app.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::NONE,
)))
.await
.expect("type query");
}
// Highlight the second suggestion (typically the model picker preview).
app.handle_event(Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)))
.await
.expect("move selection");
with_settings!({ snapshot_suffix => "80x20" }, {
let snapshot = render_snapshot(&mut app, 80, 20);
assert_snapshot!("command_palette_focus", snapshot);
});
}

View File

@@ -0,0 +1,25 @@
---
source: crates/owlen-tui/tests/chat_snapshots.rs
assertion_line: 230
expression: snapshot
---
" Command Palette Ctrl+P "
" "
" :focus "
" "
" "
" Commands "
" code "
" Switch to code mode "
" System · Modes: Command · #mode #code #focus "
" chat "
" Switch to chat mode "
" System · Modes: Command · #mode #chat #focus "
" files <leader> f 1 · Ctrl+1 / Alt+1 "
" Toggle the files panel "
" Navigation · Modes: Normal, Command · #focus #panel #navigation "
" "
" "
" Enter: run · Tab: autocomplete · /tag filter · Esc: cancel "
" "
" "

View File

@@ -47,7 +47,10 @@ fn palette_apply_selected_updates_buffer() {
#[test]
fn command_catalog_contains_expected_aliases() {
let keywords: Vec<_> = commands::all().iter().map(|spec| spec.keyword).collect();
let mut keywords: Vec<&str> = Vec::new();
for descriptor in commands::catalog() {
keywords.extend_from_slice(descriptor.keywords());
}
assert!(keywords.contains(&"model"));
assert!(keywords.contains(&"open"));
assert!(keywords.contains(&"close"));