diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc27fef..ac3d0608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,14 @@ Big thanks to every contributor below. localization table, so once you pick your language the rest of the flow is in that language. Particularly nice for users on CJK input methods who want to avoid IME juggling during setup. +- **`/skills ` filters the local skills list** (#1318) — on + top of the v0.8.26 inter-row spacing (#1328 from @reidliu41), the + list now narrows to skills whose names start with the typed + prefix. Case-insensitive. The header reflects matched count vs + registry total; an empty match set says so explicitly and points + back at unfiltered `/skills`. `--remote` and `sync` stay + reserved as subcommands; any `--`-prefixed argument is rejected + rather than being silently treated as a no-match prefix. - **HTTP 400 quota errors retried** (#1203) — some OpenAI-compatible gateways return quota/rate-limit errors as HTTP 400 instead of 429. These are now classified as retryable `RateLimited` errors. diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 60ab4f45..bc34532b 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -444,7 +444,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "skills", aliases: &[], - usage: "/skills [--remote|sync]", + usage: "/skills [--remote|sync|]", description_id: MessageId::CmdSkillsDescription, }, CommandInfo { diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index e01b4275..eed4423c 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -35,6 +35,7 @@ fn render_skill_warnings(registry: &SkillRegistry) -> String { /// Pass `sync` to pull the registry index and download all skills to the /// local cache (`~/.deepseek/cache/skills/`). pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { + let mut prefix: Option = None; if let Some(arg) = arg { let trimmed = arg.trim(); if trimmed == "--remote" || trimmed == "remote" { @@ -44,7 +45,15 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { return sync_skills(app); } if !trimmed.is_empty() { - return CommandResult::error("Usage: /skills [--remote|sync]"); + // Anything else is treated as a name-prefix filter (#1318). + // Reject obviously malformed args (whitespace inside the + // prefix, leading dash) so future flag additions don't + // collide with skill names. Skill names that start with + // `-` aren't allowed by the loader so this is safe. + if trimmed.starts_with('-') || trimmed.split_whitespace().count() > 1 { + return CommandResult::error("Usage: /skills [--remote|sync|]"); + } + prefix = Some(trimmed.to_ascii_lowercase()); } } let skills_dir = app.skills_dir.clone(); @@ -70,9 +79,38 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { return CommandResult::message(msg); } - let mut output = format!("Available skills ({}):\n", registry.len()); + let filtered: Vec<&crate::skills::Skill> = if let Some(p) = prefix.as_deref() { + registry + .list() + .iter() + .filter(|s| s.name.to_ascii_lowercase().starts_with(p)) + .collect() + } else { + registry.list().iter().collect() + }; + + if filtered.is_empty() { + // The user typed a prefix that matched nothing. Surface what + // they typed plus the full count so they can decide whether + // to adjust the prefix or run `/skills` for the whole list. + let p = prefix.as_deref().unwrap_or(""); + return CommandResult::message(format!( + "No skills match prefix `{p}` (out of {} available).\n\nRun /skills to see them all.{warnings}", + registry.len() + )); + } + + let mut output = if let Some(p) = prefix.as_deref() { + format!( + "Available skills matching `{p}` ({} of {}):\n", + filtered.len(), + registry.len() + ) + } else { + format!("Available skills ({}):\n", registry.len()) + }; output.push_str("─────────────────────────────\n"); - for (idx, skill) in registry.list().iter().enumerate() { + for (idx, skill) in filtered.iter().enumerate() { if idx > 0 { output.push('\n'); } @@ -680,6 +718,104 @@ mod tests { assert!(msg.contains("/test-skill")); } + #[test] + fn test_list_skills_filters_by_name_prefix() { + // #1318: a `/skills ` argument should narrow the list to + // skills whose names start with the prefix. The header reflects + // both the matched count and the registry total so the user + // knows what they're looking at. + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "alpha-skill", + "---\nname: alpha-skill\ndescription: First\n---\nbody", + ); + create_skill_dir( + &tmpdir, + "alphabet-helper", + "---\nname: alphabet-helper\ndescription: Helper\n---\nbody", + ); + create_skill_dir( + &tmpdir, + "beta-skill", + "---\nname: beta-skill\ndescription: Second\n---\nbody", + ); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = list_skills(&mut app, Some("alph")); + let msg = result.message.expect("filter result has message"); + + assert!(msg.contains("/alpha-skill")); + assert!(msg.contains("/alphabet-helper")); + assert!( + !msg.contains("/beta-skill"), + "beta-skill must be filtered out" + ); + assert!( + msg.contains("matching `alph`") && msg.contains("2 of 3"), + "header should show count + total, got: {msg}" + ); + } + + #[test] + fn test_list_skills_filter_is_case_insensitive() { + // Prefix matching is case-insensitive — typing `Alph` finds + // `alpha-skill` the same as `alph` does. + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "alpha-skill", + "---\nname: alpha-skill\ndescription: First\n---\nbody", + ); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = list_skills(&mut app, Some("ALPH")); + let msg = result.message.expect("case-insensitive filter has message"); + assert!(msg.contains("/alpha-skill")); + } + + #[test] + fn test_list_skills_filter_with_zero_matches_says_so() { + // When the prefix matches nothing, the message must say so + // explicitly (rather than printing an empty list) and point + // the user back at the unfiltered command. + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "alpha-skill", + "---\nname: alpha-skill\ndescription: First\n---\nbody", + ); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = list_skills(&mut app, Some("nonexistent")); + let msg = result.message.expect("zero-match filter still has message"); + assert!(msg.contains("No skills match prefix `nonexistent`")); + assert!(msg.contains("Run /skills")); + } + + #[test] + fn test_list_skills_rejects_flag_like_prefix() { + // `--remote` and `sync` stay reserved as subcommands; any other + // dash-prefixed argument is rejected so we don't silently turn + // a future flag into a no-match filter. + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = list_skills(&mut app, Some("--bogus")); + assert!( + result.is_error, + "expected usage error for --bogus, got: {result:?}" + ); + assert!( + result + .message + .as_deref() + .is_some_and(|m| m.contains("name-prefix")), + "expected --bogus error message to mention name-prefix, got: {result:?}" + ); + } + #[test] fn test_list_skills_separates_entries_with_blank_line() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 0fa24bde..5fd8d1ca 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -841,7 +841,7 @@ fn english(id: MessageId) -> &'static str { "Activate a skill, or install/update/uninstall/trust a community skill" } MessageId::CmdSkillsDescription => { - "List local skills (or --remote to browse the curated registry)" + "List local skills (filter by `/skills `; --remote browses the curated registry)" } MessageId::CmdStashDescription => { "Park or restore a composer draft (Ctrl+S to push, /stash list/pop)" @@ -1182,7 +1182,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { "スキルを有効化、またはコミュニティスキルをインストール/更新/アンインストール/信頼" } MessageId::CmdSkillsDescription => { - "ローカルスキルを一覧表示(--remote で精選レジストリを参照)" + "ローカルスキルを一覧表示(`/skills ` で絞り込み、--remote で精選レジストリを参照)" } MessageId::CmdStashDescription => { "コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop)" @@ -1496,7 +1496,9 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdSessionsDescription => "打开会话选择器", MessageId::CmdSettingsDescription => "显示持久化设置", MessageId::CmdSkillDescription => "激活技能,或安装/更新/卸载/信任社区技能", - MessageId::CmdSkillsDescription => "列出本地技能(或使用 --remote 浏览精选注册表)", + MessageId::CmdSkillsDescription => { + "列出本地技能(用 `/skills ` 按名称前缀过滤,--remote 浏览精选注册表)" + } MessageId::CmdStashDescription => "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop)", MessageId::CmdStatusDescription => "显示当前运行状态", MessageId::CmdStatuslineDescription => "配置底栏要显示哪些条目", @@ -1808,7 +1810,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Ativar uma skill, ou instalar/atualizar/desinstalar/confiar em uma skill da comunidade" } MessageId::CmdSkillsDescription => { - "Listar skills locais (ou --remote para navegar pelo registro curado)" + "Listar skills locais (filtre com `/skills `; --remote navega pelo registro curado)" } MessageId::CmdStashDescription => { "Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)"