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