skill_web/components/run/
command_palette.rs

1//! Command Palette component - Spotlight/Alfred-style search interface
2//!
3//! Features:
4//! - Giant centered search bar with fuzzy search
5//! - Keyboard navigation (arrows, enter, escape)
6//! - Real-time skill+tool filtering
7//! - Quick action buttons for recent commands
8//! - Dark terminal aesthetic
9
10use yew::prelude::*;
11use web_sys::{HtmlInputElement, KeyboardEvent};
12use wasm_bindgen::JsCast;
13use crate::api::types::SkillDetail;
14
15/// Suggestion item combining skill and tool
16#[derive(Debug, Clone, PartialEq)]
17pub struct SuggestionItem {
18    pub skill: String,
19    pub tool: String,
20    pub description: String,
21}
22
23#[derive(Properties, PartialEq)]
24pub struct CommandPaletteProps {
25    /// All skill details with tools
26    pub skill_details: Vec<SkillDetail>,
27    /// Callback when a skill+tool is selected
28    pub on_select: Callback<(String, String)>,
29    /// Recent executions for quick actions
30    #[prop_or_default]
31    pub recent_commands: Vec<(String, String)>,
32    /// Whether to show the palette
33    #[prop_or(true)]
34    pub visible: bool,
35}
36
37#[function_component(CommandPalette)]
38pub fn command_palette(props: &CommandPaletteProps) -> Html {
39    let search_query = use_state(String::new);
40    let selected_index = use_state(|| 0_usize);
41    let input_ref = use_node_ref();
42
43    // Build flattened list of all skill+tool combinations
44    let all_items: Vec<SuggestionItem> = props.skill_details
45        .iter()
46        .flat_map(|skill| {
47            skill.tools.iter().map(move |tool| SuggestionItem {
48                skill: skill.summary.name.clone(),
49                tool: tool.name.clone(),
50                description: tool.description.clone(),
51            })
52        })
53        .collect();
54
55    // Fuzzy search filtering
56    let filtered_suggestions: Vec<SuggestionItem> = if search_query.is_empty() {
57        vec![]
58    } else {
59        let query_lower = search_query.to_lowercase();
60        let mut results: Vec<(SuggestionItem, f32)> = all_items
61            .iter()
62            .filter_map(|item| {
63                // Simple fuzzy matching - check if query is substring or chars appear in order
64                let skill_tool = format!("{} {}", item.skill, item.tool).to_lowercase();
65
66                // First check: simple substring match (highest score)
67                if skill_tool.contains(&query_lower) {
68                    return Some((item.clone(), 100.0));
69                }
70
71                // Second check: fuzzy char-by-char matching
72                let mut query_chars = query_lower.chars();
73                let mut current_char = query_chars.next()?;
74                let mut score = 0.0_f32;
75
76                for (idx, c) in skill_tool.chars().enumerate() {
77                    if c == current_char {
78                        score += 1.0 / (idx as f32 + 1.0); // Earlier matches score higher
79                        match query_chars.next() {
80                            Some(next) => current_char = next,
81                            None => return Some((item.clone(), score)),
82                        }
83                    }
84                }
85
86                None // Not all query chars matched
87            })
88            .collect();
89
90        // Sort by score descending
91        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
92        results.into_iter().map(|(item, _)| item).take(8).collect()
93    };
94
95    // Handle keyboard navigation
96    let on_keydown = {
97        let selected_index = selected_index.clone();
98        let search_query = search_query.clone();
99        let filtered_suggestions = filtered_suggestions.clone();
100        let on_select = props.on_select.clone();
101
102        Callback::from(move |e: KeyboardEvent| {
103            match e.key().as_str() {
104                "ArrowDown" => {
105                    e.prevent_default();
106                    let max_idx = filtered_suggestions.len().saturating_sub(1);
107                    selected_index.set((*selected_index + 1).min(max_idx));
108                }
109                "ArrowUp" => {
110                    e.prevent_default();
111                    selected_index.set(selected_index.saturating_sub(1));
112                }
113                "Enter" => {
114                    e.prevent_default();
115                    if let Some(item) = filtered_suggestions.get(*selected_index) {
116                        on_select.emit((item.skill.clone(), item.tool.clone()));
117                    }
118                }
119                "Escape" => {
120                    e.prevent_default();
121                    search_query.set(String::new());
122                    selected_index.set(0);
123                }
124                _ => {}
125            }
126        })
127    };
128
129    // Handle input changes
130    let on_input = {
131        let search_query = search_query.clone();
132        let selected_index = selected_index.clone();
133
134        Callback::from(move |e: InputEvent| {
135            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
136            search_query.set(input.value());
137            selected_index.set(0); // Reset selection when query changes
138        })
139    };
140
141    // Handle suggestion click
142    let make_on_suggestion_click = {
143        let on_select = props.on_select.clone();
144        move |skill: String, tool: String| {
145            let on_select = on_select.clone();
146            Callback::from(move |_: MouseEvent| {
147                on_select.emit((skill.clone(), tool.clone()));
148            })
149        }
150    };
151
152    // Handle quick action click
153    let make_on_quick_action_click = {
154        let on_select = props.on_select.clone();
155        move |skill: String, tool: String| {
156            let on_select = on_select.clone();
157            Callback::from(move |_: MouseEvent| {
158                on_select.emit((skill.clone(), tool.clone()));
159            })
160        }
161    };
162
163    // Focus input on mount
164    use_effect_with(input_ref.clone(), |input_ref| {
165        if let Some(input) = input_ref.cast::<HtmlInputElement>() {
166            let _ = input.focus();
167        }
168        || ()
169    });
170
171    if !props.visible {
172        return html! {};
173    }
174
175    html! {
176        <div class="command-palette flex flex-col items-center gap-4">
177            // Giant search input
178            <div class="w-full max-w-2xl">
179                <input
180                    ref={input_ref}
181                    type="text"
182                    class="search-input neon-focus w-full"
183                    placeholder="🔍 Type skill or tool name..."
184                    value={(*search_query).clone()}
185                    oninput={on_input}
186                    onkeydown={on_keydown}
187                    autofocus=true
188                />
189            </div>
190
191            // Suggestions list (only show if there's a query and results)
192            if !search_query.is_empty() && !filtered_suggestions.is_empty() {
193                <div class="suggestions-list glass-panel w-full max-w-2xl">
194                    { for filtered_suggestions.iter().enumerate().map(|(idx, item)| {
195                        let is_selected = idx == *selected_index;
196                        let skill = item.skill.clone();
197                        let tool = item.tool.clone();
198
199                        html! {
200                            <button
201                                class={classes!(
202                                    "suggestion-item",
203                                    "w-full",
204                                    is_selected.then(|| "selected")
205                                )}
206                                onclick={make_on_suggestion_click(skill, tool)}
207                            >
208                                <span class="skill-name text-terminal-accent-cyan font-semibold">
209                                    { &item.skill }
210                                </span>
211                                <span class="separator text-terminal-text-secondary mx-2">
212                                    { "/" }
213                                </span>
214                                <span class="tool-name text-terminal-text-primary">
215                                    { &item.tool }
216                                </span>
217                                <span class="flex-1"></span>
218                                <span class="text-xs text-terminal-text-secondary truncate max-w-xs">
219                                    { &item.description }
220                                </span>
221                            </button>
222                        }
223                    }) }
224                </div>
225            }
226
227            // Show hint when no results
228            if !search_query.is_empty() && filtered_suggestions.is_empty() {
229                <div class="w-full max-w-2xl text-center py-8">
230                    <p class="text-terminal-text-secondary text-sm">
231                        { "No matching skills or tools found" }
232                    </p>
233                </div>
234            }
235
236            // Quick actions (recent commands) - only show when search is empty
237            if !props.recent_commands.is_empty() && search_query.is_empty() {
238                <div class="quick-actions w-full max-w-2xl mt-4">
239                    <div class="flex items-center gap-2 mb-3">
240                        <span class="text-sm text-terminal-text-secondary font-medium">
241                            { "Recent:" }
242                        </span>
243                    </div>
244                    <div class="flex flex-wrap gap-2">
245                        { for props.recent_commands.iter().take(6).map(|(skill, tool)| {
246                            let skill_clone = skill.clone();
247                            let tool_clone = tool.clone();
248
249                            html! {
250                                <button
251                                    class="quick-action-btn"
252                                    onclick={make_on_quick_action_click(skill_clone, tool_clone)}
253                                >
254                                    <span class="text-terminal-accent-cyan">{ skill }</span>
255                                    <span class="text-terminal-text-secondary mx-1">{ "/" }</span>
256                                    <span>{ tool }</span>
257                                </button>
258                            }
259                        }) }
260                    </div>
261                </div>
262            }
263
264            // Keyboard hints
265            <div class="flex items-center gap-6 text-xs text-terminal-text-secondary mt-4">
266                <span>{ "↑↓ Navigate" }</span>
267                <span>{ "↵ Select" }</span>
268                <span>{ "Esc Clear" }</span>
269            </div>
270        </div>
271    }
272}