1package modes
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "github.com/patriceckhart/zot/internal/provider"
9 "github.com/patriceckhart/zot/internal/tui"
10)
11
12// modelDialog is an inline picker for choosing the active model.
13// It lists all models known to the provider package (baked-in catalog
14// + any live entries discovered via /v1/models) sorted by provider
15// then model id, and lets the user pick one with arrow keys + enter.
16// Typing characters narrows the list via a fuzzy substring match that
17// ignores punctuation (e.g. "opus46" matches "claude-opus-4-6").
18type modelDialog struct {
19 active bool
20 all []provider.Model // full catalog, sorted
21 view []provider.Model // filtered view shown to the user
22 cursor int
23 current string // currently selected model id (highlighted)
24 query string // live filter text typed by the user
25}
26
27// modelDialogAction is returned by HandleKey.
28type modelDialogAction struct {
29 Select bool
30 Provider string
31 Model string
32 Close bool
33}
34
35func newModelDialog() *modelDialog {
36 return &modelDialog{}
37}
38
39// Open shows the dialog. current is the currently active model id so
40// it can be pre-selected.
41func (d *modelDialog) Open(current string) {
42 d.active = true
43 d.all = sortedModels(provider.Active())
44 d.current = current
45 d.query = ""
46 d.refilter()
47}
48
49// Close hides the dialog.
50func (d *modelDialog) Close() { d.active = false }
51
52// Active reports whether the dialog is visible and consumes input.
53func (d *modelDialog) Active() bool { return d != nil && d.active }
54
55// refilter rebuilds view from all according to query, and snaps the
56// cursor to either the current model (if visible) or the first row.
57func (d *modelDialog) refilter() {
58 needle := normalizeModelQuery(d.query)
59 if needle == "" {
60 d.view = append([]provider.Model(nil), d.all...)
61 } else {
62 out := make([]provider.Model, 0, len(d.all))
63 for _, m := range d.all {
64 if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
65 out = append(out, m)
66 }
67 }
68 d.view = out
69 }
70 d.cursor = 0
71 for i, m := range d.view {
72 if m.ID == d.current {
73 d.cursor = i
74 break
75 }
76 }
77}
78
79// sortedModels returns a fresh slice sorted by provider, then model id.
80func sortedModels(in []provider.Model) []provider.Model {
81 out := append([]provider.Model(nil), in...)
82 sort.SliceStable(out, func(i, j int) bool {
83 if out[i].Provider != out[j].Provider {
84 return out[i].Provider < out[j].Provider
85 }
86 return out[i].ID < out[j].ID
87 })
88 return out
89}
90
91// normalizeModelQuery lowercases and strips punctuation so fuzzy
92// substring matching works on both the query and haystacks. "opus46"
93// and "opus-4-6" both become "opus46".
94func normalizeModelQuery(s string) string {
95 var sb strings.Builder
96 sb.Grow(len(s))
97 for _, r := range s {
98 switch {
99 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
100 sb.WriteRune(r)
101 case r >= 'A' && r <= 'Z':
102 sb.WriteRune(r + ('a' - 'A'))
103 }
104 }
105 return sb.String()
106}
107
108// Render returns the dialog lines.
109func (d *modelDialog) Render(th tui.Theme, width int) []string {
110 if !d.Active() {
111 return nil
112 }
113 var lines []string
114 lines = append(lines, frameHeader(th, "model", width))
115
116 hint := "pick a model (↑/↓, enter, esc to cancel)"
117 if d.query != "" {
118 hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
119 if len(d.view) != 1 {
120 hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
121 }
122 } else {
123 hint += " · type to filter"
124 }
125 lines = append(lines, th.FG256(th.Muted, hint))
126
127 if len(d.view) == 0 {
128 lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
129 lines = append(lines, frameRule(th, width))
130 return lines
131 }
132
133 // Scroll window so very tall catalogs still fit in a short tui.
134 const visible = 14
135 start := 0
136 end := len(d.view)
137 if end > visible {
138 start = d.cursor - visible/2
139 if start < 0 {
140 start = 0
141 }
142 if start+visible > end {
143 start = end - visible
144 }
145 end = start + visible
146 }
147
148 for i := start; i < end; i++ {
149 m := d.view[i]
150 prov := m.Provider
151 id := m.ID
152 reason := " "
153 if m.Reasoning {
154 reason = "✦"
155 }
156 name := m.DisplayName
157 tag := ""
158 switch {
159 case m.Speculative:
160 tag = "[speculative] "
161 case m.Source == "live":
162 tag = "[live] "
163 }
164 curMark := " "
165 if m.ID == d.current {
166 curMark = "● "
167 }
168 plain := fmt.Sprintf(" %s%-10s %-28s %s %s%s", curMark, prov, id, reason, tag, name)
169 if i == d.cursor {
170 lines = append(lines, th.PadHighlight(plain, width))
171 } else {
172 lines = append(lines, th.FG256(th.Muted, plain))
173 }
174 }
175
176 if start > 0 {
177 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
178 }
179 if end < len(d.view) {
180 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
181 }
182
183 lines = append(lines, frameRule(th, width))
184 return lines
185}
186
187// HandleKey advances the dialog and returns an action to apply, if any.
188func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
189 switch k.Kind {
190 case tui.KeyUp:
191 if d.cursor > 0 {
192 d.cursor--
193 }
194 case tui.KeyDown:
195 if d.cursor < len(d.view)-1 {
196 d.cursor++
197 }
198 case tui.KeyBackspace:
199 if len(d.query) > 0 {
200 // Drop one rune from the query.
201 r := []rune(d.query)
202 d.query = string(r[:len(r)-1])
203 d.refilter()
204 }
205 case tui.KeyRune:
206 if k.Alt || k.Ctrl {
207 break
208 }
209 // Only printable ASCII is useful for narrowing.
210 if k.Rune >= 0x20 && k.Rune < 0x7f {
211 d.query += string(k.Rune)
212 d.refilter()
213 }
214 case tui.KeyEsc:
215 d.Close()
216 return modelDialogAction{Close: true}
217 case tui.KeyEnter:
218 if len(d.view) == 0 {
219 d.Close()
220 return modelDialogAction{Close: true}
221 }
222 m := d.view[d.cursor]
223 d.Close()
224 return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
225 }
226 return modelDialogAction{}
227}