feat(commands): add metadata-driven palette with tag filters
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
" "
|
||||||
|
" "
|
||||||
@@ -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"));
|
||||||
|
|||||||
Reference in New Issue
Block a user