detached c3694f1

feat: add opt-in fuzzy skill suggestions

commit
c3694f1302ae6b9bc8f35355427444fbd39f01d9
author
patriceckhart · · 2026-09-10 06:08 UTC
parent
5bef29b
signature
unsigned
9 files · +299 -9
M README.md +1 -0
@@ -433,6 +433,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
433433 }
434434 ```
435435
436+- **fuzzy skill suggestions**: opt in to relevance-ranked, case-insensitive subsequence matching for `/skill:` names. For example, `/skill:review` or `/skill:crv` can find `code-review`. Matching characters appear in bold. Empty queries stay alphabetical, score ties use alphabetical order, and exact names retain priority. Off by default; missing or `false` keeps prefix matching. Changes apply immediately and persist as `fuzzy_skill_suggest` in `$ZOT_HOME/config.json`. Other slash commands and skill invocation lookup are unchanged. See [Skills](docs/skills.md).
436437 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
437438 - **collapse tool call rendering**: retain each tool-call header and the last non-empty rendered preview line, hiding the rest of the body. The preview may be an exit status, output line, or truncation summary; failed calls also retain an explicit error indicator. Boxed, flat, and compact layouts are preserved. `ctrl+o` expands the full output. Off by default. Changes apply immediately and persist to `config.json` as `collapse_tool_call`.
438439 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -433,6 +433,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
433 }433 }
434 ```434 ```
435 435
436+- **fuzzy skill suggestions**: opt in to relevance-ranked, case-insensitive subsequence matching for `/skill:` names. For example, `/skill:review` or `/skill:crv` can find `code-review`. Matching characters appear in bold. Empty queries stay alphabetical, score ties use alphabetical order, and exact names retain priority. Off by default; missing or `false` keeps prefix matching. Changes apply immediately and persist as `fuzzy_skill_suggest` in `$ZOT_HOME/config.json`. Other slash commands and skill invocation lookup are unchanged. See [Skills](docs/skills.md).
436 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.437 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
437 - **collapse tool call rendering**: retain each tool-call header and the last non-empty rendered preview line, hiding the rest of the body. The preview may be an exit status, output line, or truncation summary; failed calls also retain an explicit error indicator. Boxed, flat, and compact layouts are preserved. `ctrl+o` expands the full output. Off by default. Changes apply immediately and persist to `config.json` as `collapse_tool_call`.438 - **collapse tool call rendering**: retain each tool-call header and the last non-empty rendered preview line, hiding the rest of the body. The preview may be an exit status, output line, or truncation summary; failed calls also retain an explicit error indicator. Boxed, flat, and compact layouts are preserved. `ctrl+o` expands the full output. Off by default. Changes apply immediately and persist to `config.json` as `collapse_tool_call`.
438 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.439 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
M docs/skills.md +14 -0
@@ -99,6 +99,20 @@ To force a specific skill, invoke it as a slash command. Typing `/skill:` opens
9999 /skill:code-review focus on security issues
100100 ```
101101
102+By default, suggestions match case-insensitive name prefixes and are sorted
103+alphabetically. Enable **fuzzy skill suggestions** in `/settings` to match
104+characters in order anywhere in a skill name: `/skill:review` and `/skill:crv`
105+can both find `code-review`. Results are ranked by relevance, with alphabetical
106+tie-breaking, and matching characters are shown in bold, including in the
107+selected row. Only names are searched, not descriptions or the `/skill:` prefix.
108+An empty query still lists skills alphabetically; an exact name shows only that
109+skill. Other slash-command suggestions and exact skill lookup during invocation
110+are unchanged. Complete a fuzzy query before adding request text.
111+
112+The setting takes effect immediately and persists as `fuzzy_skill_suggest` in
113+`$ZOT_HOME/config.json`. Missing or `false` preserves the default prefix behavior.
114+You can turn it off at any time. Bold emphasis depends on terminal support.
115+
102116 zot expands the command into a user message containing the complete skill
103117 body, its directory for resolving relative references, and any text following
104118 the command as the request. This bypasses model-side skill selection.
@@ -99,6 +99,20 @@ To force a specific skill, invoke it as a slash command. Typing `/skill:` opens
99 /skill:code-review focus on security issues99 /skill:code-review focus on security issues
100 ```100 ```
101 101
102+By default, suggestions match case-insensitive name prefixes and are sorted
103+alphabetically. Enable **fuzzy skill suggestions** in `/settings` to match
104+characters in order anywhere in a skill name: `/skill:review` and `/skill:crv`
105+can both find `code-review`. Results are ranked by relevance, with alphabetical
106+tie-breaking, and matching characters are shown in bold, including in the
107+selected row. Only names are searched, not descriptions or the `/skill:` prefix.
108+An empty query still lists skills alphabetically; an exact name shows only that
109+skill. Other slash-command suggestions and exact skill lookup during invocation
110+are unchanged. Complete a fuzzy query before adding request text.
111+
112+The setting takes effect immediately and persists as `fuzzy_skill_suggest` in
113+`$ZOT_HOME/config.json`. Missing or `false` preserves the default prefix behavior.
114+You can turn it off at any time. Bold emphasis depends on terminal support.
115+
102 zot expands the command into a user message containing the complete skill116 zot expands the command into a user message containing the complete skill
103 body, its directory for resolving relative references, and any text following117 body, its directory for resolving relative references, and any text following
104 the command as the request. This bypasses model-side skill selection.118 the command as the request. This bypasses model-side skill selection.
M packages/agent/cli.go +1 -0
@@ -1328,6 +1328,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13281328 NoTools: args.NoTools,
13291329 QuickModelShortcuts: quickModelShortcuts,
13301330 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1331+ FuzzySkillSuggest: initialCfg.FuzzySkillSuggest,
13311332 RespectGitignore: initialCfg.RespectGitignore,
13321333 CompactMode: initialCfg.CompactMode,
13331334 CollapseToolCall: initialCfg.CollapseToolCall,
@@ -1328,6 +1328,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1328 NoTools: args.NoTools,1328 NoTools: args.NoTools,
1329 QuickModelShortcuts: quickModelShortcuts,1329 QuickModelShortcuts: quickModelShortcuts,
1330 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,1330 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1331+ FuzzySkillSuggest: initialCfg.FuzzySkillSuggest,
1331 RespectGitignore: initialCfg.RespectGitignore,1332 RespectGitignore: initialCfg.RespectGitignore,
1332 CompactMode: initialCfg.CompactMode,1333 CompactMode: initialCfg.CompactMode,
1333 CollapseToolCall: initialCfg.CollapseToolCall,1334 CollapseToolCall: initialCfg.CollapseToolCall,
M packages/agent/config.go +4 -0
@@ -87,6 +87,10 @@ type Config struct {
8787 // directory browse. Toggle from /settings.
8888 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
8989
90+ // FuzzySkillSuggest enables ranked subsequence skill suggestions.
91+ // nil/missing/false preserves prefix matching. Toggle from /settings.
92+ FuzzySkillSuggest *bool `json:"fuzzy_skill_suggest,omitempty"`
93+
9094 // RespectGitignore controls whether the @-mention file picker hides
9195 // files and directories matched by the project's root .gitignore (in
9296 // both flat and recursive modes). nil/missing means the default,
@@ -87,6 +87,10 @@ type Config struct {
87 // directory browse. Toggle from /settings.87 // directory browse. Toggle from /settings.
88 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`88 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
89 89
90+ // FuzzySkillSuggest enables ranked subsequence skill suggestions.
91+ // nil/missing/false preserves prefix matching. Toggle from /settings.
92+ FuzzySkillSuggest *bool `json:"fuzzy_skill_suggest,omitempty"`
93+
90 // RespectGitignore controls whether the @-mention file picker hides94 // RespectGitignore controls whether the @-mention file picker hides
91 // files and directories matched by the project's root .gitignore (in95 // files and directories matched by the project's root .gitignore (in
92 // both flat and recursive modes). nil/missing means the default,96 // both flat and recursive modes). nil/missing means the default,
A packages/agent/modes/fuzzy_skill_suggest_test.go +139 -0
@@ -0,0 +1,139 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "slices"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/agent/skills"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestFuzzySkillSuggestions(t *testing.T) {
14+ s := newSlashSuggester()
15+ s.SetSkills([]*skills.Skill{
16+ {Name: "code-review", DisableModelInvocation: true},
17+ {Name: "review-code"},
18+ {Name: "aaa", Description: "review"},
19+ {Name: "review-builtin", Builtin: true},
20+ })
21+ check := func(query string, want ...string) {
22+ t.Helper()
23+ if got := commandNames(s.matches(query)); !slices.Equal(got, want) {
24+ t.Fatalf("matches(%q) = %v, want %v", query, got, want)
25+ }
26+ }
27+ check("/skill:review", "/skill:review-code")
28+ check("/skill:crv")
29+ s.SetFuzzySkills(true)
30+ check("/skill:review", "/skill:review-code", "/skill:code-review")
31+ check("/SKILL:CRV", "/skill:code-review")
32+ check("/skill:", "/skill:aaa", "/skill:code-review", "/skill:review-code")
33+ check("/skill:code-review", "/skill:code-review")
34+ check("/skill:zzz")
35+ check("/skill:skill") // The command prefix is not searched.
36+ check("/skill:review request")
37+ check("/x") // Other slash commands still require prefixes.
38+ s.SetExtra([]slashCommand{{Name: "/code-review"}})
39+ check("/review")
40+ s.Render("/skill:review", tui.Theme{}, 80)
41+ s.Down()
42+ if got := s.Selection("/skill:review"); got != "/skill:code-review" {
43+ t.Fatal(got)
44+ }
45+ s.SetFuzzySkills(false)
46+ check("/skill:review", "/skill:review-code")
47+ if s.cursor != 0 || s.lastMatches != nil {
48+ t.Fatal("toggle did not reset selection")
49+ }
50+ for _, c := range s.matches("/skill:") {
51+ if len(c.matchedIndexes) != 0 {
52+ t.Fatal("highlight leaked into catalog")
53+ }
54+ }
55+}
56+
57+func TestFuzzySkillSuggestionExactName(t *testing.T) {
58+ s := newSlashSuggester()
59+ s.SetSkills([]*skills.Skill{{Name: "review-more"}, {Name: "review"}, {Name: "code-review"}})
60+ for _, enabled := range []bool{false, true} {
61+ s.SetFuzzySkills(enabled)
62+ if got := commandNames(s.matches("/SKILL:REVIEW")); !slices.Equal(got, []string{"/skill:review"}) {
63+ t.Fatalf("exact match with fuzzy=%v: %v", enabled, got)
64+ }
65+ }
66+ if _, recognized, err := expandSkillCommand("/skill:crv", []*skills.Skill{{Name: "code-review"}}); !recognized || err == nil {
67+ t.Fatal("invocation must still require an exact name")
68+ }
69+}
70+
71+func TestFuzzySkillSuggestionTies(t *testing.T) {
72+ s := newSlashSuggester()
73+ s.SetSkills([]*skills.Skill{{Name: "bb-review"}, {Name: "aa-review"}})
74+ s.SetFuzzySkills(true)
75+ want := []string{"/skill:aa-review", "/skill:bb-review"}
76+ for range 10 {
77+ if got := commandNames(s.matches("/skill:review")); !slices.Equal(got, want) {
78+ t.Fatalf("ties = %v", got)
79+ }
80+ }
81+}
82+
83+func TestFuzzySkillSuggestionHighlight(t *testing.T) {
84+ s := newSlashSuggester()
85+ s.SetSkills([]*skills.Skill{{Name: "pré-review"}, {Name: "pré-revise"}})
86+ s.SetFuzzySkills(true)
87+ for _, width := range []int{1, 20, 80} {
88+ lines := s.Render("/skill:Érv", tui.Theme{}, width)
89+ for row := range 2 {
90+ for _, char := range []string{"é", "r", "v"} {
91+ if !strings.Contains(lines[row], tui.Bold(char)) {
92+ t.Fatalf("missing emphasis for %q: %q", char, lines[row])
93+ }
94+ }
95+ if !strings.Contains(lines[row], "/skill:") {
96+ t.Fatalf("prefix was emphasized: %q", lines[row])
97+ }
98+ }
99+ }
100+}
101+
102+type fuzzySkillSettingsStore struct {
103+ SettingsStore
104+ value bool
105+ err error
106+}
107+
108+func (s *fuzzySkillSettingsStore) SetFuzzySkillSuggest(value bool) error {
109+ if s.err != nil {
110+ return s.err
111+ }
112+ s.value = value
113+ return nil
114+}
115+
116+func TestFuzzySkillSetting(t *testing.T) {
117+ for _, enabled := range []bool{false, true} {
118+ i := NewInteractive(InteractiveConfig{FuzzySkillSuggest: &enabled})
119+ if i.suggest.fuzzySkills != enabled {
120+ t.Fatal("startup preference ignored")
121+ }
122+ }
123+ store := &fuzzySkillSettingsStore{}
124+ i := NewInteractive(InteractiveConfig{SettingsStore: store})
125+ if i.suggest.fuzzySkills {
126+ t.Fatal("default must be off")
127+ }
128+ for _, value := range []bool{true, false} {
129+ i.applySettingToggle("fuzzy_skill_suggest", value)
130+ if store.value != value || i.suggest.fuzzySkills != value || i.cfg.FuzzySkillSuggest == nil || *i.cfg.FuzzySkillSuggest != value {
131+ t.Fatal("toggle was not applied and persisted")
132+ }
133+ }
134+ store.err = errors.New("save failed")
135+ i.applySettingToggle("fuzzy_skill_suggest", true)
136+ if i.suggest.fuzzySkills || *i.cfg.FuzzySkillSuggest || !strings.Contains(i.statusErr, "save failed") {
137+ t.Fatal("failed save changed live preference or hid error")
138+ }
139+}
@@ -0,0 +1,139 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "slices"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/agent/skills"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestFuzzySkillSuggestions(t *testing.T) {
14+ s := newSlashSuggester()
15+ s.SetSkills([]*skills.Skill{
16+ {Name: "code-review", DisableModelInvocation: true},
17+ {Name: "review-code"},
18+ {Name: "aaa", Description: "review"},
19+ {Name: "review-builtin", Builtin: true},
20+ })
21+ check := func(query string, want ...string) {
22+ t.Helper()
23+ if got := commandNames(s.matches(query)); !slices.Equal(got, want) {
24+ t.Fatalf("matches(%q) = %v, want %v", query, got, want)
25+ }
26+ }
27+ check("/skill:review", "/skill:review-code")
28+ check("/skill:crv")
29+ s.SetFuzzySkills(true)
30+ check("/skill:review", "/skill:review-code", "/skill:code-review")
31+ check("/SKILL:CRV", "/skill:code-review")
32+ check("/skill:", "/skill:aaa", "/skill:code-review", "/skill:review-code")
33+ check("/skill:code-review", "/skill:code-review")
34+ check("/skill:zzz")
35+ check("/skill:skill") // The command prefix is not searched.
36+ check("/skill:review request")
37+ check("/x") // Other slash commands still require prefixes.
38+ s.SetExtra([]slashCommand{{Name: "/code-review"}})
39+ check("/review")
40+ s.Render("/skill:review", tui.Theme{}, 80)
41+ s.Down()
42+ if got := s.Selection("/skill:review"); got != "/skill:code-review" {
43+ t.Fatal(got)
44+ }
45+ s.SetFuzzySkills(false)
46+ check("/skill:review", "/skill:review-code")
47+ if s.cursor != 0 || s.lastMatches != nil {
48+ t.Fatal("toggle did not reset selection")
49+ }
50+ for _, c := range s.matches("/skill:") {
51+ if len(c.matchedIndexes) != 0 {
52+ t.Fatal("highlight leaked into catalog")
53+ }
54+ }
55+}
56+
57+func TestFuzzySkillSuggestionExactName(t *testing.T) {
58+ s := newSlashSuggester()
59+ s.SetSkills([]*skills.Skill{{Name: "review-more"}, {Name: "review"}, {Name: "code-review"}})
60+ for _, enabled := range []bool{false, true} {
61+ s.SetFuzzySkills(enabled)
62+ if got := commandNames(s.matches("/SKILL:REVIEW")); !slices.Equal(got, []string{"/skill:review"}) {
63+ t.Fatalf("exact match with fuzzy=%v: %v", enabled, got)
64+ }
65+ }
66+ if _, recognized, err := expandSkillCommand("/skill:crv", []*skills.Skill{{Name: "code-review"}}); !recognized || err == nil {
67+ t.Fatal("invocation must still require an exact name")
68+ }
69+}
70+
71+func TestFuzzySkillSuggestionTies(t *testing.T) {
72+ s := newSlashSuggester()
73+ s.SetSkills([]*skills.Skill{{Name: "bb-review"}, {Name: "aa-review"}})
74+ s.SetFuzzySkills(true)
75+ want := []string{"/skill:aa-review", "/skill:bb-review"}
76+ for range 10 {
77+ if got := commandNames(s.matches("/skill:review")); !slices.Equal(got, want) {
78+ t.Fatalf("ties = %v", got)
79+ }
80+ }
81+}
82+
83+func TestFuzzySkillSuggestionHighlight(t *testing.T) {
84+ s := newSlashSuggester()
85+ s.SetSkills([]*skills.Skill{{Name: "pré-review"}, {Name: "pré-revise"}})
86+ s.SetFuzzySkills(true)
87+ for _, width := range []int{1, 20, 80} {
88+ lines := s.Render("/skill:Érv", tui.Theme{}, width)
89+ for row := range 2 {
90+ for _, char := range []string{"é", "r", "v"} {
91+ if !strings.Contains(lines[row], tui.Bold(char)) {
92+ t.Fatalf("missing emphasis for %q: %q", char, lines[row])
93+ }
94+ }
95+ if !strings.Contains(lines[row], "/skill:") {
96+ t.Fatalf("prefix was emphasized: %q", lines[row])
97+ }
98+ }
99+ }
100+}
101+
102+type fuzzySkillSettingsStore struct {
103+ SettingsStore
104+ value bool
105+ err error
106+}
107+
108+func (s *fuzzySkillSettingsStore) SetFuzzySkillSuggest(value bool) error {
109+ if s.err != nil {
110+ return s.err
111+ }
112+ s.value = value
113+ return nil
114+}
115+
116+func TestFuzzySkillSetting(t *testing.T) {
117+ for _, enabled := range []bool{false, true} {
118+ i := NewInteractive(InteractiveConfig{FuzzySkillSuggest: &enabled})
119+ if i.suggest.fuzzySkills != enabled {
120+ t.Fatal("startup preference ignored")
121+ }
122+ }
123+ store := &fuzzySkillSettingsStore{}
124+ i := NewInteractive(InteractiveConfig{SettingsStore: store})
125+ if i.suggest.fuzzySkills {
126+ t.Fatal("default must be off")
127+ }
128+ for _, value := range []bool{true, false} {
129+ i.applySettingToggle("fuzzy_skill_suggest", value)
130+ if store.value != value || i.suggest.fuzzySkills != value || i.cfg.FuzzySkillSuggest == nil || *i.cfg.FuzzySkillSuggest != value {
131+ t.Fatal("toggle was not applied and persisted")
132+ }
133+ }
134+ store.err = errors.New("save failed")
135+ i.applySettingToggle("fuzzy_skill_suggest", true)
136+ if i.suggest.fuzzySkills || *i.cfg.FuzzySkillSuggest || !strings.Contains(i.statusErr, "save failed") {
137+ t.Fatal("failed save changed live preference or hid error")
138+ }
139+}
M packages/agent/modes/interactive.go +26 -0
@@ -95,6 +95,9 @@ type InteractiveConfig struct {
9595 // whole project tree instead of browsing one directory at a time.
9696 RecursiveFileSuggest *bool
9797
98+ // FuzzySkillSuggest opts into ranked skill-name subsequence suggestions.
99+ FuzzySkillSuggest *bool
100+
98101 // RespectGitignore mirrors the persisted respect_gitignore flag at
99102 // startup. nil means the default (on); when false the @-mention
100103 // picker shows files matched by the project's root .gitignore.
@@ -361,6 +364,7 @@ type SettingsStore interface {
361364 SetJailByDefault(enabled bool) error
362365 SetOpenRouterServerTools(enabled bool) error
363366 SetRecursiveFileSuggest(enabled bool) error
367+ SetFuzzySkillSuggest(enabled bool) error
364368 SetRespectGitignore(enabled bool) error
365369 SetCompactMode(enabled bool) error
366370 SetCollapseToolCall(enabled bool) error
@@ -681,6 +685,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
681685 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
682686 }
683687 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
688+ i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
684689 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
685690 if cfg.LlamaCPPConfig != nil {
686691 baseURL, _, err := cfg.LlamaCPPConfig()
@@ -3561,6 +3566,12 @@ func (i *Interactive) openSettingsDialog() {
35613566 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
35623567 value: recursiveFiles,
35633568 },
3569+ {
3570+ key: "fuzzy_skill_suggest",
3571+ label: "fuzzy skill suggestions",
3572+ desc: "rank /skill: suggestions by name subsequence matches and emphasize matching characters",
3573+ value: i.cfg.FuzzySkillSuggest != nil && *i.cfg.FuzzySkillSuggest,
3574+ },
35643575 {
35653576 key: "respect_gitignore",
35663577 label: "hide gitignored files in @-picker",
@@ -3940,6 +3951,21 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
39403951 i.statusOK = "OpenRouter server tools " + onOff(value)
39413952 i.statusErr = ""
39423953 i.mu.Unlock()
3954+ case "fuzzy_skill_suggest":
3955+ if i.cfg.SettingsStore != nil {
3956+ if err := i.cfg.SettingsStore.SetFuzzySkillSuggest(value); err != nil {
3957+ i.mu.Lock()
3958+ i.statusErr = "settings: " + err.Error()
3959+ i.mu.Unlock()
3960+ return
3961+ }
3962+ }
3963+ i.cfg.FuzzySkillSuggest = &value
3964+ i.suggest.SetFuzzySkills(value)
3965+ i.mu.Lock()
3966+ i.statusOK = "fuzzy skill suggestions " + onOff(value)
3967+ i.statusErr = ""
3968+ i.mu.Unlock()
39433969 case "recursive_file_suggest":
39443970 val := value
39453971 i.cfg.RecursiveFileSuggest = &val
@@ -95,6 +95,9 @@ type InteractiveConfig struct {
95 // whole project tree instead of browsing one directory at a time.95 // whole project tree instead of browsing one directory at a time.
96 RecursiveFileSuggest *bool96 RecursiveFileSuggest *bool
97 97
98+ // FuzzySkillSuggest opts into ranked skill-name subsequence suggestions.
99+ FuzzySkillSuggest *bool
100+
98 // RespectGitignore mirrors the persisted respect_gitignore flag at101 // RespectGitignore mirrors the persisted respect_gitignore flag at
99 // startup. nil means the default (on); when false the @-mention102 // startup. nil means the default (on); when false the @-mention
100 // picker shows files matched by the project's root .gitignore.103 // picker shows files matched by the project's root .gitignore.
@@ -361,6 +364,7 @@ type SettingsStore interface {
361 SetJailByDefault(enabled bool) error364 SetJailByDefault(enabled bool) error
362 SetOpenRouterServerTools(enabled bool) error365 SetOpenRouterServerTools(enabled bool) error
363 SetRecursiveFileSuggest(enabled bool) error366 SetRecursiveFileSuggest(enabled bool) error
367+ SetFuzzySkillSuggest(enabled bool) error
364 SetRespectGitignore(enabled bool) error368 SetRespectGitignore(enabled bool) error
365 SetCompactMode(enabled bool) error369 SetCompactMode(enabled bool) error
366 SetCollapseToolCall(enabled bool) error370 SetCollapseToolCall(enabled bool) error
@@ -681,6 +685,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
681 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),685 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
682 }686 }
683 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)687 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
688+ i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
684 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)689 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
685 if cfg.LlamaCPPConfig != nil {690 if cfg.LlamaCPPConfig != nil {
686 baseURL, _, err := cfg.LlamaCPPConfig()691 baseURL, _, err := cfg.LlamaCPPConfig()
@@ -3561,6 +3566,12 @@ func (i *Interactive) openSettingsDialog() {
3561 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",3566 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
3562 value: recursiveFiles,3567 value: recursiveFiles,
3563 },3568 },
3569+ {
3570+ key: "fuzzy_skill_suggest",
3571+ label: "fuzzy skill suggestions",
3572+ desc: "rank /skill: suggestions by name subsequence matches and emphasize matching characters",
3573+ value: i.cfg.FuzzySkillSuggest != nil && *i.cfg.FuzzySkillSuggest,
3574+ },
3564 {3575 {
3565 key: "respect_gitignore",3576 key: "respect_gitignore",
3566 label: "hide gitignored files in @-picker",3577 label: "hide gitignored files in @-picker",
@@ -3940,6 +3951,21 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
3940 i.statusOK = "OpenRouter server tools " + onOff(value)3951 i.statusOK = "OpenRouter server tools " + onOff(value)
3941 i.statusErr = ""3952 i.statusErr = ""
3942 i.mu.Unlock()3953 i.mu.Unlock()
3954+ case "fuzzy_skill_suggest":
3955+ if i.cfg.SettingsStore != nil {
3956+ if err := i.cfg.SettingsStore.SetFuzzySkillSuggest(value); err != nil {
3957+ i.mu.Lock()
3958+ i.statusErr = "settings: " + err.Error()
3959+ i.mu.Unlock()
3960+ return
3961+ }
3962+ }
3963+ i.cfg.FuzzySkillSuggest = &value
3964+ i.suggest.SetFuzzySkills(value)
3965+ i.mu.Lock()
3966+ i.statusOK = "fuzzy skill suggestions " + onOff(value)
3967+ i.statusErr = ""
3968+ i.mu.Unlock()
3943 case "recursive_file_suggest":3969 case "recursive_file_suggest":
3944 val := value3970 val := value
3945 i.cfg.RecursiveFileSuggest = &val3971 i.cfg.RecursiveFileSuggest = &val
M packages/agent/modes/slash_suggest.go +76 -9
@@ -5,6 +5,7 @@ import (
55 "strings"
66
77 "github.com/mattn/go-runewidth"
8+ "github.com/sahilm/fuzzy"
89
910 "github.com/patriceckhart/zot/packages/agent/skills"
1011 "github.com/patriceckhart/zot/packages/tui"
@@ -14,9 +15,10 @@ import (
1415 // (group dividers like "── extensions ───") are real entries
1516 // flagged with header=true; they render but aren't navigable.
1617 type slashCommand struct {
17- Name string // with leading "/"
18- Desc string
19- Header bool // true = visual divider, not selectable
18+ Name string // with leading "/"
19+ Desc string
20+ Header bool // true = visual divider, not selectable
21+ matchedIndexes []int // byte offsets in Name for fuzzy skill matches
2022 }
2123
2224 // slashCancelsTurn reports whether the named slash command, when run
@@ -88,6 +90,7 @@ type slashSuggester struct {
8890 // interactive mode can refresh discovery once instead of every frame.
8991 skills []slashCommand
9092 skillInputActive bool
93+ fuzzySkills bool
9194
9295 // lastMatches is the list shown in the most recent Render call.
9396 // Up/Down read it so they know which indexes to skip across
@@ -143,6 +146,47 @@ func (s *slashSuggester) SkillInputStarted(input string) bool {
143146 return started
144147 }
145148
149+// SetFuzzySkills changes skill matching without affecting other commands.
150+func (s *slashSuggester) SetFuzzySkills(enabled bool) {
151+ if s.fuzzySkills != enabled {
152+ s.fuzzySkills = enabled
153+ s.Reset()
154+ s.lastMatches = nil
155+ }
156+}
157+
158+// fuzzySkillMatches ranks names only, breaking score ties by the index in
159+// the alphabetically sorted catalog.
160+func (s *slashSuggester) fuzzySkillMatches(query string) []slashCommand {
161+ if query == "" {
162+ return s.skills
163+ }
164+ names := make([]string, len(s.skills))
165+ for i, c := range s.skills {
166+ names[i] = strings.TrimPrefix(c.Name, skillCommandPrefix)
167+ }
168+ ranked := fuzzy.Find(query, names)
169+ sort.Slice(ranked, func(i, j int) bool {
170+ if ranked[i].Score == ranked[j].Score {
171+ return ranked[i].Index < ranked[j].Index
172+ }
173+ return ranked[i].Score > ranked[j].Score
174+ })
175+ var out []slashCommand
176+ for _, match := range ranked {
177+ c := s.skills[match.Index]
178+ c.matchedIndexes = make([]int, len(match.MatchedIndexes))
179+ for i, index := range match.MatchedIndexes {
180+ c.matchedIndexes[i] = len(skillCommandPrefix) + index
181+ }
182+ if strings.EqualFold(names[match.Index], query) {
183+ return []slashCommand{c}
184+ }
185+ out = append(out, c)
186+ }
187+ return out
188+}
189+
146190 // SetJailed updates the current sandbox state. Called once per render
147191 // so state-dependent commands can appear/disappear immediately.
148192 func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed }
@@ -273,8 +317,9 @@ var hiddenSlashCommands = []string{
273317
274318 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }
275319
276-// matches returns the commands whose name has input as a prefix.
277-// If input is just "/", everything is shown. The /session command exposes
320+// matches uses name prefixes by default, or ranked name subsequences for
321+// skills when explicitly enabled. If input is just "/", everything is shown.
322+// The /session command exposes
278323 // its actions after the first space.
279324 func (s *slashSuggester) matches(input string) []slashCommand {
280325 if input == "" || !strings.HasPrefix(input, "/") {
@@ -296,6 +341,9 @@ func (s *slashSuggester) matches(input string) []slashCommand {
296341 catalog := s.allCatalog()
297342 if strings.HasPrefix(matchPrefix, skillCommandPrefix) {
298343 catalog = s.skills
344+ if s.fuzzySkills {
345+ return s.fuzzySkillMatches(input[len(skillCommandPrefix):])
346+ }
299347 }
300348 var out []slashCommand
301349 for _, c := range catalog {
@@ -486,7 +534,7 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
486534 if c.Header {
487535 continue
488536 }
489- if n := len(c.Name); n > nameWidth {
537+ if n := runewidth.StringWidth(c.Name); n > nameWidth {
490538 nameWidth = n
491539 }
492540 }
@@ -508,9 +556,9 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
508556 lines = append(lines, "")
509557 continue
510558 }
511- name := c.Name
512- if len(name) < nameWidth {
513- name = name + strings.Repeat(" ", nameWidth-len(name))
559+ name := highlightedSlashName(c)
560+ if n := runewidth.StringWidth(c.Name); n < nameWidth {
561+ name += strings.Repeat(" ", nameWidth-n)
514562 }
515563 plain := " " + name + " " + c.Desc
516564 if i == s.cursor {
@@ -529,6 +577,25 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
529577 return lines
530578 }
531579
580+// highlightedSlashName uses emphasis without resetting the row's foreground
581+// or selection background. Match offsets are UTF-8 byte indexes.
582+func highlightedSlashName(c slashCommand) string {
583+ if len(c.matchedIndexes) == 0 {
584+ return c.Name
585+ }
586+ var out strings.Builder
587+ match := 0
588+ for index, r := range c.Name {
589+ text := string(r)
590+ if match < len(c.matchedIndexes) && index == c.matchedIndexes[match] {
591+ text = tui.Bold(text)
592+ match++
593+ }
594+ out.WriteString(text)
595+ }
596+ return out.String()
597+}
598+
532599 // Reset puts the cursor back to the first match. Call this whenever the
533600 // input changes in a way that reshapes the match list.
534601 func (s *slashSuggester) Reset() { s.cursor = 0 }
@@ -5,6 +5,7 @@ import (
5 "strings"5 "strings"
6 6
7 "github.com/mattn/go-runewidth"7 "github.com/mattn/go-runewidth"
8+ "github.com/sahilm/fuzzy"
8 9
9 "github.com/patriceckhart/zot/packages/agent/skills"10 "github.com/patriceckhart/zot/packages/agent/skills"
10 "github.com/patriceckhart/zot/packages/tui"11 "github.com/patriceckhart/zot/packages/tui"
@@ -14,9 +15,10 @@ import (
14 // (group dividers like "── extensions ───") are real entries15 // (group dividers like "── extensions ───") are real entries
15 // flagged with header=true; they render but aren't navigable.16 // flagged with header=true; they render but aren't navigable.
16 type slashCommand struct {17 type slashCommand struct {
17- Name string // with leading "/"18+ Name string // with leading "/"
18- Desc string19+ Desc string
19- Header bool // true = visual divider, not selectable20+ Header bool // true = visual divider, not selectable
21+ matchedIndexes []int // byte offsets in Name for fuzzy skill matches
20 }22 }
21 23
22 // slashCancelsTurn reports whether the named slash command, when run24 // slashCancelsTurn reports whether the named slash command, when run
@@ -88,6 +90,7 @@ type slashSuggester struct {
88 // interactive mode can refresh discovery once instead of every frame.90 // interactive mode can refresh discovery once instead of every frame.
89 skills []slashCommand91 skills []slashCommand
90 skillInputActive bool92 skillInputActive bool
93+ fuzzySkills bool
91 94
92 // lastMatches is the list shown in the most recent Render call.95 // lastMatches is the list shown in the most recent Render call.
93 // Up/Down read it so they know which indexes to skip across96 // Up/Down read it so they know which indexes to skip across
@@ -143,6 +146,47 @@ func (s *slashSuggester) SkillInputStarted(input string) bool {
143 return started146 return started
144 }147 }
145 148
149+// SetFuzzySkills changes skill matching without affecting other commands.
150+func (s *slashSuggester) SetFuzzySkills(enabled bool) {
151+ if s.fuzzySkills != enabled {
152+ s.fuzzySkills = enabled
153+ s.Reset()
154+ s.lastMatches = nil
155+ }
156+}
157+
158+// fuzzySkillMatches ranks names only, breaking score ties by the index in
159+// the alphabetically sorted catalog.
160+func (s *slashSuggester) fuzzySkillMatches(query string) []slashCommand {
161+ if query == "" {
162+ return s.skills
163+ }
164+ names := make([]string, len(s.skills))
165+ for i, c := range s.skills {
166+ names[i] = strings.TrimPrefix(c.Name, skillCommandPrefix)
167+ }
168+ ranked := fuzzy.Find(query, names)
169+ sort.Slice(ranked, func(i, j int) bool {
170+ if ranked[i].Score == ranked[j].Score {
171+ return ranked[i].Index < ranked[j].Index
172+ }
173+ return ranked[i].Score > ranked[j].Score
174+ })
175+ var out []slashCommand
176+ for _, match := range ranked {
177+ c := s.skills[match.Index]
178+ c.matchedIndexes = make([]int, len(match.MatchedIndexes))
179+ for i, index := range match.MatchedIndexes {
180+ c.matchedIndexes[i] = len(skillCommandPrefix) + index
181+ }
182+ if strings.EqualFold(names[match.Index], query) {
183+ return []slashCommand{c}
184+ }
185+ out = append(out, c)
186+ }
187+ return out
188+}
189+
146 // SetJailed updates the current sandbox state. Called once per render190 // SetJailed updates the current sandbox state. Called once per render
147 // so state-dependent commands can appear/disappear immediately.191 // so state-dependent commands can appear/disappear immediately.
148 func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed }192 func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed }
@@ -273,8 +317,9 @@ var hiddenSlashCommands = []string{
273 317
274 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }318 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }
275 319
276-// matches returns the commands whose name has input as a prefix.320+// matches uses name prefixes by default, or ranked name subsequences for
277-// If input is just "/", everything is shown. The /session command exposes321+// skills when explicitly enabled. If input is just "/", everything is shown.
322+// The /session command exposes
278 // its actions after the first space.323 // its actions after the first space.
279 func (s *slashSuggester) matches(input string) []slashCommand {324 func (s *slashSuggester) matches(input string) []slashCommand {
280 if input == "" || !strings.HasPrefix(input, "/") {325 if input == "" || !strings.HasPrefix(input, "/") {
@@ -296,6 +341,9 @@ func (s *slashSuggester) matches(input string) []slashCommand {
296 catalog := s.allCatalog()341 catalog := s.allCatalog()
297 if strings.HasPrefix(matchPrefix, skillCommandPrefix) {342 if strings.HasPrefix(matchPrefix, skillCommandPrefix) {
298 catalog = s.skills343 catalog = s.skills
344+ if s.fuzzySkills {
345+ return s.fuzzySkillMatches(input[len(skillCommandPrefix):])
346+ }
299 }347 }
300 var out []slashCommand348 var out []slashCommand
301 for _, c := range catalog {349 for _, c := range catalog {
@@ -486,7 +534,7 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
486 if c.Header {534 if c.Header {
487 continue535 continue
488 }536 }
489- if n := len(c.Name); n > nameWidth {537+ if n := runewidth.StringWidth(c.Name); n > nameWidth {
490 nameWidth = n538 nameWidth = n
491 }539 }
492 }540 }
@@ -508,9 +556,9 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
508 lines = append(lines, "")556 lines = append(lines, "")
509 continue557 continue
510 }558 }
511- name := c.Name559+ name := highlightedSlashName(c)
512- if len(name) < nameWidth {560+ if n := runewidth.StringWidth(c.Name); n < nameWidth {
513- name = name + strings.Repeat(" ", nameWidth-len(name))561+ name += strings.Repeat(" ", nameWidth-n)
514 }562 }
515 plain := " " + name + " " + c.Desc563 plain := " " + name + " " + c.Desc
516 if i == s.cursor {564 if i == s.cursor {
@@ -529,6 +577,25 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
529 return lines577 return lines
530 }578 }
531 579
580+// highlightedSlashName uses emphasis without resetting the row's foreground
581+// or selection background. Match offsets are UTF-8 byte indexes.
582+func highlightedSlashName(c slashCommand) string {
583+ if len(c.matchedIndexes) == 0 {
584+ return c.Name
585+ }
586+ var out strings.Builder
587+ match := 0
588+ for index, r := range c.Name {
589+ text := string(r)
590+ if match < len(c.matchedIndexes) && index == c.matchedIndexes[match] {
591+ text = tui.Bold(text)
592+ match++
593+ }
594+ out.WriteString(text)
595+ }
596+ return out.String()
597+}
598+
532 // Reset puts the cursor back to the first match. Call this whenever the599 // Reset puts the cursor back to the first match. Call this whenever the
533 // input changes in a way that reshapes the match list.600 // input changes in a way that reshapes the match list.
534 func (s *slashSuggester) Reset() { s.cursor = 0 }601 func (s *slashSuggester) Reset() { s.cursor = 0 }
M packages/agent/settings_store.go +9 -0
@@ -95,6 +95,15 @@ func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
9595 return SaveConfig(cfg)
9696 }
9797
98+func (configSettingsStore) SetFuzzySkillSuggest(enabled bool) error {
99+ cfg, err := LoadConfig()
100+ if err != nil {
101+ return err
102+ }
103+ cfg.FuzzySkillSuggest = &enabled
104+ return SaveConfig(cfg)
105+}
106+
98107 func (configSettingsStore) SetRespectGitignore(enabled bool) error {
99108 cfg, err := LoadConfig()
100109 if err != nil {
@@ -95,6 +95,15 @@ func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
95 return SaveConfig(cfg)95 return SaveConfig(cfg)
96 }96 }
97 97
98+func (configSettingsStore) SetFuzzySkillSuggest(enabled bool) error {
99+ cfg, err := LoadConfig()
100+ if err != nil {
101+ return err
102+ }
103+ cfg.FuzzySkillSuggest = &enabled
104+ return SaveConfig(cfg)
105+}
106+
98 func (configSettingsStore) SetRespectGitignore(enabled bool) error {107 func (configSettingsStore) SetRespectGitignore(enabled bool) error {
99 cfg, err := LoadConfig()108 cfg, err := LoadConfig()
100 if err != nil {109 if err != nil {
M packages/agent/settings_store_test.go +29 -0
@@ -2,6 +2,35 @@ package agent
22
33 import "testing"
44
5+func TestConfigSettingsStorePersistsFuzzySkillSuggest(t *testing.T) {
6+ t.Setenv("ZOT_HOME", t.TempDir())
7+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
8+ t.Fatal(err)
9+ }
10+ cfg, err := LoadConfig()
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ if cfg.FuzzySkillSuggest != nil {
15+ t.Fatal("missing preference should remain unset")
16+ }
17+ for _, enabled := range []bool{true, false} {
18+ if err := (configSettingsStore{}).SetFuzzySkillSuggest(enabled); err != nil {
19+ t.Fatal(err)
20+ }
21+ cfg, err := LoadConfig()
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ if cfg.FuzzySkillSuggest == nil || *cfg.FuzzySkillSuggest != enabled {
26+ t.Fatal("preference not persisted")
27+ }
28+ if cfg.Theme != "dark" {
29+ t.Fatal("unrelated preference changed")
30+ }
31+ }
32+}
33+
534 func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {
635 t.Setenv("ZOT_HOME", t.TempDir())
736 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
@@ -2,6 +2,35 @@ package agent
2 2
3 import "testing"3 import "testing"
4 4
5+func TestConfigSettingsStorePersistsFuzzySkillSuggest(t *testing.T) {
6+ t.Setenv("ZOT_HOME", t.TempDir())
7+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
8+ t.Fatal(err)
9+ }
10+ cfg, err := LoadConfig()
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ if cfg.FuzzySkillSuggest != nil {
15+ t.Fatal("missing preference should remain unset")
16+ }
17+ for _, enabled := range []bool{true, false} {
18+ if err := (configSettingsStore{}).SetFuzzySkillSuggest(enabled); err != nil {
19+ t.Fatal(err)
20+ }
21+ cfg, err := LoadConfig()
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ if cfg.FuzzySkillSuggest == nil || *cfg.FuzzySkillSuggest != enabled {
26+ t.Fatal("preference not persisted")
27+ }
28+ if cfg.Theme != "dark" {
29+ t.Fatal("unrelated preference changed")
30+ }
31+ }
32+}
33+
5 func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {34 func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {
6 t.Setenv("ZOT_HOME", t.TempDir())35 t.Setenv("ZOT_HOME", t.TempDir())
7 if err := SaveConfig(Config{Theme: "dark"}); err != nil {36 if err := SaveConfig(Config{Theme: "dark"}); err != nil {

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