detached e455c59

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
8282 short descriptions. When a request matches, the model calls the `skill` tool
8383 to load the full instructions on demand.
8484
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.
8686
8787 ```text
8888 /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` tool82 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 ```text87 ```text
88 /skill:code-review88 /skill:code-review
M packages/agent/modes/interactive.go +7 -0
@@ -1330,6 +1330,13 @@ func (i *Interactive) redraw() {
13301330 }
13311331 var suggest []string
13321332 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+ }
13331340 // Slash popup renders even while the agent is busy so the user
13341341 // can queue a destructive command (/clear, /compact, /logout,
13351342 // /model) or a read-only one (/help, /jump, /sessions, etc.)
@@ -1330,6 +1330,13 @@ func (i *Interactive) redraw() {
1330 }1330 }
1331 var suggest []string1331 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 user1340 // 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 (
66
77 "github.com/mattn/go-runewidth"
88
9+ "github.com/patriceckhart/zot/packages/agent/skills"
910 "github.com/patriceckhart/zot/packages/tui"
1011 )
1112
@@ -74,6 +75,12 @@ type slashSuggester struct {
7475 // iteration order doesn't reshuffle the popup between frames.
7576 extra []slashCommand
7677
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+
7784 // lastMatches is the list shown in the most recent Render call.
7885 // Up/Down read it so they know which indexes to skip across
7986 // header rows.
@@ -87,14 +94,45 @@ type slashSuggester struct {
8794 // randomises).
8895 func (s *slashSuggester) SetExtra(cmds []slashCommand) {
8996 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)
92121 if left == right {
93- return sorted[i].Name < sorted[j].Name
122+ return commands[i].Name < commands[j].Name
94123 }
95124 return left < right
96125 })
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
98136 }
99137
100138 // SetJailed updates the current sandbox state. Called once per render
@@ -239,8 +277,12 @@ func (s *slashSuggester) matches(input string) []slashCommand {
239277 return nil
240278 }
241279 matchPrefix := strings.ToLower(input)
280+ catalog := s.allCatalog()
281+ if strings.HasPrefix(matchPrefix, skillCommandPrefix) {
282+ catalog = s.skills
283+ }
242284 var out []slashCommand
243- for _, c := range s.allCatalog() {
285+ for _, c := range catalog {
244286 if c.Header {
245287 // Headers ride along whenever there's at least one
246288 // 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 []slashCommand76 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 across85 // 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].Name122+ return commands[i].Name < commands[j].Name
94 }123 }
95 return left < right124 return left < right
96 })125 })
97- s.extra = sorted126+}
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 render138 // SetJailed updates the current sandbox state. Called once per render
@@ -239,8 +277,12 @@ func (s *slashSuggester) matches(input string) []slashCommand {
239 return nil277 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 []slashCommand284 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 one287 // Headers ride along whenever there's at least one
246 // matching command from their group; we drop trailing288 // matching command from their group; we drop trailing
M packages/agent/modes/slash_suggest_test.go +57 -1
@@ -1,6 +1,11 @@
11 package modes
22
3-import "testing"
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/skills"
8+)
49
510 func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) {
611 s := newSlashSuggester()
@@ -61,6 +66,57 @@ func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {
6166 }
6267 }
6368
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+
64120 func commandNames(cmds []slashCommand) []string {
65121 out := make([]string, 0, len(cmds))
66122 for _, c := range cmds {
@@ -1,6 +1,11 @@
1 package modes1 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close