1package modes
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/patriceckhart/zot/internal/provider"
9 "github.com/patriceckhart/zot/internal/tui"
10)
11
12// modelDialog is an inline picker for choosing the active model.
13// It lists all models known to the provider package (baked-in catalog
14// + any live entries discovered via /v1/models) sorted by provider
15// then model id, and lets the user pick one with arrow keys + enter.
16// Typing characters narrows the list via a fuzzy substring match that
17// ignores punctuation (e.g. "opus46" matches "claude-opus-4-6").
18type modelDialog struct {
19 active bool
20 all []provider.Model // full catalog, sorted
21 view []provider.Model // filtered view shown to the user
22 cursor int
23 current string // currently selected model id (highlighted)
24 query string // live filter text typed by the user
25}
26
27// modelDialogAction is returned by HandleKey.
28type modelDialogAction struct {
29 Select bool
30 Provider string
31 Model string
32 Close bool
33}
34
35func newModelDialog() *modelDialog {
36 return &modelDialog{}
37}
38
39// Open shows the dialog. current is the currently active model id so
40// it can be pre-selected.
41func (d *modelDialog) Open(current string, loggedInProviders []string) {
42 d.active = true
43 all := provider.Active()
44 if len(loggedInProviders) > 0 {
45 provSet := map[string]bool{}
46 for _, p := range loggedInProviders {
47 provSet[p] = true
48 }
49 var filtered []provider.Model
50 for _, m := range all {
51 if provSet[m.Provider] {
52 filtered = append(filtered, m)
53 }
54 }
55 all = filtered
56 }
57 d.all = sortedModels(all)
58 d.current = current
59 d.query = ""
60 d.refilter()
61}
62
63// Close hides the dialog.
64func (d *modelDialog) Close() { d.active = false }
65
66// Active reports whether the dialog is visible and consumes input.
67func (d *modelDialog) Active() bool { return d != nil && d.active }
68
69// refilter rebuilds view from all according to query, and snaps the
70// cursor to either the current model (if visible) or the first row.
71func (d *modelDialog) refilter() {
72 needle := normalizeModelQuery(d.query)
73 if needle == "" {
74 d.view = append([]provider.Model(nil), d.all...)
75 } else {
76 out := make([]provider.Model, 0, len(d.all))
77 for _, m := range d.all {
78 if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
79 out = append(out, m)
80 }
81 }
82 d.view = out
83 }
84 d.cursor = 0
85 for i, m := range d.view {
86 if m.ID == d.current {
87 d.cursor = i
88 break
89 }
90 }
91}
92
93// sortedModels returns a fresh slice sorted by provider, then model id.
94func sortedModels(in []provider.Model) []provider.Model {
95 out := append([]provider.Model(nil), in...)
96 sort.SliceStable(out, func(i, j int) bool {
97 if out[i].Provider != out[j].Provider {
98 return out[i].Provider < out[j].Provider
99 }
100 return out[i].ID < out[j].ID
101 })
102 return out
103}
104
105// normalizeModelQuery lowercases and strips punctuation so fuzzy
106// substring matching works on both the query and haystacks. "opus46"
107// and "opus-4-6" both become "opus46".
108func normalizeModelQuery(s string) string {
109 var sb strings.Builder
110 sb.Grow(len(s))
111 for _, r := range s {
112 switch {
113 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
114 sb.WriteRune(r)
115 case r >= 'A' && r <= 'Z':
116 sb.WriteRune(r + ('a' - 'A'))
117 }
118 }
119 return sb.String()
120}
121
122// Render returns the dialog lines.
123func (d *modelDialog) Render(th tui.Theme, width int) []string {
124 if !d.Active() {
125 return nil
126 }
127 var lines []string
128 lines = append(lines, frameHeader(th, "model", width))
129
130 hint := "pick a model (↑/↓, enter, esc to cancel)"
131 if d.query != "" {
132 hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
133 if len(d.view) != 1 {
134 hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
135 }
136 } else {
137 hint += " - type to filter"
138 }
139 lines = append(lines, th.FG256(th.Muted, hint))
140
141 if len(d.view) == 0 {
142 lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
143 lines = append(lines, frameRule(th, width))
144 return lines
145 }
146
147 // Scroll window so very tall catalogs still fit in a short tui.
148 const visible = 14
149 start := 0
150 end := len(d.view)
151 if end > visible {
152 start = d.cursor - visible/2
153 if start < 0 {
154 start = 0
155 }
156 if start+visible > end {
157 start = end - visible
158 }
159 end = start + visible
160 }
161
162 for i := start; i < end; i++ {
163 m := d.view[i]
164 prov := m.Provider
165 id := m.ID
166 reason := " "
167 if m.Reasoning {
168 reason = "✦"
169 }
170 name := m.DisplayName
171 tag := ""
172 switch {
173 case m.Speculative:
174 tag = "[speculative] "
175 case m.Source == "live":
176 tag = "[live] "
177 }
178 curMark := " "
179 if m.ID == d.current {
180 curMark = "● "
181 }
182 plain := fmt.Sprintf(" %s%-10s %-28s %s %s%s", curMark, prov, id, reason, tag, name)
183 if i == d.cursor {
184 lines = append(lines, th.PadHighlight(plain, width))
185 } else {
186 lines = append(lines, th.FG256(th.Muted, plain))
187 }
188 }
189
190 if start > 0 {
191 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
192 }
193 if end < len(d.view) {
194 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
195 }
196
197 lines = append(lines, frameRule(th, width))
198 return lines
199}
200
201// HandleKey advances the dialog and returns an action to apply, if any.
202func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
203 switch k.Kind {
204 case tui.KeyUp:
205 if d.cursor > 0 {
206 d.cursor--
207 }
208 case tui.KeyDown:
209 if d.cursor < len(d.view)-1 {
210 d.cursor++
211 }
212 case tui.KeyBackspace:
213 if len(d.query) > 0 {
214 // Drop one rune from the query.
215 r := []rune(d.query)
216 d.query = string(r[:len(r)-1])
217 d.refilter()
218 }
219 case tui.KeyRune:
220 if k.Alt || k.Ctrl {
221 break
222 }
223 // Only printable ASCII is useful for narrowing.
224 if k.Rune >= 0x20 && k.Rune < 0x7f {
225 d.query += string(k.Rune)
226 d.refilter()
227 }
228 case tui.KeyEsc:
229 d.Close()
230 return modelDialogAction{Close: true}
231 case tui.KeyEnter:
232 if len(d.view) == 0 {
233 d.Close()
234 return modelDialogAction{Close: true}
235 }
236 m := d.view[d.cursor]
237 d.Close()
238 return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
239 }
240 return modelDialogAction{}
241}