Skip to content

Commit fe27014

Browse files
committed
feat: use shared fzf model search
Extract model-choice filtering and ranking into a shared package backed by the existing fzf matcher. Search across the display name, provider, model ID, and reference while preserving the configured order for an empty query. Update the full TUI model picker to use the shared search so other interfaces can provide identical matching and ranking without duplicating the implementation. Signed-off-by: Djordje Lukic <[email protected]>
1 parent 28f1558 commit fe27014

4 files changed

Lines changed: 105 additions & 22 deletions

File tree

pkg/modelpicker/search.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package modelpicker
2+
3+
import (
4+
"sort"
5+
"strings"
6+
7+
"github.com/junegunn/fzf/src/algo"
8+
"github.com/junegunn/fzf/src/util"
9+
10+
"github.com/docker/docker-agent/pkg/runtime"
11+
)
12+
13+
type scoredChoice struct {
14+
choice runtime.ModelChoice
15+
score int
16+
}
17+
18+
// Score returns fzf's match score for a model choice and query.
19+
func Score(choice runtime.ModelChoice, query string) (int, bool) {
20+
query = strings.TrimSpace(query)
21+
if query == "" {
22+
return 0, true
23+
}
24+
25+
chars := util.ToChars([]byte(choice.Name + " " + choice.Provider + " " + choice.Model + " " + choice.Ref))
26+
result, _ := algo.FuzzyMatchV1(
27+
false,
28+
false,
29+
true,
30+
&chars,
31+
[]rune(strings.ToLower(query)),
32+
true,
33+
nil,
34+
)
35+
return result.Score, result.Start >= 0
36+
}
37+
38+
// Filter returns matching choices ranked by fzf score. An empty query keeps
39+
// the input order.
40+
func Filter(choices []runtime.ModelChoice, query string) []runtime.ModelChoice {
41+
if strings.TrimSpace(query) == "" {
42+
return append([]runtime.ModelChoice(nil), choices...)
43+
}
44+
45+
matches := make([]scoredChoice, 0, len(choices))
46+
for _, choice := range choices {
47+
if score, ok := Score(choice, query); ok {
48+
matches = append(matches, scoredChoice{choice: choice, score: score})
49+
}
50+
}
51+
sort.SliceStable(matches, func(i, j int) bool {
52+
return matches[i].score > matches[j].score
53+
})
54+
55+
result := make([]runtime.ModelChoice, len(matches))
56+
for i, match := range matches {
57+
result[i] = match.choice
58+
}
59+
return result
60+
}

pkg/modelpicker/search_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package modelpicker
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/docker-agent/pkg/runtime"
10+
)
11+
12+
func TestFilterUsesFuzzyModelMetadataSearch(t *testing.T) {
13+
t.Parallel()
14+
15+
choices := []runtime.ModelChoice{
16+
{Name: "Claude Sonnet", Ref: "sonnet", Provider: "anthropic", Model: "claude-sonnet-4-6"},
17+
{Name: "GPT Five", Ref: "gpt", Provider: "openai", Model: "gpt-5"},
18+
}
19+
20+
matches := Filter(choices, "cldsn46")
21+
require.Len(t, matches, 1)
22+
assert.Equal(t, "sonnet", matches[0].Ref)
23+
}
24+
25+
func TestFilterEmptyQueryPreservesOrder(t *testing.T) {
26+
t.Parallel()
27+
28+
choices := []runtime.ModelChoice{{Ref: "second"}, {Ref: "first"}}
29+
assert.Equal(t, choices, Filter(choices, ""))
30+
}

pkg/tui/dialog/model_picker.go

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
tea "charm.land/bubbletea/v2"
1515
"charm.land/lipgloss/v2"
1616

17+
"github.com/docker/docker-agent/pkg/modelpicker"
1718
"github.com/docker/docker-agent/pkg/runtime"
1819
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
1920
"github.com/docker/docker-agent/pkg/tui/core"
@@ -324,22 +325,13 @@ func (d *modelPickerDialog) filterModels() {
324325
// If query contains "/", show "Custom" option as well as matches
325326
isCustomQuery := strings.Contains(query, "/")
326327

327-
d.filtered = d.filtered[:0]
328-
for _, model := range d.models {
329-
if query == "" {
330-
d.filtered = append(d.filtered, model)
331-
continue
332-
}
333-
334-
// Match against name, provider, and model
335-
searchText := strings.ToLower(model.Name + " " + model.Provider + " " + model.Model)
336-
if strings.Contains(searchText, query) {
337-
d.filtered = append(d.filtered, model)
338-
}
339-
}
328+
d.filtered = modelpicker.Filter(d.models, query)
340329

341-
// If query looks like a custom model spec and we have no exact match, show it as an option
342-
if isCustomQuery && len(d.filtered) == 0 {
330+
// Keep an explicit provider/model option alongside fuzzy matches unless
331+
// the query already identifies an available choice.
332+
if isCustomQuery && !slices.ContainsFunc(d.models, func(model runtime.ModelChoice) bool {
333+
return strings.EqualFold(model.Ref, query) || strings.EqualFold(model.Provider+"/"+model.Model, query)
334+
}) {
343335
d.filtered = append(d.filtered, runtime.ModelChoice{
344336
Name: "Custom: " + query,
345337
Ref: query,

pkg/tui/dialog/model_picker_test.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ func TestModelPickerFiltering(t *testing.T) {
8080
d.Update(tea.KeyPressMsg{Text: string(ch)})
8181
}
8282

83-
// Should now only show openai model
84-
require.Len(t, d.filtered, 1, "should have 1 model after filtering for 'openai'")
83+
// The exact provider match should rank above looser fuzzy matches.
84+
require.NotEmpty(t, d.filtered)
8585
require.Equal(t, "openai_model", d.filtered[0].Name)
8686

8787
// Selection should be reset to 0
@@ -92,7 +92,7 @@ func TestModelPickerCustomModel(t *testing.T) {
9292
t.Parallel()
9393

9494
models := []runtime.ModelChoice{
95-
{Name: "default_model", Ref: "default_model", Provider: "openai", Model: "gpt-4o", IsDefault: true},
95+
{Name: "default_model", Ref: "openai/gpt-4o", Provider: "openai", Model: "gpt-4o", IsDefault: true},
9696
}
9797

9898
dialog := NewModelPickerDialog(models)
@@ -105,10 +105,11 @@ func TestModelPickerCustomModel(t *testing.T) {
105105
d.Update(tea.KeyPressMsg{Text: string(ch)})
106106
}
107107

108-
// Should show the custom model option since nothing matches
109-
require.Len(t, d.filtered, 1, "should have 1 item (custom option)")
110-
require.Equal(t, "Custom: openai/gpt-4", d.filtered[0].Name)
111-
require.Equal(t, "openai/gpt-4", d.filtered[0].Ref)
108+
// Keep fuzzy matches and append the explicit custom model option.
109+
require.GreaterOrEqual(t, len(d.filtered), 2)
110+
custom := d.filtered[len(d.filtered)-1]
111+
require.Equal(t, "Custom: openai/gpt-4", custom.Name)
112+
require.Equal(t, "openai/gpt-4", custom.Ref)
112113
}
113114

114115
func TestModelPickerRefreshShortcut(t *testing.T) {

0 commit comments

Comments
 (0)