Add skill command autocomplete
- commit
- e455c593c937c7872fd6646eda4b2863c170b6d7
- author
- patriceckhart · · 2026-08-03 17:33 UTC
- parent
- f57d176
- signature
- unsigned
- refs
- tag: v0.3.33
4 files · +112 -7
M docs/skills.md +1 -1
| @@ -82,7 +82,7 @@ For normal skills, the system prompt tells the model the skill names and | |||
| 82 | 82 | short descriptions. When a request matches, the model calls the `skill` tool | |
| 83 | 83 | to load the full instructions on demand. | |
| 84 | 84 | ||
| 85 | - | To force a specific skill, invoke it as a slash command: | |
| 85 | + | To force a specific skill, invoke it as a slash command. Typing `/skill:` opens a filtered list of discovered user skills. Use the arrow keys to select one, `tab` to complete its name, or `enter` to invoke the highlighted skill. | |
| 86 | 86 | ||
| 87 | 87 | ```text | |
| 88 | 88 | /skill:code-review | |
| @@ -82,7 +82,7 @@ For normal skills, the system prompt tells the model the skill names and | |||||
| 82 | short descriptions. When a request matches, the model calls the `skill` tool | 82 | short descriptions. When a request matches, the model calls the `skill` tool | ||
| 83 | to load the full instructions on demand. | 83 | to load the full instructions on demand. | ||
| 84 | 84 | ||||
| 85 | - | To force a specific skill, invoke it as a slash command: | 85 | + | To force a specific skill, invoke it as a slash command. Typing `/skill:` opens a filtered list of discovered user skills. Use the arrow keys to select one, `tab` to complete its name, or `enter` to invoke the highlighted skill. |
| 86 | 86 | ||||
| 87 | ```text | 87 | ```text | ||
| 88 | /skill:code-review | 88 | /skill:code-review | ||
M packages/agent/modes/interactive.go +7 -0
| @@ -1330,6 +1330,13 @@ func (i *Interactive) redraw() { | |||
| 1330 | 1330 | } | |
| 1331 | 1331 | var suggest []string | |
| 1332 | 1332 | currentInput := i.ed.Value() | |
| 1333 | + | if i.suggest.SkillInputStarted(currentInput) { | |
| 1334 | + | var list []*skills.Skill | |
| 1335 | + | if i.cfg.SkillSnapshot != nil { | |
| 1336 | + | list = i.cfg.SkillSnapshot() | |
| 1337 | + | } | |
| 1338 | + | i.suggest.SetSkills(list) | |
| 1339 | + | } | |
| 1333 | 1340 | // Slash popup renders even while the agent is busy so the user | |
| 1334 | 1341 | // can queue a destructive command (/clear, /compact, /logout, | |
| 1335 | 1342 | // /model) or a read-only one (/help, /jump, /sessions, etc.) | |
| @@ -1330,6 +1330,13 @@ func (i *Interactive) redraw() { | |||||
| 1330 | } | 1330 | } | ||
| 1331 | var suggest []string | 1331 | var suggest []string | ||
| 1332 | currentInput := i.ed.Value() | 1332 | currentInput := i.ed.Value() | ||
| 1333 | + | if i.suggest.SkillInputStarted(currentInput) { | |||
| 1334 | + | var list []*skills.Skill | |||
| 1335 | + | if i.cfg.SkillSnapshot != nil { | |||
| 1336 | + | list = i.cfg.SkillSnapshot() | |||
| 1337 | + | } | |||
| 1338 | + | i.suggest.SetSkills(list) | |||
| 1339 | + | } | |||
| 1333 | // Slash popup renders even while the agent is busy so the user | 1340 | // Slash popup renders even while the agent is busy so the user | ||
| 1334 | // can queue a destructive command (/clear, /compact, /logout, | 1341 | // can queue a destructive command (/clear, /compact, /logout, | ||
| 1335 | // /model) or a read-only one (/help, /jump, /sessions, etc.) | 1342 | // /model) or a read-only one (/help, /jump, /sessions, etc.) | ||
M packages/agent/modes/slash_suggest.go +47 -5
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/mattn/go-runewidth" | |
| 8 | 8 | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 9 | 10 | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -74,6 +75,12 @@ type slashSuggester struct { | |||
| 74 | 75 | // iteration order doesn't reshuffle the popup between frames. | |
| 75 | 76 | extra []slashCommand | |
| 76 | 77 | ||
| 78 | + | // skills are dynamic /skill:<name> entries. skillInputActive tracks | |
| 79 | + | // whether the editor is already in one autocomplete session so the | |
| 80 | + | // interactive mode can refresh discovery once instead of every frame. | |
| 81 | + | skills []slashCommand | |
| 82 | + | skillInputActive bool | |
| 83 | + | ||
| 77 | 84 | // lastMatches is the list shown in the most recent Render call. | |
| 78 | 85 | // Up/Down read it so they know which indexes to skip across | |
| 79 | 86 | // header rows. | |
| @@ -87,14 +94,45 @@ type slashSuggester struct { | |||
| 87 | 94 | // randomises). | |
| 88 | 95 | func (s *slashSuggester) SetExtra(cmds []slashCommand) { | |
| 89 | 96 | sorted := append([]slashCommand(nil), cmds...) | |
| 90 | - | sort.Slice(sorted, func(i, j int) bool { | |
| 91 | - | left, right := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name) | |
| 97 | + | sortSlashCommands(sorted) | |
| 98 | + | s.extra = sorted | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // SetSkills updates the user-visible /skill:<name> completion entries. | |
| 102 | + | // Built-ins stay hidden, while skills that disable model invocation remain | |
| 103 | + | // available because explicit invocation is their intended entry point. | |
| 104 | + | func (s *slashSuggester) SetSkills(list []*skills.Skill) { | |
| 105 | + | visible := skills.VisibleSkills(list) | |
| 106 | + | commands := make([]slashCommand, 0, len(visible)) | |
| 107 | + | for _, skill := range visible { | |
| 108 | + | commands = append(commands, slashCommand{ | |
| 109 | + | Name: skillCommandPrefix + skill.Name, | |
| 110 | + | Desc: strings.TrimSpace(skill.Description), | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | sortSlashCommands(commands) | |
| 114 | + | s.skills = commands | |
| 115 | + | s.cursor = 0 | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func sortSlashCommands(commands []slashCommand) { | |
| 119 | + | sort.Slice(commands, func(i, j int) bool { | |
| 120 | + | left, right := strings.ToLower(commands[i].Name), strings.ToLower(commands[j].Name) | |
| 92 | 121 | if left == right { | |
| 93 | - | return sorted[i].Name < sorted[j].Name | |
| 122 | + | return commands[i].Name < commands[j].Name | |
| 94 | 123 | } | |
| 95 | 124 | return left < right | |
| 96 | 125 | }) | |
| 97 | - | s.extra = sorted | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // SkillInputStarted reports the first frame of a /skill: autocomplete | |
| 129 | + | // session. Leaving the prefix resets it, so the next attempt refreshes skill | |
| 130 | + | // discovery while ordinary redraws reuse the existing list. | |
| 131 | + | func (s *slashSuggester) SkillInputStarted(input string) bool { | |
| 132 | + | active := strings.HasPrefix(strings.ToLower(input), skillCommandPrefix) | |
| 133 | + | started := active && !s.skillInputActive | |
| 134 | + | s.skillInputActive = active | |
| 135 | + | return started | |
| 98 | 136 | } | |
| 99 | 137 | ||
| 100 | 138 | // SetJailed updates the current sandbox state. Called once per render | |
| @@ -239,8 +277,12 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||
| 239 | 277 | return nil | |
| 240 | 278 | } | |
| 241 | 279 | matchPrefix := strings.ToLower(input) | |
| 280 | + | catalog := s.allCatalog() | |
| 281 | + | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | |
| 282 | + | catalog = s.skills | |
| 283 | + | } | |
| 242 | 284 | var out []slashCommand | |
| 243 | - | for _, c := range s.allCatalog() { | |
| 285 | + | for _, c := range catalog { | |
| 244 | 286 | if c.Header { | |
| 245 | 287 | // Headers ride along whenever there's at least one | |
| 246 | 288 | // matching command from their group; we drop trailing | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/mattn/go-runewidth" | 7 | "github.com/mattn/go-runewidth" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/skills" | |||
| 9 | "github.com/patriceckhart/zot/packages/tui" | 10 | "github.com/patriceckhart/zot/packages/tui" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -74,6 +75,12 @@ type slashSuggester struct { | |||||
| 74 | // iteration order doesn't reshuffle the popup between frames. | 75 | // iteration order doesn't reshuffle the popup between frames. | ||
| 75 | extra []slashCommand | 76 | extra []slashCommand | ||
| 76 | 77 | ||||
| 78 | + | // skills are dynamic /skill:<name> entries. skillInputActive tracks | |||
| 79 | + | // whether the editor is already in one autocomplete session so the | |||
| 80 | + | // interactive mode can refresh discovery once instead of every frame. | |||
| 81 | + | skills []slashCommand | |||
| 82 | + | skillInputActive bool | |||
| 83 | + | ||||
| 77 | // lastMatches is the list shown in the most recent Render call. | 84 | // lastMatches is the list shown in the most recent Render call. | ||
| 78 | // Up/Down read it so they know which indexes to skip across | 85 | // Up/Down read it so they know which indexes to skip across | ||
| 79 | // header rows. | 86 | // header rows. | ||
| @@ -87,14 +94,45 @@ type slashSuggester struct { | |||||
| 87 | // randomises). | 94 | // randomises). | ||
| 88 | func (s *slashSuggester) SetExtra(cmds []slashCommand) { | 95 | func (s *slashSuggester) SetExtra(cmds []slashCommand) { | ||
| 89 | sorted := append([]slashCommand(nil), cmds...) | 96 | sorted := append([]slashCommand(nil), cmds...) | ||
| 90 | - | sort.Slice(sorted, func(i, j int) bool { | 97 | + | sortSlashCommands(sorted) |
| 91 | - | left, right := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name) | 98 | + | s.extra = sorted |
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // SetSkills updates the user-visible /skill:<name> completion entries. | |||
| 102 | + | // Built-ins stay hidden, while skills that disable model invocation remain | |||
| 103 | + | // available because explicit invocation is their intended entry point. | |||
| 104 | + | func (s *slashSuggester) SetSkills(list []*skills.Skill) { | |||
| 105 | + | visible := skills.VisibleSkills(list) | |||
| 106 | + | commands := make([]slashCommand, 0, len(visible)) | |||
| 107 | + | for _, skill := range visible { | |||
| 108 | + | commands = append(commands, slashCommand{ | |||
| 109 | + | Name: skillCommandPrefix + skill.Name, | |||
| 110 | + | Desc: strings.TrimSpace(skill.Description), | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | sortSlashCommands(commands) | |||
| 114 | + | s.skills = commands | |||
| 115 | + | s.cursor = 0 | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func sortSlashCommands(commands []slashCommand) { | |||
| 119 | + | sort.Slice(commands, func(i, j int) bool { | |||
| 120 | + | left, right := strings.ToLower(commands[i].Name), strings.ToLower(commands[j].Name) | |||
| 92 | if left == right { | 121 | if left == right { | ||
| 93 | - | return sorted[i].Name < sorted[j].Name | 122 | + | return commands[i].Name < commands[j].Name |
| 94 | } | 123 | } | ||
| 95 | return left < right | 124 | return left < right | ||
| 96 | }) | 125 | }) | ||
| 97 | - | s.extra = sorted | 126 | + | } |
| 127 | + | ||||
| 128 | + | // SkillInputStarted reports the first frame of a /skill: autocomplete | |||
| 129 | + | // session. Leaving the prefix resets it, so the next attempt refreshes skill | |||
| 130 | + | // discovery while ordinary redraws reuse the existing list. | |||
| 131 | + | func (s *slashSuggester) SkillInputStarted(input string) bool { | |||
| 132 | + | active := strings.HasPrefix(strings.ToLower(input), skillCommandPrefix) | |||
| 133 | + | started := active && !s.skillInputActive | |||
| 134 | + | s.skillInputActive = active | |||
| 135 | + | return started | |||
| 98 | } | 136 | } | ||
| 99 | 137 | ||||
| 100 | // SetJailed updates the current sandbox state. Called once per render | 138 | // SetJailed updates the current sandbox state. Called once per render | ||
| @@ -239,8 +277,12 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||||
| 239 | return nil | 277 | return nil | ||
| 240 | } | 278 | } | ||
| 241 | matchPrefix := strings.ToLower(input) | 279 | matchPrefix := strings.ToLower(input) | ||
| 280 | + | catalog := s.allCatalog() | |||
| 281 | + | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | |||
| 282 | + | catalog = s.skills | |||
| 283 | + | } | |||
| 242 | var out []slashCommand | 284 | var out []slashCommand | ||
| 243 | - | for _, c := range s.allCatalog() { | 285 | + | for _, c := range catalog { |
| 244 | if c.Header { | 286 | if c.Header { | ||
| 245 | // Headers ride along whenever there's at least one | 287 | // Headers ride along whenever there's at least one | ||
| 246 | // matching command from their group; we drop trailing | 288 | // matching command from their group; we drop trailing | ||
M packages/agent/modes/slash_suggest_test.go +57 -1
| @@ -1,6 +1,11 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 8 | + | ) | |
| 4 | 9 | ||
| 5 | 10 | func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | |
| 6 | 11 | s := newSlashSuggester() | |
| @@ -61,6 +66,57 @@ func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) { | |||
| 61 | 66 | } | |
| 62 | 67 | } | |
| 63 | 68 | ||
| 69 | + | func TestSlashSuggesterCompletesVisibleSkills(t *testing.T) { | |
| 70 | + | s := newSlashSuggester() | |
| 71 | + | s.SetSkills([]*skills.Skill{ | |
| 72 | + | {Name: "test-fix", Description: "Fix failing tests."}, | |
| 73 | + | {Name: "ai-development-workflow", Description: "Guide AI development."}, | |
| 74 | + | {Name: "manual-only", Description: "Explicit invocation only.", DisableModelInvocation: true}, | |
| 75 | + | {Name: "builtin", Description: "Internal.", Builtin: true}, | |
| 76 | + | }) | |
| 77 | + | ||
| 78 | + | if got, want := commandNames(s.matches("/skill:")), []string{ | |
| 79 | + | "/skill:ai-development-workflow", | |
| 80 | + | "/skill:manual-only", | |
| 81 | + | "/skill:test-fix", | |
| 82 | + | }; !slices.Equal(got, want) { | |
| 83 | + | t.Fatalf("matches = %v, want %v", got, want) | |
| 84 | + | } | |
| 85 | + | s.lastMatches = s.matches("/skill:") | |
| 86 | + | if got := s.Selection("/skill:"); got != "/skill:ai-development-workflow" { | |
| 87 | + | t.Fatalf("initial selection = %q", got) | |
| 88 | + | } | |
| 89 | + | s.Down() | |
| 90 | + | if got := s.Selection("/skill:"); got != "/skill:manual-only" { | |
| 91 | + | t.Fatalf("selection after down = %q", got) | |
| 92 | + | } | |
| 93 | + | if got := commandNames(s.matches("/SKILL:ai-dev")); !slices.Equal(got, []string{"/skill:ai-development-workflow"}) { | |
| 94 | + | t.Fatalf("filtered matches = %v", got) | |
| 95 | + | } | |
| 96 | + | if got := s.Selection("/skill:ai-dev"); got != "/skill:ai-development-workflow" { | |
| 97 | + | t.Fatalf("selection = %q", got) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestSlashSuggesterRefreshesSkillsOncePerInputSession(t *testing.T) { | |
| 102 | + | s := newSlashSuggester() | |
| 103 | + | if s.SkillInputStarted("/skill") { | |
| 104 | + | t.Fatal("refresh started before colon") | |
| 105 | + | } | |
| 106 | + | if !s.SkillInputStarted("/skill:") { | |
| 107 | + | t.Fatal("first /skill: did not request refresh") | |
| 108 | + | } | |
| 109 | + | if s.SkillInputStarted("/skill:review") { | |
| 110 | + | t.Fatal("same input session requested another refresh") | |
| 111 | + | } | |
| 112 | + | if s.SkillInputStarted("") { | |
| 113 | + | t.Fatal("leaving skill input requested refresh") | |
| 114 | + | } | |
| 115 | + | if !s.SkillInputStarted("/SKILL:") { | |
| 116 | + | t.Fatal("new case-insensitive input session did not request refresh") | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 64 | 120 | func commandNames(cmds []slashCommand) []string { | |
| 65 | 121 | out := make([]string, 0, len(cmds)) | |
| 66 | 122 | for _, c := range cmds { | |
| @@ -1,6 +1,11 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/skills" | |||
| 8 | + | ) | |||
| 4 | 9 | ||||
| 5 | func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | 10 | func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | ||
| 6 | s := newSlashSuggester() | 11 | s := newSlashSuggester() | ||
| @@ -61,6 +66,57 @@ func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) { | |||||
| 61 | } | 66 | } | ||
| 62 | } | 67 | } | ||
| 63 | 68 | ||||
| 69 | + | func TestSlashSuggesterCompletesVisibleSkills(t *testing.T) { | |||
| 70 | + | s := newSlashSuggester() | |||
| 71 | + | s.SetSkills([]*skills.Skill{ | |||
| 72 | + | {Name: "test-fix", Description: "Fix failing tests."}, | |||
| 73 | + | {Name: "ai-development-workflow", Description: "Guide AI development."}, | |||
| 74 | + | {Name: "manual-only", Description: "Explicit invocation only.", DisableModelInvocation: true}, | |||
| 75 | + | {Name: "builtin", Description: "Internal.", Builtin: true}, | |||
| 76 | + | }) | |||
| 77 | + | ||||
| 78 | + | if got, want := commandNames(s.matches("/skill:")), []string{ | |||
| 79 | + | "/skill:ai-development-workflow", | |||
| 80 | + | "/skill:manual-only", | |||
| 81 | + | "/skill:test-fix", | |||
| 82 | + | }; !slices.Equal(got, want) { | |||
| 83 | + | t.Fatalf("matches = %v, want %v", got, want) | |||
| 84 | + | } | |||
| 85 | + | s.lastMatches = s.matches("/skill:") | |||
| 86 | + | if got := s.Selection("/skill:"); got != "/skill:ai-development-workflow" { | |||
| 87 | + | t.Fatalf("initial selection = %q", got) | |||
| 88 | + | } | |||
| 89 | + | s.Down() | |||
| 90 | + | if got := s.Selection("/skill:"); got != "/skill:manual-only" { | |||
| 91 | + | t.Fatalf("selection after down = %q", got) | |||
| 92 | + | } | |||
| 93 | + | if got := commandNames(s.matches("/SKILL:ai-dev")); !slices.Equal(got, []string{"/skill:ai-development-workflow"}) { | |||
| 94 | + | t.Fatalf("filtered matches = %v", got) | |||
| 95 | + | } | |||
| 96 | + | if got := s.Selection("/skill:ai-dev"); got != "/skill:ai-development-workflow" { | |||
| 97 | + | t.Fatalf("selection = %q", got) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestSlashSuggesterRefreshesSkillsOncePerInputSession(t *testing.T) { | |||
| 102 | + | s := newSlashSuggester() | |||
| 103 | + | if s.SkillInputStarted("/skill") { | |||
| 104 | + | t.Fatal("refresh started before colon") | |||
| 105 | + | } | |||
| 106 | + | if !s.SkillInputStarted("/skill:") { | |||
| 107 | + | t.Fatal("first /skill: did not request refresh") | |||
| 108 | + | } | |||
| 109 | + | if s.SkillInputStarted("/skill:review") { | |||
| 110 | + | t.Fatal("same input session requested another refresh") | |||
| 111 | + | } | |||
| 112 | + | if s.SkillInputStarted("") { | |||
| 113 | + | t.Fatal("leaving skill input requested refresh") | |||
| 114 | + | } | |||
| 115 | + | if !s.SkillInputStarted("/SKILL:") { | |||
| 116 | + | t.Fatal("new case-insensitive input session did not request refresh") | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 64 | func commandNames(cmds []slashCommand) []string { | 120 | func commandNames(cmds []slashCommand) []string { | ||
| 65 | out := make([]string, 0, len(cmds)) | 121 | out := make([]string, 0, len(cmds)) | ||
| 66 | for _, c := range cmds { | 122 | for _, c := range cmds { | ||