use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use crate::locked;
#[derive(Clone, Debug, Default)]
pub(crate) struct SkillCommandState {
inner: Arc<Mutex<SkillSnapshot>>,
}
#[derive(Debug, Default)]
struct SkillSnapshot {
completions: BTreeMap<String, SkillCompletion>,
available_names: BTreeSet<tau_proto::SkillName>,
}
#[derive(Clone, Debug)]
struct SkillCompletion {
description: String,
argument_hint: Option<String>,
source_label: String,
}
impl SkillCommandState {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn apply_session_snapshot(
&self,
snapshot: &tau_proto::HarnessSessionSkillsAvailable,
) {
let mut inner = locked(&self.inner);
inner.completions.clear();
inner.available_names.clear();
for skill in &snapshot.skills {
inner.available_names.insert(skill.name.clone());
if !skill.user_invocable {
continue;
}
let source_label = match &skill.source {
tau_proto::DiscoveryEffectiveSkillSource::File { path } => {
path.display().to_string()
}
tau_proto::DiscoveryEffectiveSkillSource::BuiltIn => "built-in skill".to_owned(),
};
inner.completions.insert(
skill.name.to_string(),
SkillCompletion {
description: skill.description.clone(),
argument_hint: skill.argument_hint.clone(),
source_label,
},
);
}
}
pub(crate) fn unadvertised_count(
&self,
advertised: &[tau_proto::DiscoveryEffectiveSkill],
) -> usize {
let advertised = advertised
.iter()
.map(|skill| skill.name.clone())
.collect::<BTreeSet<_>>();
locked(&self.inner)
.available_names
.difference(&advertised)
.count()
}
pub(crate) fn arg_completer(&self) -> tau_cli_term::ArgCompleter {
let state = self.clone();
Arc::new(move |args| state.complete_args(args))
}
fn complete_args(&self, args: &[&str]) -> Vec<tau_cli_term::CompletionItem> {
if args.len() != 1 {
return Vec::new();
}
let needle = args[0].to_lowercase();
let mut prefix_matches = Vec::new();
let mut substring_matches = Vec::new();
for (name, skill) in &locked(&self.inner).completions {
let lower_name = name.to_lowercase();
let item = tau_cli_term::CompletionItem::new(name, skill.menu_description());
if needle.is_empty() || lower_name.starts_with(&needle) {
prefix_matches.push(item);
} else if lower_name.contains(&needle) {
substring_matches.push(item);
}
}
prefix_matches.extend(substring_matches);
prefix_matches
}
}
impl SkillCompletion {
fn menu_description(&self) -> String {
let mut description = self.description.clone();
if let Some(hint) = self
.argument_hint
.as_deref()
.filter(|hint| !hint.is_empty())
{
description.push_str(" — ");
description.push_str(hint);
}
description.push_str(" (");
description.push_str(&self.source_label);
description.push(')');
description
}
}
#[cfg(test)]
mod tests;