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}; use std::collections::{HashSet, VecDeque};
const MAX_RESULTS: usize = 12; const MAX_RESULTS: usize = 12;
@@ -25,6 +25,17 @@ pub struct PaletteSuggestion {
pub label: String, pub label: String,
pub detail: Option<String>, pub detail: Option<String>,
pub group: PaletteGroup, 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)] #[derive(Debug, Clone)]
@@ -165,7 +176,8 @@ impl CommandPalette {
} }
fn dynamic_suggestions(&self, trimmed: &str) -> Vec<PaletteSuggestion> { 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 results: Vec<PaletteSuggestion> = Vec::new();
let mut seen: HashSet<String> = HashSet::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); push_entries(&mut results, &mut seen, history);
if results.len() >= MAX_RESULTS { if results.len() >= MAX_RESULTS {
return 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 ") { if lowered.starts_with("model ") {
let rest = trimmed[5..].trim(); let rest = query.text.get(5..).unwrap_or_default().trim();
push_entries( push_entries(
&mut results, &mut results,
&mut seen, &mut seen,
self.model_suggestions("model", rest), self.model_suggestions("model", rest),
); );
if results.len() < MAX_RESULTS { 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; return results;
} }
if lowered.starts_with("m ") { 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)); push_entries(&mut results, &mut seen, self.model_suggestions("m", rest));
if results.len() < MAX_RESULTS { 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; return results;
} }
@@ -215,20 +233,20 @@ impl CommandPalette {
if lowered == "model" { if lowered == "model" {
push_entries(&mut results, &mut seen, self.model_suggestions("model", "")); push_entries(&mut results, &mut seen, self.model_suggestions("model", ""));
if results.len() < MAX_RESULTS { 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; return results;
} }
if lowered.starts_with("provider ") { if lowered.starts_with("provider ") {
let rest = trimmed[9..].trim(); let rest = query.text.get(9..).unwrap_or_default().trim();
push_entries( push_entries(
&mut results, &mut results,
&mut seen, &mut seen,
self.provider_suggestions("provider", rest), self.provider_suggestions("provider", rest),
); );
if results.len() < MAX_RESULTS { 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; return results;
} }
@@ -240,37 +258,41 @@ impl CommandPalette {
self.provider_suggestions("provider", ""), self.provider_suggestions("provider", ""),
); );
if results.len() < MAX_RESULTS { 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; return results;
} }
// General query combine commands, models, and providers using fuzzy order. // General query combine commands, models, and providers using fuzzy order.
push_entries(&mut results, &mut seen, self.command_entries(trimmed)); push_entries(&mut results, &mut seen, self.command_entries(&query));
if results.len() < MAX_RESULTS { if results.len() < MAX_RESULTS && query.tags.is_empty() {
push_entries( push_entries(
&mut results, &mut results,
&mut seen, &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( push_entries(
&mut results, &mut results,
&mut seen, &mut seen,
self.provider_suggestions("provider", trimmed), self.provider_suggestions("provider", query.text.trim()),
); );
} }
results results
} }
fn history_suggestions(&self, query: &str) -> Vec<PaletteSuggestion> { fn history_suggestions(&self, query: &QueryParts) -> Vec<PaletteSuggestion> {
if self.history.is_empty() { if self.history.is_empty() {
return Vec::new(); 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 return self
.history .history
.iter() .iter()
@@ -281,6 +303,11 @@ impl CommandPalette {
label: value.to_string(), label: value.to_string(),
detail: Some("Recent command".to_string()), detail: Some("Recent command".to_string()),
group: PaletteGroup::History, group: PaletteGroup::History,
category: None,
modes: vec![],
keybinding: None,
tags: vec!["history".to_string()],
preview: None,
}) })
.collect(); .collect();
} }
@@ -291,7 +318,7 @@ impl CommandPalette {
.rev() .rev()
.enumerate() .enumerate()
.filter_map(|(recency, value)| { .filter_map(|(recency, value)| {
commands::match_score(value, query) commands::match_score(value, query.text.as_str())
.map(|(primary, secondary)| (primary, secondary, recency, value)) .map(|(primary, secondary)| (primary, secondary, recency, value))
}) })
.collect(); .collect();
@@ -306,19 +333,45 @@ impl CommandPalette {
label: value.to_string(), label: value.to_string(),
detail: Some("Recent command".to_string()), detail: Some("Recent command".to_string()),
group: PaletteGroup::History, group: PaletteGroup::History,
category: None,
modes: vec![],
keybinding: None,
tags: vec!["history".to_string()],
preview: None,
}) })
.collect() .collect()
} }
fn command_entries(&self, query: &str) -> Vec<PaletteSuggestion> { fn command_entries(&self, query: &QueryParts) -> Vec<PaletteSuggestion> {
let specs: Vec<CommandSpec> = commands::suggestions(query); let term_refs: Vec<&str> = query.terms.iter().map(|s| s.as_str()).collect();
specs let tag_refs: Vec<&str> = query.tags.iter().map(|s| s.as_str()).collect();
.into_iter() let hits = commands::search(&term_refs, &tag_refs);
.map(|spec| PaletteSuggestion {
value: spec.keyword.to_string(), hits.into_iter()
label: spec.keyword.to_string(), .map(|hit| {
detail: Some(spec.description.to_string()), let descriptor = hit.descriptor;
group: PaletteGroup::Command, 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() .collect()
} }
@@ -334,6 +387,11 @@ impl CommandPalette {
label: entry.display_name().to_string(), label: entry.display_name().to_string(),
detail: Some(format!("Model · {}", entry.provider)), detail: Some(format!("Model · {}", entry.provider)),
group: PaletteGroup::Model, group: PaletteGroup::Model,
category: Some("Models".to_string()),
modes: vec!["Command".to_string()],
keybinding: None,
tags: vec!["model".to_string()],
preview: None,
}) })
.collect(); .collect();
} }
@@ -361,6 +419,11 @@ impl CommandPalette {
label: entry.display_name().to_string(), label: entry.display_name().to_string(),
detail: Some(format!("Model · {}", entry.provider)), detail: Some(format!("Model · {}", entry.provider)),
group: PaletteGroup::Model, group: PaletteGroup::Model,
category: Some("Models".to_string()),
modes: vec!["Command".to_string()],
keybinding: None,
tags: vec!["model".to_string()],
preview: None,
}) })
.collect() .collect()
} }
@@ -376,6 +439,11 @@ impl CommandPalette {
label: provider.to_string(), label: provider.to_string(),
detail: Some("Provider".to_string()), detail: Some("Provider".to_string()),
group: PaletteGroup::Provider, group: PaletteGroup::Provider,
category: Some("Providers".to_string()),
modes: vec!["Command".to_string()],
keybinding: None,
tags: vec!["provider".to_string()],
preview: None,
}) })
.collect(); .collect();
} }
@@ -398,6 +466,11 @@ impl CommandPalette {
label: provider.to_string(), label: provider.to_string(),
detail: Some("Provider".to_string()), detail: Some("Provider".to_string()),
group: PaletteGroup::Provider, group: PaletteGroup::Provider,
category: Some("Providers".to_string()),
modes: vec!["Command".to_string()],
keybinding: None,
tags: vec!["provider".to_string()],
preview: None,
}) })
.collect() .collect()
} }
@@ -437,3 +510,33 @@ mod tests {
assert_eq!(history_entries[0].value, "OPEN FOO.RS"); 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)); .style(Style::default().bg(palette.highlight).fg(palette.label));
frame.render_widget(input, layout[0]); frame.render_widget(input, layout[0]);
let selected_index = if suggestions.is_empty() { let (selected_index, selected_preview) = if suggestions.is_empty() {
None (None, None)
} else { } else {
Some( let idx = app
app.selected_suggestion() .selected_suggestion()
.min(suggestions.len().saturating_sub(1)), .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() { 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 items: Vec<ListItem> = Vec::new();
let mut previous_group: Option<PaletteGroup> = None; let mut previous_group: Option<PaletteGroup> = None;
let accent = Style::default().fg(theme.info);
for (idx, suggestion) in suggestions.iter().enumerate() { for (idx, suggestion) in suggestions.iter().enumerate() {
let mut lines: Vec<Line> = Vec::new(); 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); previous_group = Some(suggestion.group);
} }
let label_line = Line::from(vec![ let mut label_spans = vec![
Span::styled( Span::styled(
if Some(idx) == selected_index { if Some(idx) == selected_index {
"" ""
@@ -5436,8 +5451,13 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
suggestion.label.clone(), suggestion.label.clone(),
Style::default().add_modifier(Modifier::BOLD), 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 { if let Some(detail) = &suggestion.detail {
lines.push(Line::from(Span::styled( 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 = let item =
ListItem::new(lines).style(Style::default().bg(palette.active).fg(palette.label)); ListItem::new(lines).style(Style::default().bg(palette.active).fg(palette.label));
items.push(item); items.push(item);
@@ -5460,18 +5514,38 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
.highlight_style(highlight) .highlight_style(highlight)
.style(Style::default().bg(palette.active).fg(palette.label)); .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 instructions = "Enter: run · Tab: autocomplete · /tag filter · 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( let footer = Paragraph::new(Line::from(Span::styled(
detail_text, instructions,
Style::default().fg(palette.label), Style::default().fg(palette.label),
))) )))
.alignment(Alignment::Center) .alignment(Alignment::Center)

View File

@@ -1,6 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use insta::{assert_snapshot, with_settings}; use insta::{assert_snapshot, with_settings};
use owlen_core::{ use owlen_core::{
Config, Mode, Provider, Config, Mode, Provider,
@@ -10,6 +11,7 @@ use owlen_core::{
ui::{NoOpUiController, UiController}, ui::{NoOpUiController, UiController},
}; };
use owlen_tui::ChatApp; use owlen_tui::ChatApp;
use owlen_tui::events::Event;
use owlen_tui::ui::render_chat; use owlen_tui::ui::render_chat;
use ratatui::{Terminal, backend::TestBackend}; use ratatui::{Terminal, backend::TestBackend};
use tempfile::tempdir; use tempfile::tempdir;
@@ -197,3 +199,34 @@ async fn render_chat_tool_call_snapshot() {
assert_snapshot!("chat_tool_call_snapshot", 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] #[test]
fn command_catalog_contains_expected_aliases() { 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(&"model"));
assert!(keywords.contains(&"open")); assert!(keywords.contains(&"open"));
assert!(keywords.contains(&"close")); assert!(keywords.contains(&"close"));