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