v0.1.99 fb3e1af

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/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}

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 476

↑↓ moveenter openesc close