use yew::prelude::*;
use web_sys::{HtmlInputElement, KeyboardEvent};
use wasm_bindgen::JsCast;
use crate::api::types::SkillDetail;
#[derive(Debug, Clone, PartialEq)]
pub struct SuggestionItem {
pub skill: String,
pub tool: String,
pub description: String,
}
#[derive(Properties, PartialEq)]
pub struct CommandPaletteProps {
pub skill_details: Vec<SkillDetail>,
pub on_select: Callback<(String, String)>,
#[prop_or_default]
pub recent_commands: Vec<(String, String)>,
#[prop_or(true)]
pub visible: bool,
}
#[function_component(CommandPalette)]
pub fn command_palette(props: &CommandPaletteProps) -> Html {
let search_query = use_state(String::new);
let selected_index = use_state(|| 0_usize);
let input_ref = use_node_ref();
let all_items: Vec<SuggestionItem> = props.skill_details
.iter()
.flat_map(|skill| {
skill.tools.iter().map(move |tool| SuggestionItem {
skill: skill.summary.name.clone(),
tool: tool.name.clone(),
description: tool.description.clone(),
})
})
.collect();
let filtered_suggestions: Vec<SuggestionItem> = if search_query.is_empty() {
vec![]
} else {
let query_lower = search_query.to_lowercase();
let mut results: Vec<(SuggestionItem, f32)> = all_items
.iter()
.filter_map(|item| {
let skill_tool = format!("{} {}", item.skill, item.tool).to_lowercase();
if skill_tool.contains(&query_lower) {
return Some((item.clone(), 100.0));
}
let mut query_chars = query_lower.chars();
let mut current_char = query_chars.next()?;
let mut score = 0.0_f32;
for (idx, c) in skill_tool.chars().enumerate() {
if c == current_char {
score += 1.0 / (idx as f32 + 1.0); match query_chars.next() {
Some(next) => current_char = next,
None => return Some((item.clone(), score)),
}
}
}
None })
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.into_iter().map(|(item, _)| item).take(8).collect()
};
let on_keydown = {
let selected_index = selected_index.clone();
let search_query = search_query.clone();
let filtered_suggestions = filtered_suggestions.clone();
let on_select = props.on_select.clone();
Callback::from(move |e: KeyboardEvent| {
match e.key().as_str() {
"ArrowDown" => {
e.prevent_default();
let max_idx = filtered_suggestions.len().saturating_sub(1);
selected_index.set((*selected_index + 1).min(max_idx));
}
"ArrowUp" => {
e.prevent_default();
selected_index.set(selected_index.saturating_sub(1));
}
"Enter" => {
e.prevent_default();
if let Some(item) = filtered_suggestions.get(*selected_index) {
on_select.emit((item.skill.clone(), item.tool.clone()));
}
}
"Escape" => {
e.prevent_default();
search_query.set(String::new());
selected_index.set(0);
}
_ => {}
}
})
};
let on_input = {
let search_query = search_query.clone();
let selected_index = selected_index.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
search_query.set(input.value());
selected_index.set(0); })
};
let make_on_suggestion_click = {
let on_select = props.on_select.clone();
move |skill: String, tool: String| {
let on_select = on_select.clone();
Callback::from(move |_: MouseEvent| {
on_select.emit((skill.clone(), tool.clone()));
})
}
};
let make_on_quick_action_click = {
let on_select = props.on_select.clone();
move |skill: String, tool: String| {
let on_select = on_select.clone();
Callback::from(move |_: MouseEvent| {
on_select.emit((skill.clone(), tool.clone()));
})
}
};
use_effect_with(input_ref.clone(), |input_ref| {
if let Some(input) = input_ref.cast::<HtmlInputElement>() {
let _ = input.focus();
}
|| ()
});
if !props.visible {
return html! {};
}
html! {
<div class="command-palette flex flex-col items-center gap-4">
<div class="w-full max-w-2xl">
<input
ref={input_ref}
type="text"
class="search-input neon-focus w-full"
placeholder="🔍 Type skill or tool name..."
value={(*search_query).clone()}
oninput={on_input}
onkeydown={on_keydown}
autofocus=true
/>
</div>
if !search_query.is_empty() && !filtered_suggestions.is_empty() {
<div class="suggestions-list glass-panel w-full max-w-2xl">
{ for filtered_suggestions.iter().enumerate().map(|(idx, item)| {
let is_selected = idx == *selected_index;
let skill = item.skill.clone();
let tool = item.tool.clone();
html! {
<button
class={classes!(
"suggestion-item",
"w-full",
is_selected.then(|| "selected")
)}
onclick={make_on_suggestion_click(skill, tool)}
>
<span class="skill-name text-terminal-accent-cyan font-semibold">
{ &item.skill }
</span>
<span class="separator text-terminal-text-secondary mx-2">
{ "/" }
</span>
<span class="tool-name text-terminal-text-primary">
{ &item.tool }
</span>
<span class="flex-1"></span>
<span class="text-xs text-terminal-text-secondary truncate max-w-xs">
{ &item.description }
</span>
</button>
}
}) }
</div>
}
if !search_query.is_empty() && filtered_suggestions.is_empty() {
<div class="w-full max-w-2xl text-center py-8">
<p class="text-terminal-text-secondary text-sm">
{ "No matching skills or tools found" }
</p>
</div>
}
if !props.recent_commands.is_empty() && search_query.is_empty() {
<div class="quick-actions w-full max-w-2xl mt-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-sm text-terminal-text-secondary font-medium">
{ "Recent:" }
</span>
</div>
<div class="flex flex-wrap gap-2">
{ for props.recent_commands.iter().take(6).map(|(skill, tool)| {
let skill_clone = skill.clone();
let tool_clone = tool.clone();
html! {
<button
class="quick-action-btn"
onclick={make_on_quick_action_click(skill_clone, tool_clone)}
>
<span class="text-terminal-accent-cyan">{ skill }</span>
<span class="text-terminal-text-secondary mx-1">{ "/" }</span>
<span>{ tool }</span>
</button>
}
}) }
</div>
</div>
}
<div class="flex items-center gap-6 text-xs text-terminal-text-secondary mt-4">
<span>{ "↑↓ Navigate" }</span>
<span>{ "↵ Select" }</span>
<span>{ "Esc Clear" }</span>
</div>
</div>
}
}