Skip to main content

vtcode_skills/
render.rs

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