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 | |||
| 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 | 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 | 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 | 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`. | |
| @@ -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 | |||
| 99 | 99 | /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 | 116 | zot expands the command into a user message containing the complete skill | |
| 103 | 117 | body, its directory for resolving relative references, and any text following | |
| 104 | 118 | 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 issues | 99 | /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 skill | 116 | zot expands the command into a user message containing the complete skill | ||
| 103 | body, its directory for resolving relative references, and any text following | 117 | 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 { | |||
| 1328 | 1328 | NoTools: args.NoTools, | |
| 1329 | 1329 | QuickModelShortcuts: quickModelShortcuts, | |
| 1330 | 1330 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 1331 | + | FuzzySkillSuggest: initialCfg.FuzzySkillSuggest, | |
| 1331 | 1332 | RespectGitignore: initialCfg.RespectGitignore, | |
| 1332 | 1333 | CompactMode: initialCfg.CompactMode, | |
| 1333 | 1334 | 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 { | |||
| 87 | 87 | // directory browse. Toggle from /settings. | |
| 88 | 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 | 94 | // RespectGitignore controls whether the @-mention file picker hides | |
| 91 | 95 | // files and directories matched by the project's root .gitignore (in | |
| 92 | 96 | // 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 hides | 94 | // RespectGitignore controls whether the @-mention file picker hides | ||
| 91 | // files and directories matched by the project's root .gitignore (in | 95 | // 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 { | |||
| 95 | 95 | // whole project tree instead of browsing one directory at a time. | |
| 96 | 96 | RecursiveFileSuggest *bool | |
| 97 | 97 | ||
| 98 | + | // FuzzySkillSuggest opts into ranked skill-name subsequence suggestions. | |
| 99 | + | FuzzySkillSuggest *bool | |
| 100 | + | ||
| 98 | 101 | // RespectGitignore mirrors the persisted respect_gitignore flag at | |
| 99 | 102 | // startup. nil means the default (on); when false the @-mention | |
| 100 | 103 | // picker shows files matched by the project's root .gitignore. | |
| @@ -361,6 +364,7 @@ type SettingsStore interface { | |||
| 361 | 364 | SetJailByDefault(enabled bool) error | |
| 362 | 365 | SetOpenRouterServerTools(enabled bool) error | |
| 363 | 366 | SetRecursiveFileSuggest(enabled bool) error | |
| 367 | + | SetFuzzySkillSuggest(enabled bool) error | |
| 364 | 368 | SetRespectGitignore(enabled bool) error | |
| 365 | 369 | SetCompactMode(enabled bool) error | |
| 366 | 370 | SetCollapseToolCall(enabled bool) error | |
| @@ -681,6 +685,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 681 | 685 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| 682 | 686 | } | |
| 683 | 687 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 688 | + | i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest) | |
| 684 | 689 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| 685 | 690 | if cfg.LlamaCPPConfig != nil { | |
| 686 | 691 | baseURL, _, err := cfg.LlamaCPPConfig() | |
| @@ -3561,6 +3566,12 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3561 | 3566 | desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time", | |
| 3562 | 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 | 3576 | key: "respect_gitignore", | |
| 3566 | 3577 | label: "hide gitignored files in @-picker", | |
| @@ -3940,6 +3951,21 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 3940 | 3951 | i.statusOK = "OpenRouter server tools " + onOff(value) | |
| 3941 | 3952 | i.statusErr = "" | |
| 3942 | 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 | 3969 | case "recursive_file_suggest": | |
| 3944 | 3970 | val := value | |
| 3945 | 3971 | 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 *bool | 96 | 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 at | 101 | // RespectGitignore mirrors the persisted respect_gitignore flag at | ||
| 99 | // startup. nil means the default (on); when false the @-mention | 102 | // 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) error | 364 | SetJailByDefault(enabled bool) error | ||
| 362 | SetOpenRouterServerTools(enabled bool) error | 365 | SetOpenRouterServerTools(enabled bool) error | ||
| 363 | SetRecursiveFileSuggest(enabled bool) error | 366 | SetRecursiveFileSuggest(enabled bool) error | ||
| 367 | + | SetFuzzySkillSuggest(enabled bool) error | |||
| 364 | SetRespectGitignore(enabled bool) error | 368 | SetRespectGitignore(enabled bool) error | ||
| 365 | SetCompactMode(enabled bool) error | 369 | SetCompactMode(enabled bool) error | ||
| 366 | SetCollapseToolCall(enabled bool) error | 370 | 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 := value | 3970 | val := value | ||
| 3945 | i.cfg.RecursiveFileSuggest = &val | 3971 | i.cfg.RecursiveFileSuggest = &val | ||
M packages/agent/modes/slash_suggest.go +76 -9
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/mattn/go-runewidth" | |
| 8 | + | "github.com/sahilm/fuzzy" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 10 | 11 | "github.com/patriceckhart/zot/packages/tui" | |
| @@ -14,9 +15,10 @@ import ( | |||
| 14 | 15 | // (group dividers like "── extensions ───") are real entries | |
| 15 | 16 | // flagged with header=true; they render but aren't navigable. | |
| 16 | 17 | 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 | |
| 20 | 22 | } | |
| 21 | 23 | ||
| 22 | 24 | // slashCancelsTurn reports whether the named slash command, when run | |
| @@ -88,6 +90,7 @@ type slashSuggester struct { | |||
| 88 | 90 | // interactive mode can refresh discovery once instead of every frame. | |
| 89 | 91 | skills []slashCommand | |
| 90 | 92 | skillInputActive bool | |
| 93 | + | fuzzySkills bool | |
| 91 | 94 | ||
| 92 | 95 | // lastMatches is the list shown in the most recent Render call. | |
| 93 | 96 | // Up/Down read it so they know which indexes to skip across | |
| @@ -143,6 +146,47 @@ func (s *slashSuggester) SkillInputStarted(input string) bool { | |||
| 143 | 146 | 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 | 190 | // SetJailed updates the current sandbox state. Called once per render | |
| 147 | 191 | // so state-dependent commands can appear/disappear immediately. | |
| 148 | 192 | func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } | |
| @@ -273,8 +317,9 @@ var hiddenSlashCommands = []string{ | |||
| 273 | 317 | ||
| 274 | 318 | func newSlashSuggester() *slashSuggester { return &slashSuggester{} } | |
| 275 | 319 | ||
| 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 | |
| 278 | 323 | // its actions after the first space. | |
| 279 | 324 | func (s *slashSuggester) matches(input string) []slashCommand { | |
| 280 | 325 | if input == "" || !strings.HasPrefix(input, "/") { | |
| @@ -296,6 +341,9 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||
| 296 | 341 | catalog := s.allCatalog() | |
| 297 | 342 | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | |
| 298 | 343 | catalog = s.skills | |
| 344 | + | if s.fuzzySkills { | |
| 345 | + | return s.fuzzySkillMatches(input[len(skillCommandPrefix):]) | |
| 346 | + | } | |
| 299 | 347 | } | |
| 300 | 348 | var out []slashCommand | |
| 301 | 349 | for _, c := range catalog { | |
| @@ -486,7 +534,7 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string | |||
| 486 | 534 | if c.Header { | |
| 487 | 535 | continue | |
| 488 | 536 | } | |
| 489 | - | if n := len(c.Name); n > nameWidth { | |
| 537 | + | if n := runewidth.StringWidth(c.Name); n > nameWidth { | |
| 490 | 538 | nameWidth = n | |
| 491 | 539 | } | |
| 492 | 540 | } | |
| @@ -508,9 +556,9 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string | |||
| 508 | 556 | lines = append(lines, "") | |
| 509 | 557 | continue | |
| 510 | 558 | } | |
| 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) | |
| 514 | 562 | } | |
| 515 | 563 | plain := " " + name + " " + c.Desc | |
| 516 | 564 | if i == s.cursor { | |
| @@ -529,6 +577,25 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string | |||
| 529 | 577 | 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 | 599 | // Reset puts the cursor back to the first match. Call this whenever the | |
| 533 | 600 | // input changes in a way that reshapes the match list. | |
| 534 | 601 | 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 entries | 15 | // (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 string | 19 | + | Desc string |
| 19 | - | Header bool // true = visual divider, not selectable | 20 | + | 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 run | 24 | // 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 []slashCommand | 91 | skills []slashCommand | ||
| 90 | skillInputActive bool | 92 | 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 across | 96 | // 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 started | 146 | 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 render | 190 | // 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 exposes | 321 | + | // 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.skills | 343 | catalog = s.skills | ||
| 344 | + | if s.fuzzySkills { | |||
| 345 | + | return s.fuzzySkillMatches(input[len(skillCommandPrefix):]) | |||
| 346 | + | } | |||
| 299 | } | 347 | } | ||
| 300 | var out []slashCommand | 348 | 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 | continue | 535 | continue | ||
| 488 | } | 536 | } | ||
| 489 | - | if n := len(c.Name); n > nameWidth { | 537 | + | if n := runewidth.StringWidth(c.Name); n > nameWidth { |
| 490 | nameWidth = n | 538 | 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 | continue | 557 | continue | ||
| 510 | } | 558 | } | ||
| 511 | - | name := c.Name | 559 | + | 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.Desc | 563 | 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 lines | 577 | 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 the | 599 | // 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 { | |||
| 95 | 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 | 107 | func (configSettingsStore) SetRespectGitignore(enabled bool) error { | |
| 99 | 108 | cfg, err := LoadConfig() | |
| 100 | 109 | 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 | |||
| 2 | 2 | ||
| 3 | 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 | 34 | func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) { | |
| 6 | 35 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 7 | 36 | 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 { | ||