1package modes
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/bnema/zut/packages/provider"
9 "github.com/bnema/zut/packages/tui"
10 "github.com/mattn/go-runewidth"
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".
125func normalizeModelQuery(s string) string {
126 var sb strings.Builder
127 sb.Grow(len(s))
128 for _, r := range s {
129 switch {
130 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
131 sb.WriteRune(r)
132 case r >= 'A' && r <= 'Z':
133 sb.WriteRune(r + ('a' - 'A'))
134 }
135 }
136 return sb.String()
137}
138
139// Render returns the dialog lines.
140func (d *modelDialog) Render(th tui.Theme, width int) []string {
141 if !d.Active() {
142 return nil
143 }
144 var lines []string
145 lines = append(lines, frameHeader(th, "model", width))
146
147 hint := "pick a model (↑/↓, enter, esc to cancel)"
148 if d.query != "" {
149 hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
150 if len(d.view) != 1 {
151 hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
152 }
153 } else {
154 hint += " - type to filter"
155 }
156 lines = append(lines, th.FGColor(th.Muted, hint))
157 if d.showReasoning {
158 reasoning := d.reasoning
159 if reasoning == "" {
160 reasoning = "off"
161 }
162 lines = append(lines, th.FGColor(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning"))
163 }
164
165 if len(d.view) == 0 {
166 msg := " no models match " + fmt.Sprintf("%q", d.query)
167 if len(d.all) == 0 {
168 msg = " no credentials found - run /login to add an api key or subscription"
169 }
170 lines = append(lines, th.FGColor(th.Muted, msg))
171 lines = append(lines, frameRule(th, width))
172 return lines
173 }
174
175 // Scroll window so very tall catalogs still fit in a short tui.
176 const visible = 14
177 start := 0
178 end := len(d.view)
179 if end > visible {
180 start = d.cursor - visible/2
181 if start < 0 {
182 start = 0
183 }
184 if start+visible > end {
185 start = end - visible
186 }
187 end = start + visible
188 }
189
190 // Column widths were computed once on Open() across the full catalog
191 // (d.provW / d.idW). Reusing them keeps the layout rock-stable while
192 // scrolling and filtering — recomputing per visible window made the
193 // columns visibly jitter.
194 provW, idW := d.provW, d.idW
195 for i := start; i < end; i++ {
196 m := d.view[i]
197 reason := " "
198 if m.Reasoning {
199 reason = "✦"
200 }
201 tag := ""
202 switch {
203 case m.Speculative:
204 tag = "[speculative] "
205 case m.Source == "live":
206 tag = "[live] "
207 }
208 curMark := " "
209 if m.ID == d.current {
210 curMark = "● "
211 }
212 plain := fmt.Sprintf(" %s%s %s %s %s%s",
213 curMark,
214 padRight(m.Provider, provW),
215 padRight(m.ID, idW),
216 reason, tag, m.DisplayName)
217 if i == d.cursor {
218 lines = append(lines, th.PadHighlight(plain, width))
219 } else {
220 lines = append(lines, th.FGColor(th.Muted, plain))
221 }
222 }
223
224 if start > 0 {
225 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more above", start)))
226 }
227 if end < len(d.view) {
228 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
229 }
230 if len(d.view) > visible {
231 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
232 }
233
234 lines = append(lines, frameRule(th, width))
235 return lines
236}
237
238// columnWidths returns the display-cell width of the longest provider
239// name and the longest model id in rows. Each column is clamped to a
240// minimum so a single-row filter still renders sensibly.
241func columnWidths(rows []provider.Model) (provW, idW int) {
242 const minProv, minID = 6, 12
243 provW, idW = minProv, minID
244 for _, m := range rows {
245 if w := runewidth.StringWidth(m.Provider); w > provW {
246 provW = w
247 }
248 if w := runewidth.StringWidth(m.ID); w > idW {
249 idW = w
250 }
251 }
252 return
253}
254
255// padRight returns s padded with spaces on the right so its display
256// width equals w. Truncates with an ellipsis when s is too wide to
257// avoid blowing out the column.
258func padRight(s string, w int) string {
259 cw := runewidth.StringWidth(s)
260 if cw == w {
261 return s
262 }
263 if cw < w {
264 return s + strings.Repeat(" ", w-cw)
265 }
266 return runewidth.Truncate(s, w, "…")
267}
268
269// HandleKey advances the dialog and returns an action to apply, if any.
270func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
271 switch k.Kind {
272 case tui.KeyUp:
273 if d.cursor > 0 {
274 d.cursor--
275 }
276 case tui.KeyDown:
277 if d.cursor < len(d.view)-1 {
278 d.cursor++
279 }
280 case tui.KeyPageUp:
281 if len(d.view) > 0 {
282 d.cursor -= 14
283 if d.cursor < 0 {
284 d.cursor = 0
285 }
286 }
287 case tui.KeyPageDown:
288 if len(d.view) > 0 {
289 d.cursor += 14
290 if d.cursor >= len(d.view) {
291 d.cursor = len(d.view) - 1
292 }
293 }
294 case tui.KeyBackspace:
295 if len(d.query) > 0 {
296 // Drop one rune from the query.
297 r := []rune(d.query)
298 d.query = string(r[:len(r)-1])
299 d.refilter()
300 }
301 case tui.KeyPaste:
302 d.query += singleLinePaste(k.Paste)
303 d.refilter()
304 case tui.KeyRune:
305 if k.Alt || k.Ctrl {
306 break
307 }
308 // Only printable ASCII is useful for narrowing.
309 if k.Rune >= 0x20 && k.Rune < 0x7f {
310 d.query += string(k.Rune)
311 d.refilter()
312 }
313 case tui.KeyEsc:
314 d.Close()
315 return modelDialogAction{Close: true}
316 case tui.KeyEnter:
317 if len(d.view) == 0 {
318 d.Close()
319 return modelDialogAction{Close: true}
320 }
321 m := d.view[d.cursor]
322 d.Close()
323 return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
324 }
325 return modelDialogAction{}
326}