1package modes
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/mattn/go-runewidth"
9 "github.com/patriceckhart/zot/packages/provider"
10 "github.com/patriceckhart/zot/packages/tui"
11)
12
13// modelDialog is an inline picker for choosing the active model.
14// It lists all models known to the provider package (baked-in catalog
15// + any live entries discovered via /v1/models) sorted by provider
16// then model id, and lets the user pick one with arrow keys + enter.
17// Typing characters narrows the list via a fuzzy substring match that
18// ignores punctuation (e.g. "opus46" matches "claude-opus-4-6").
19type modelDialog struct {
20 active bool
21 all []provider.Model // full catalog, sorted
22 view []provider.Model // filtered view shown to the user
23 cursor int
24 current string // currently selected model id (highlighted)
25 reasoning string // current reasoning level, shown for discoverability
26 showReasoning bool // whether this picker can route to /reasoning
27 query string // live filter text typed by the user
28
29 // Column widths are computed once on Open() across the entire
30 // catalog so the layout stays stable while the user scrolls or
31 // filters. Recomputing per visible window would make the columns
32 // jitter left/right whenever rows entered or left the 14-row band.
33 provW int
34 idW int
35}
36
37// modelDialogAction is returned by HandleKey.
38type modelDialogAction struct {
39 Select bool
40 Provider string
41 Model string
42 Close bool
43}
44
45func newModelDialog() *modelDialog {
46 return &modelDialog{}
47}
48
49// Open shows the dialog. current is the currently active model id so
50// it can be pre-selected.
51func (d *modelDialog) Open(current string, loggedInProviders []string, reasoning ...string) {
52 d.active = true
53 // Only surface models the user can actually reach: a provider is
54 // shown only when it has a resolvable credential (api key, oauth
55 // subscription, or the always-available ollama). An empty
56 // loggedInProviders list therefore yields an empty picker rather
57 // than dumping the entire ~900-model catalog.
58 provSet := map[string]bool{}
59 for _, p := range loggedInProviders {
60 provSet[p] = true
61 }
62 var filtered []provider.Model
63 for _, m := range provider.Active() {
64 if provSet[m.Provider] {
65 filtered = append(filtered, m)
66 }
67 }
68 d.all = sortedModels(filtered)
69 d.current = current
70 d.reasoning = ""
71 d.showReasoning = len(reasoning) > 0
72 if d.showReasoning {
73 d.reasoning = provider.NormalizeReasoning(reasoning[0])
74 }
75 d.query = ""
76 d.provW, d.idW = columnWidths(d.all)
77 d.refilter()
78}
79
80// Close hides the dialog.
81func (d *modelDialog) Close() { d.active = false }
82
83// Active reports whether the dialog is visible and consumes input.
84func (d *modelDialog) Active() bool { return d != nil && d.active }
85
86// refilter rebuilds view from all according to query, and snaps the
87// cursor to either the current model (if visible) or the first row.
88func (d *modelDialog) refilter() {
89 needle := normalizeModelQuery(d.query)
90 if needle == "" {
91 d.view = append([]provider.Model(nil), d.all...)
92 } else {
93 out := make([]provider.Model, 0, len(d.all))
94 for _, m := range d.all {
95 if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
96 out = append(out, m)
97 }
98 }
99 d.view = out
100 }
101 d.cursor = 0
102 for i, m := range d.view {
103 if m.ID == d.current {
104 d.cursor = i
105 break
106 }
107 }
108}
109
110// sortedModels returns a fresh slice sorted by provider, then model id.
111func sortedModels(in []provider.Model) []provider.Model {
112 out := append([]provider.Model(nil), in...)
113 sort.SliceStable(out, func(i, j int) bool {
114 if out[i].Provider != out[j].Provider {
115 return out[i].Provider < out[j].Provider
116 }
117 return out[i].ID < out[j].ID
118 })
119 return out
120}
121
122// normalizeModelQuery lowercases and strips punctuation so fuzzy
123// substring matching works on both the query and haystacks. "opus46"
124// and "opus-4-6" both become "opus46". "@" is kept so typing "preset"
125// or "@preset" matches OpenRouter ids like "@preset/flash".
126func normalizeModelQuery(s string) string {
127 var sb strings.Builder
128 sb.Grow(len(s))
129 for _, r := range s {
130 switch {
131 case r == '@', r >= 'a' && r <= 'z', r >= '0' && r <= '9':
132 sb.WriteRune(r)
133 case r >= 'A' && r <= 'Z':
134 sb.WriteRune(r + ('a' - 'A'))
135 }
136 }
137 return sb.String()
138}
139
140// Render returns the dialog lines.
141func (d *modelDialog) Render(th tui.Theme, width int) []string {
142 if !d.Active() {
143 return nil
144 }
145 var lines []string
146 lines = append(lines, frameHeader(th, "model", width))
147
148 hint := "pick a model (↑/↓, enter, esc to cancel)"
149 if d.query != "" {
150 hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
151 if len(d.view) != 1 {
152 hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
153 }
154 } else {
155 hint += " - type to filter"
156 }
157 lines = append(lines, th.FG256(th.Muted, hint))
158 if d.showReasoning {
159 reasoning := d.reasoning
160 if reasoning == "" {
161 reasoning = "off"
162 }
163 lines = append(lines, th.FG256(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning"))
164 }
165
166 if len(d.view) == 0 {
167 msg := " no models match " + fmt.Sprintf("%q", d.query)
168 if len(d.all) == 0 {
169 msg = " no credentials found - run /login to add an api key or subscription"
170 }
171 lines = append(lines, th.FG256(th.Muted, msg))
172 lines = append(lines, frameRule(th, width))
173 return lines
174 }
175
176 // Scroll window so very tall catalogs still fit in a short tui.
177 const visible = 14
178 start := 0
179 end := len(d.view)
180 if end > visible {
181 start = d.cursor - visible/2
182 if start < 0 {
183 start = 0
184 }
185 if start+visible > end {
186 start = end - visible
187 }
188 end = start + visible
189 }
190
191 // Column widths were computed once on Open() across the full catalog
192 // (d.provW / d.idW). Reusing them keeps the layout rock-stable while
193 // scrolling and filtering — recomputing per visible window made the
194 // columns visibly jitter.
195 provW, idW := d.provW, d.idW
196 for i := start; i < end; i++ {
197 m := d.view[i]
198 reason := " "
199 if m.Reasoning {
200 reason = "✦"
201 }
202 tag := ""
203 switch {
204 case m.Speculative:
205 tag = "[speculative] "
206 case m.Source == "live":
207 tag = "[live] "
208 }
209 curMark := " "
210 if m.ID == d.current {
211 curMark = "● "
212 }
213 plain := fmt.Sprintf(" %s%s %s %s %s%s",
214 curMark,
215 padRight(m.Provider, provW),
216 padRight(m.ID, idW),
217 reason, tag, m.DisplayName)
218 if i == d.cursor {
219 lines = append(lines, th.PadHighlight(plain, width))
220 } else {
221 lines = append(lines, th.FG256(th.Muted, plain))
222 }
223 }
224
225 if start > 0 {
226 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more above", start)))
227 }
228 if end < len(d.view) {
229 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
230 }
231 if len(d.view) > visible {
232 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
233 }
234
235 lines = append(lines, frameRule(th, width))
236 return lines
237}
238
239// columnWidths returns the display-cell width of the longest provider
240// name and the longest model id in rows. Each column is clamped to a
241// minimum so a single-row filter still renders sensibly.
242func columnWidths(rows []provider.Model) (provW, idW int) {
243 const minProv, minID = 6, 12
244 provW, idW = minProv, minID
245 for _, m := range rows {
246 if w := runewidth.StringWidth(m.Provider); w > provW {
247 provW = w
248 }
249 if w := runewidth.StringWidth(m.ID); w > idW {
250 idW = w
251 }
252 }
253 return
254}
255
256// padRight returns s padded with spaces on the right so its display
257// width equals w. Truncates with an ellipsis when s is too wide to
258// avoid blowing out the column.
259func padRight(s string, w int) string {
260 cw := runewidth.StringWidth(s)
261 if cw == w {
262 return s
263 }
264 if cw < w {
265 return s + strings.Repeat(" ", w-cw)
266 }
267 return runewidth.Truncate(s, w, "…")
268}
269
270// HandleKey advances the dialog and returns an action to apply, if any.
271func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
272 switch k.Kind {
273 case tui.KeyUp:
274 if d.cursor > 0 {
275 d.cursor--
276 }
277 case tui.KeyDown:
278 if d.cursor < len(d.view)-1 {
279 d.cursor++
280 }
281 case tui.KeyPageUp:
282 if len(d.view) > 0 {
283 d.cursor -= 14
284 if d.cursor < 0 {
285 d.cursor = 0
286 }
287 }
288 case tui.KeyPageDown:
289 if len(d.view) > 0 {
290 d.cursor += 14
291 if d.cursor >= len(d.view) {
292 d.cursor = len(d.view) - 1
293 }
294 }
295 case tui.KeyBackspace:
296 if len(d.query) > 0 {
297 // Drop one rune from the query.
298 r := []rune(d.query)
299 d.query = string(r[:len(r)-1])
300 d.refilter()
301 }
302 case tui.KeyPaste:
303 d.query += singleLinePaste(k.Paste)
304 d.refilter()
305 case tui.KeyRune:
306 if k.Alt || k.Ctrl {
307 break
308 }
309 // Only printable ASCII is useful for narrowing.
310 if k.Rune >= 0x20 && k.Rune < 0x7f {
311 d.query += string(k.Rune)
312 d.refilter()
313 }
314 case tui.KeyEsc:
315 d.Close()
316 return modelDialogAction{Close: true}
317 case tui.KeyEnter:
318 if len(d.view) == 0 {
319 d.Close()
320 return modelDialogAction{Close: true}
321 }
322 m := d.view[d.cursor]
323 d.Close()
324 return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
325 }
326 return modelDialogAction{}
327}