skill_web/components/run/
command_palette.rs1use yew::prelude::*;
11use web_sys::{HtmlInputElement, KeyboardEvent};
12use wasm_bindgen::JsCast;
13use crate::api::types::SkillDetail;
14
15#[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 pub skill_details: Vec<SkillDetail>,
27 pub on_select: Callback<(String, String)>,
29 #[prop_or_default]
31 pub recent_commands: Vec<(String, String)>,
32 #[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 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 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 let skill_tool = format!("{} {}", item.skill, item.tool).to_lowercase();
65
66 if skill_tool.contains(&query_lower) {
68 return Some((item.clone(), 100.0));
69 }
70
71 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); match query_chars.next() {
80 Some(next) => current_char = next,
81 None => return Some((item.clone(), score)),
82 }
83 }
84 }
85
86 None })
88 .collect();
89
90 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 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 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); })
139 };
140
141 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 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 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 <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 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 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 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 <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}