Skip to main content

vtcode_core/skills/
render.rs

1use crate::skills::command_skills::is_model_catalog_eligible;
2use crate::skills::model::SkillMetadata;
3
4pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
5    if skills.is_empty() {
6        return None;
7    }
8
9    let (mut lines, overflow) = render_skill_index(
10        skills,
11        "These skills are discovered at startup from multiple local sources. Each entry includes a description and file path so you can open the source for full instructions.",
12    );
13
14    if overflow > 0 {
15        lines.push(format!("(+{} more skills available)", overflow));
16    }
17
18    lines.push(render_skills_usage_rules().to_string());
19
20    Some(lines.join("\n"))
21}
22
23pub fn render_prompt_skills_section(skills: &[SkillMetadata]) -> Option<String> {
24    let visible_skills = skills
25        .iter()
26        .filter(|skill| is_model_catalog_eligible(skill))
27        .collect::<Vec<_>>();
28    if visible_skills.is_empty() {
29        return None;
30    }
31
32    let mut lines = Vec::new();
33    lines.push("## Skills".to_string());
34    lines.push(
35        "Use a skill only when the user names it or the task clearly matches. Load details on demand."
36            .to_string(),
37    );
38
39    let mut sorted_skills = visible_skills;
40    sorted_skills.sort_by(|left, right| left.name.cmp(&right.name));
41    let overflow = sorted_skills.len().saturating_sub(5);
42    if overflow > 0 {
43        sorted_skills.truncate(5);
44    }
45
46    for skill in sorted_skills {
47        lines.push(render_prompt_skill_line(skill));
48    }
49
50    if overflow > 0 {
51        lines.push(format!("(+{} more skills available)", overflow));
52    }
53
54    Some(lines.join("\n"))
55}
56
57/// Returns the standard skill usage rules (Codex-compatible).
58/// These rules guide the agent on when and how to use skills.
59fn render_skills_usage_rules() -> &'static str {
60    r###"- Discovery: Available skills are listed in project docs and may also appear in a runtime "## Skills" section (name + description + file path). Skill bodies live on disk at the listed paths.
61- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
62- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
63- How to use a skill (progressive disclosure):
64  1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
65  2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
66  3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
67  4) If `assets/` or templates exist, reuse them instead of recreating from scratch.
68- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal. If unsure, ask a brief clarification before proceeding.
69- Coordination and sequencing:
70  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
71  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
72- Context hygiene:
73  - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
74  - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.
75  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
76- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
77}
78
79fn render_skill_index(skills: &[SkillMetadata], intro: &str) -> (Vec<String>, usize) {
80    let mut lines = Vec::new();
81    lines.push("## Skills".to_string());
82    lines.push(intro.to_string());
83
84    let mut sorted_skills = skills.iter().collect::<Vec<_>>();
85    sorted_skills.sort_by(|left, right| left.name.cmp(&right.name));
86    let overflow = sorted_skills.len().saturating_sub(10);
87    if overflow > 0 {
88        sorted_skills.truncate(10);
89    }
90
91    for skill in sorted_skills {
92        lines.push(render_skill_line(skill));
93    }
94
95    (lines, overflow)
96}
97
98fn render_skill_line(skill: &SkillMetadata) -> String {
99    let path_str = skill.path.to_string_lossy().replace('\\', "/");
100    let name = skill.name.as_str();
101    let description = skill.description.as_str();
102    let scope = match skill.scope {
103        crate::skills::model::SkillScope::User => "user",
104        crate::skills::model::SkillScope::Repo => "repo",
105        crate::skills::model::SkillScope::System => "system",
106        crate::skills::model::SkillScope::Admin => "admin",
107    };
108    format!("- {name}: {description} (file: {path_str}, scope: {scope})")
109}
110
111fn render_prompt_skill_line(skill: &SkillMetadata) -> String {
112    format!(
113        "- {}: {}",
114        skill.name,
115        prompt_skill_summary(skill).trim_end_matches('.')
116    )
117}
118
119fn prompt_skill_summary(skill: &SkillMetadata) -> String {
120    let summary = skill
121        .short_description
122        .as_deref()
123        .filter(|text| !text.trim().is_empty())
124        .unwrap_or(skill.description.as_str())
125        .trim();
126
127    if summary.chars().count() <= 32 {
128        return summary.to_string();
129    }
130
131    let truncated = summary.chars().take(32).collect::<String>();
132    format!("{}...", truncated.trim_end())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::path::PathBuf;
139
140    #[test]
141    fn test_render_skills_section_empty() {
142        let skills: Vec<SkillMetadata> = vec![];
143        let result = render_skills_section(&skills);
144        assert_eq!(result, None);
145    }
146
147    #[test]
148    fn test_render_skills_section_single() {
149        let skill = SkillMetadata {
150            name: "test-skill".to_string(),
151            description: "A test skill".to_string(),
152            short_description: None,
153            path: PathBuf::from("/path/to/skill"),
154            scope: crate::skills::model::SkillScope::User,
155            manifest: None,
156        };
157        let skills = vec![skill];
158        let result = render_skills_section(&skills);
159
160        assert!(result.is_some());
161        let output = result.unwrap();
162        assert!(output.contains("## Skills"));
163        assert!(output.contains("- test-skill: A test skill (file: /path/to/skill, scope: user)"));
164        // Check for Codex-style usage rules
165        assert!(output.contains("Discovery: Available skills are listed"));
166        assert!(output.contains("Description as trigger"));
167    }
168
169    #[test]
170    fn test_render_skills_section_multiple() {
171        let skill1 = SkillMetadata {
172            name: "skill-one".to_string(),
173            description: "First skill".to_string(),
174            short_description: None,
175            path: PathBuf::from("/path/to/skill1"),
176            scope: crate::skills::model::SkillScope::User,
177            manifest: None,
178        };
179        let skill2 = SkillMetadata {
180            name: "skill-two".to_string(),
181            description: "Second skill".to_string(),
182            short_description: None,
183            path: PathBuf::from("\\path\\to\\skill2"), // Test path separator replacement
184            scope: crate::skills::model::SkillScope::Repo,
185            manifest: None,
186        };
187        let skills = vec![skill1, skill2];
188        let result = render_skills_section(&skills);
189
190        assert!(result.is_some());
191        let output = result.unwrap();
192        assert!(output.contains("## Skills"));
193        assert!(output.contains("- skill-one: First skill (file: /path/to/skill1, scope: user)"));
194        assert!(output.contains("- skill-two: Second skill (file: /path/to/skill2, scope: repo)")); // Path separator replaced
195        assert!(output.contains("Context hygiene"));
196    }
197
198    #[test]
199    fn test_render_prompt_skills_section_stays_lean() {
200        let skill = SkillMetadata {
201            name: "test-skill".to_string(),
202            description: "A test skill with a longer description".to_string(),
203            short_description: None,
204            path: PathBuf::from("/path/to/skill"),
205            scope: crate::skills::model::SkillScope::User,
206            manifest: None,
207        };
208        let output = render_prompt_skills_section(&[skill]).expect("prompt skills section");
209
210        assert!(output.contains("## Skills"));
211        assert!(output.contains("Use a skill only when the user names it"));
212        assert!(output.contains("- test-skill:"));
213        assert!(output.contains("A test skill with a longer"));
214        assert!(!output.contains("Discovery: Available skills are listed"));
215        assert!(!output.contains("Description as trigger"));
216        assert!(!output.contains("scope:"));
217        assert!(!output.contains("/path/to/skill"));
218    }
219
220    #[test]
221    fn test_render_prompt_skills_section_hides_command_skills() {
222        let hidden_skill = SkillMetadata {
223            name: "hidden-skill".to_string(),
224            description: "Hidden from model activation".to_string(),
225            short_description: None,
226            path: PathBuf::from("/path/to/hidden-skill"),
227            scope: crate::skills::model::SkillScope::System,
228            manifest: Some(
229                crate::skills::types::SkillManifest {
230                    name: "hidden-skill".to_string(),
231                    description: "Hidden from model activation".to_string(),
232                    disable_model_invocation: Some(true),
233                    ..Default::default()
234                }
235                .into(),
236            ),
237        };
238        let normal_skill = SkillMetadata {
239            name: "repo-skill".to_string(),
240            description: "A repo skill".to_string(),
241            short_description: None,
242            path: PathBuf::from("/path/to/repo-skill"),
243            scope: crate::skills::model::SkillScope::Repo,
244            manifest: None,
245        };
246
247        let output = render_prompt_skills_section(&[hidden_skill, normal_skill])
248            .expect("prompt skills section");
249
250        assert!(output.contains("repo-skill"));
251        assert!(!output.contains("hidden-skill"));
252    }
253
254    #[test]
255    fn test_render_prompt_skills_section_prefers_short_description() {
256        let skill = SkillMetadata {
257            name: "codemod".to_string(),
258            description: "Long description that should not be emitted in the prompt".to_string(),
259            short_description: Some("Codemods and migrations".to_string()),
260            path: PathBuf::from("/path/to/codemod"),
261            scope: crate::skills::model::SkillScope::Repo,
262            manifest: None,
263        };
264
265        let output = render_prompt_skills_section(&[skill]).expect("prompt skills section");
266
267        assert!(output.contains("- codemod: Codemods and migrations"));
268        assert!(!output.contains("Long description"));
269    }
270
271    #[test]
272    fn test_render_prompt_skills_section_stays_within_budget() {
273        let skills = (0..8)
274            .map(|index| SkillMetadata {
275                name: format!("skill-{index}"),
276                description: format!("Long description {index} that should be truncated in prompt"),
277                short_description: Some(format!("Short skill {index}")),
278                path: PathBuf::from(format!("/path/to/skill-{index}")),
279                scope: crate::skills::model::SkillScope::Repo,
280                manifest: None,
281            })
282            .collect::<Vec<_>>();
283
284        let output = render_prompt_skills_section(&skills).expect("prompt skills section");
285        let approx_tokens = output.len() / 4;
286
287        assert!(approx_tokens < 110, "got ~{} tokens", approx_tokens);
288        assert!(!output.contains("/path/to/skill"));
289        assert!(output.contains("(+3 more skills available)"));
290    }
291
292    #[test]
293    fn test_render_skills_usage_rules() {
294        let rules = render_skills_usage_rules();
295        assert!(rules.contains("Description as trigger"));
296        assert!(rules.contains("progressive disclosure"));
297        assert!(rules.contains("Coordination and sequencing"));
298        assert!(rules.contains("Context hygiene"));
299        assert!(rules.contains("Safety and fallback"));
300    }
301}