v0.3.98 ff10c69

model_dialog.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close