use super::TuiPalette;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
#[derive(Debug, Clone)]
pub struct PaletteCommand {
pub name: String,
pub description: String,
pub command: String,
pub category: CommandCategory,
pub shortcut: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CommandCategory {
Chat,
File,
#[allow(dead_code)]
Git,
Tool,
Navigation,
#[allow(dead_code)]
Settings,
}
impl CommandCategory {
fn icon(&self) -> &'static str {
match self {
Self::Chat => "💬",
Self::File => "📄",
Self::Git => "🌿",
Self::Tool => "🔧",
Self::Navigation => "🧭",
Self::Settings => "⚙️",
}
}
}
pub struct CommandPalette {
commands: Vec<PaletteCommand>,
filtered: Vec<usize>,
query: String,
selected: usize,
matcher: SkimMatcherV2,
}
impl CommandPalette {
pub fn new() -> Self {
let commands = Self::default_commands();
let filtered: Vec<usize> = (0..commands.len()).collect();
Self {
commands,
filtered,
query: String::new(),
selected: 0,
matcher: SkimMatcherV2::default(),
}
}
fn default_commands() -> Vec<PaletteCommand> {
vec![
PaletteCommand {
name: "Clear conversation".into(),
description: "Clear all messages and start fresh".into(),
command: "/clear".into(),
category: CommandCategory::Chat,
shortcut: None,
},
PaletteCommand {
name: "Show help".into(),
description: "Display available commands".into(),
command: "/help".into(),
category: CommandCategory::Chat,
shortcut: Some("F1".into()),
},
PaletteCommand {
name: "Show status".into(),
description: "Display agent status and memory".into(),
command: "/status".into(),
category: CommandCategory::Chat,
shortcut: None,
},
PaletteCommand {
name: "Show memory".into(),
description: "Display memory statistics".into(),
command: "/memory".into(),
category: CommandCategory::Chat,
shortcut: None,
},
PaletteCommand {
name: "Analyze codebase".into(),
description: "Survey the structure of a directory".into(),
command: "/analyze ".into(),
category: CommandCategory::File,
shortcut: None,
},
PaletteCommand {
name: "Review file".into(),
description: "Review code in a specific file".into(),
command: "/review ".into(),
category: CommandCategory::File,
shortcut: None,
},
PaletteCommand {
name: "View garden".into(),
description: "Visualize codebase as digital garden".into(),
command: "/garden".into(),
category: CommandCategory::File,
shortcut: None,
},
PaletteCommand {
name: "List tools".into(),
description: "Show all available tools".into(),
command: "/tools".into(),
category: CommandCategory::Tool,
shortcut: None,
},
PaletteCommand {
name: "Create plan".into(),
description: "Create a detailed plan for a task".into(),
command: "/plan ".into(),
category: CommandCategory::Tool,
shortcut: None,
},
PaletteCommand {
name: "View journal".into(),
description: "Browse saved task entries".into(),
command: "/journal".into(),
category: CommandCategory::Navigation,
shortcut: None,
},
PaletteCommand {
name: "Exit".into(),
description: "Leave the workshop".into(),
command: "exit".into(),
category: CommandCategory::Navigation,
shortcut: Some("Ctrl+C".into()),
},
]
}
pub fn filter(&mut self) {
if self.query.is_empty() {
self.filtered = (0..self.commands.len()).collect();
} else {
let mut scored: Vec<(i64, usize)> = self
.commands
.iter()
.enumerate()
.filter_map(|(i, cmd)| {
let name_score = self.matcher.fuzzy_match(&cmd.name, &self.query);
let desc_score = self.matcher.fuzzy_match(&cmd.description, &self.query);
let cmd_score = self.matcher.fuzzy_match(&cmd.command, &self.query);
let best_score = [name_score, desc_score, cmd_score]
.into_iter()
.flatten()
.max();
best_score.map(|score| (score, i))
})
.collect();
scored.sort_by_key(|x| std::cmp::Reverse(x.0));
self.filtered = scored.into_iter().map(|(_, i)| i).collect();
}
self.selected = 0;
}
pub fn on_char(&mut self, c: char) {
self.query.push(c);
self.filter();
}
pub fn on_backspace(&mut self) {
self.query.pop();
self.filter();
}
pub fn next(&mut self) {
if !self.filtered.is_empty() {
self.selected = (self.selected + 1) % self.filtered.len();
}
}
pub fn previous(&mut self) {
if !self.filtered.is_empty() {
self.selected = self
.selected
.checked_sub(1)
.unwrap_or(self.filtered.len() - 1);
}
}
pub fn selected_command(&self) -> Option<String> {
self.filtered
.get(self.selected)
.map(|&i| self.commands[i].command.clone())
}
pub fn reset(&mut self) {
self.query.clear();
self.selected = 0;
self.filter();
}
pub fn render(&self, frame: &mut Frame, area: Rect, _selected_override: usize) {
use ratatui::layout::Alignment;
use ratatui::style::Modifier;
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(TuiPalette::AMBER)
.add_modifier(Modifier::BOLD),
)
.title(Span::styled(
" 🎯 Command Palette ",
Style::default()
.fg(TuiPalette::AMBER)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 3 {
return;
}
let query_area = Rect::new(inner.x, inner.y, inner.width, 1);
let query_text = if self.query.is_empty() {
Paragraph::new("Type to search commands...").style(TuiPalette::muted_style())
} else {
Paragraph::new(Line::from(vec![
Span::styled(
"❯ ",
Style::default()
.fg(TuiPalette::AMBER)
.add_modifier(Modifier::BOLD),
),
Span::raw(&self.query),
]))
};
frame.render_widget(query_text, query_area);
let cursor_x = inner.x + 2 + self.query.len() as u16;
frame.set_cursor_position((cursor_x.min(inner.x + inner.width - 1), inner.y));
let list_area = Rect::new(
inner.x,
inner.y + 2,
inner.width,
inner.height.saturating_sub(2),
);
let items: Vec<ListItem> = self
.filtered
.iter()
.enumerate()
.take(list_area.height as usize)
.map(|(i, &cmd_idx)| {
let cmd = &self.commands[cmd_idx];
let is_selected = i == self.selected;
let style = if is_selected {
TuiPalette::selected_style()
} else {
Style::default()
};
let shortcut = cmd
.shortcut
.as_ref()
.map(|s| format!(" [{}]", s))
.unwrap_or_default();
let line = Line::from(vec![
Span::raw(format!("{} ", cmd.category.icon())),
Span::styled(&cmd.name, style.add_modifier(Modifier::BOLD)),
Span::styled(shortcut, TuiPalette::muted_style()),
Span::raw(" — "),
Span::styled(&cmd.description, TuiPalette::muted_style()),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items);
frame.render_widget(list, list_area);
if inner.height > 5 {
let footer_y = inner.y + inner.height - 1;
let footer_area = Rect::new(inner.x, footer_y, inner.width, 1);
let footer = Paragraph::new(
Line::from(vec![
Span::styled("↑/↓: navigate", TuiPalette::muted_style()),
Span::raw(" │ "),
Span::styled("Enter: select", TuiPalette::muted_style()),
Span::raw(" │ "),
Span::styled("Esc: close", TuiPalette::muted_style()),
])
.alignment(Alignment::Center),
);
frame.render_widget(footer, footer_area);
}
}
}
impl Default for CommandPalette {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "../../../tests/unit/ui/tui/palette/palette_test.rs"]
mod tests;