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 ReasoningChanged bool
43 Reasoning string
44 Close bool
45}
46
47func newModelDialog() *modelDialog {
48 return &modelDialog{}
49}
50
51// Open shows the dialog. current is the currently active model id so
52// it can be pre-selected.
53func (d *modelDialog) Open(current string, loggedInProviders []string, reasoning ...string) {
54 d.active = true
55 // Only surface models the user can actually reach: a provider is
56 // shown only when it has a resolvable credential (api key, oauth
57 // subscription, or the always-available ollama). An empty
58 // loggedInProviders list therefore yields an empty picker rather
59 // than dumping the entire ~900-model catalog.
60 provSet := map[string]bool{}
61 for _, p := range loggedInProviders {
62 provSet[p] = true
63 }
64 var filtered []provider.Model
65 for _, m := range provider.Active() {
66 if provSet[m.Provider] {
67 filtered = append(filtered, m)
68 }
69 }
70 d.all = sortedModels(filtered)
71 d.current = current
72 d.reasoning = ""
73 d.showReasoning = len(reasoning) > 0
74 if d.showReasoning {
75 d.reasoning = provider.NormalizeReasoning(reasoning[0])
76 }
77 d.query = ""
78 d.provW, d.idW = columnWidths(d.all)
79 d.refilter()
80}
81
82// Close hides the dialog.
83func (d *modelDialog) Close() { d.active = false }
84
85// Active reports whether the dialog is visible and consumes input.
86func (d *modelDialog) Active() bool { return d != nil && d.active }
87
88// refilter rebuilds view from all according to query, and snaps the
89// cursor to either the current model (if visible) or the first row.
90func (d *modelDialog) refilter() {
91 needle := normalizeModelQuery(d.query)
92 if needle == "" {
93 d.view = append([]provider.Model(nil), d.all...)
94 } else {
95 out := make([]provider.Model, 0, len(d.all))
96 for _, m := range d.all {
97 if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
98 out = append(out, m)
99 }
100 }
101 d.view = out
102 }
103 d.cursor = 0
104 for i, m := range d.view {
105 if m.ID == d.current {
106 d.cursor = i
107 break
108 }
109 }
110}
111
112// sortedModels returns a fresh slice sorted by provider, then model id.
113func sortedModels(in []provider.Model) []provider.Model {
114 out := append([]provider.Model(nil), in...)
115 sort.SliceStable(out, func(i, j int) bool {
116 if out[i].Provider != out[j].Provider {
117 return out[i].Provider < out[j].Provider
118 }
119 return out[i].ID < out[j].ID
120 })
121 return out
122}
123
124// normalizeModelQuery lowercases and strips punctuation so fuzzy
125// substring matching works on both the query and haystacks. "opus46"
126// and "opus-4-6" both become "opus46".
127func normalizeModelQuery(s string) string {
128 var sb strings.Builder
129 sb.Grow(len(s))
130 for _, r := range s {
131 switch {
132 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
133 sb.WriteRune(r)
134 case r >= 'A' && r <= 'Z':
135 sb.WriteRune(r + ('a' - 'A'))
136 }
137 }
138 return sb.String()
139}
140
141// Render returns the dialog lines.
142func (d *modelDialog) Render(th tui.Theme, width int) []string {
143 if !d.Active() {
144 return nil
145 }
146 var lines []string
147 lines = append(lines, frameHeader(th, "model", width))
148
149 hint := "pick a model (↑/↓, enter, esc to cancel)"
150 if d.query != "" {
151 hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
152 if len(d.view) != 1 {
153 hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
154 }
155 } else {
156 hint += " - type to filter"
157 }
158 lines = append(lines, th.FGColor(th.Muted, hint))
159 if d.showReasoning {
160 reasoning, adjustable := d.cursorReasoning()
161 if reasoning == "" {
162 reasoning = "off"
163 }
164 help := " unavailable for highlighted model"
165 if adjustable {
166 help = " ←/→ or H/L to change"
167 }
168 lines = append(lines,
169 th.FGColor(th.Muted, "✦ reasoning: ")+th.FGColor(reasoningLevelColor(th, reasoning), reasoning)+th.FGColor(th.Muted, help),
170 )
171 }
172
173 if len(d.view) == 0 {
174 msg := " no models match " + fmt.Sprintf("%q", d.query)
175 if len(d.all) == 0 {
176 msg = " no credentials found - run /login to add an api key or subscription"
177 }
178 lines = append(lines, th.FGColor(th.Muted, msg))
179 lines = append(lines, frameRule(th, width))
180 return lines
181 }
182
183 // Scroll window so very tall catalogs still fit in a short tui.
184 const visible = 14
185 start := 0
186 end := len(d.view)
187 if end > visible {
188 start = d.cursor - visible/2
189 if start < 0 {
190 start = 0
191 }
192 if start+visible > end {
193 start = end - visible
194 }
195 end = start + visible
196 }
197
198 // Column widths were computed once on Open() across the full catalog
199 // (d.provW / d.idW). Reusing them keeps the layout rock-stable while
200 // scrolling and filtering — recomputing per visible window made the
201 // columns visibly jitter.
202 provW, idW := d.provW, d.idW
203 for i := start; i < end; i++ {
204 m := d.view[i]
205 reason := " "
206 if m.Reasoning {
207 reason = "✦"
208 }
209 tag := ""
210 switch {
211 case m.Speculative:
212 tag = "[speculative] "
213 case m.Source == "live":
214 tag = "[live] "
215 }
216 curMark := " "
217 if m.ID == d.current {
218 curMark = "● "
219 }
220 plain := fmt.Sprintf(" %s%s %s %s %s%s",
221 curMark,
222 padRight(m.Provider, provW),
223 padRight(m.ID, idW),
224 reason, tag, m.DisplayName)
225 if i == d.cursor {
226 lines = append(lines, th.PadHighlight(plain, width))
227 } else {
228 lines = append(lines, th.FGColor(th.Muted, plain))
229 }
230 }
231
232 if start > 0 {
233 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more above", start)))
234 }
235 if end < len(d.view) {
236 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end)))
237 }
238 if len(d.view) > visible {
239 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(d.view))))
240 }
241
242 lines = append(lines, frameRule(th, width))
243 return lines
244}
245
246// columnWidths returns the display-cell width of the longest provider
247// name and the longest model id in rows. Each column is clamped to a
248// minimum so a single-row filter still renders sensibly.
249func columnWidths(rows []provider.Model) (provW, idW int) {
250 const minProv, minID = 6, 12
251 provW, idW = minProv, minID
252 for _, m := range rows {
253 if w := runewidth.StringWidth(m.Provider); w > provW {
254 provW = w
255 }
256 if w := runewidth.StringWidth(m.ID); w > idW {
257 idW = w
258 }
259 }
260 return
261}
262
263// padRight returns s padded with spaces on the right so its display
264// width equals w. Truncates with an ellipsis when s is too wide to
265// avoid blowing out the column.
266func padRight(s string, w int) string {
267 cw := runewidth.StringWidth(s)
268 if cw == w {
269 return s
270 }
271 if cw < w {
272 return s + strings.Repeat(" ", w-cw)
273 }
274 return runewidth.Truncate(s, w, "…")
275}
276
277// reasoningLevelColor makes the active reasoning depth legible at a glance:
278// low levels use the theme accent, medium/high use the warning color, and the
279// most expensive levels use the explicit thinking-max color.
280func reasoningLevelColor(th tui.Theme, level string) tui.TerminalColor {
281 switch provider.NormalizeReasoning(level) {
282 case "minimum", "low":
283 return th.Accent
284 case "medium", "high":
285 return th.Warning
286 case "xhigh", "max":
287 return th.ThinkingMax
288 default:
289 return th.Muted
290 }
291}
292
293// cursorReasoning returns the effective level for the model under the cursor
294// and whether that model allows it to be adjusted.
295func (d *modelDialog) cursorReasoning() (string, bool) {
296 if len(d.view) == 0 || d.cursor < 0 || d.cursor >= len(d.view) {
297 return d.reasoning, false
298 }
299 model := d.view[d.cursor]
300 levels := provider.AvailableReasoningLevels(model)
301 return provider.ClampReasoningForModel(model, d.reasoning), len(levels) > 1
302}
303
304// stepReasoning moves through the levels supported by the model under the
305// cursor. It stops at each end rather than wrapping, matching arrow-key
306// expectations and preventing an accidental jump from max to off.
307func (d *modelDialog) stepReasoning(direction int) modelDialogAction {
308 if len(d.view) == 0 || d.cursor < 0 || d.cursor >= len(d.view) {
309 return modelDialogAction{}
310 }
311 model := d.view[d.cursor]
312 levels := provider.AvailableReasoningLevels(model)
313 current := provider.ClampReasoningForModel(model, d.reasoning)
314 idx := 0
315 for i, level := range levels {
316 if level == current {
317 idx = i
318 break
319 }
320 }
321 next := idx + direction
322 if next < 0 || next >= len(levels) {
323 return modelDialogAction{}
324 }
325 d.reasoning = levels[next]
326 return modelDialogAction{ReasoningChanged: true, Reasoning: d.reasoning}
327}
328
329// HandleKey advances the dialog and returns an action to apply, if any.
330func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
331 switch k.Kind {
332 case tui.KeyLeft:
333 if d.showReasoning {
334 return d.stepReasoning(-1)
335 }
336 case tui.KeyRight:
337 if d.showReasoning {
338 return d.stepReasoning(1)
339 }
340 case tui.KeyUp:
341 if d.cursor > 0 {
342 d.cursor--
343 }
344 case tui.KeyDown:
345 if d.cursor < len(d.view)-1 {
346 d.cursor++
347 }
348 case tui.KeyPageUp:
349 if len(d.view) > 0 {
350 d.cursor -= 14
351 if d.cursor < 0 {
352 d.cursor = 0
353 }
354 }
355 case tui.KeyPageDown:
356 if len(d.view) > 0 {
357 d.cursor += 14
358 if d.cursor >= len(d.view) {
359 d.cursor = len(d.view) - 1
360 }
361 }
362 case tui.KeyBackspace:
363 if len(d.query) > 0 {
364 // Drop one rune from the query.
365 r := []rune(d.query)
366 d.query = string(r[:len(r)-1])
367 d.refilter()
368 }
369 case tui.KeyPaste:
370 d.query += singleLinePaste(k.Paste)
371 d.refilter()
372 case tui.KeyRune:
373 if k.Alt || k.Ctrl {
374 break
375 }
376 if d.showReasoning {
377 switch k.Rune {
378 case 'H':
379 return d.stepReasoning(-1)
380 case 'L':
381 return d.stepReasoning(1)
382 }
383 }
384 // Only printable ASCII is useful for narrowing.
385 if k.Rune >= 0x20 && k.Rune < 0x7f {
386 d.query += string(k.Rune)
387 d.refilter()
388 }
389 case tui.KeyEsc:
390 d.Close()
391 return modelDialogAction{Close: true}
392 case tui.KeyEnter:
393 if len(d.view) == 0 {
394 d.Close()
395 return modelDialogAction{Close: true}
396 }
397 m := d.view[d.cursor]
398 d.Close()
399 return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
400 }
401 return modelDialogAction{}
402}