v0.3.2 6af4e0f

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	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	// Only surface models the user can actually reach: a provider is
52	// shown only when it has a resolvable credential (api key, oauth
53	// subscription, or the always-available ollama). An empty
54	// loggedInProviders list therefore yields an empty picker rather
55	// than dumping the entire ~900-model catalog.
56	provSet := map[string]bool{}
57	for _, p := range loggedInProviders {
58		provSet[p] = true
59	}
60	var filtered []provider.Model
61	for _, m := range provider.Active() {
62		if provSet[m.Provider] {
63			filtered = append(filtered, m)
64		}
65	}
66	d.all = sortedModels(filtered)
67	d.current = current
68	d.query = ""
69	d.provW, d.idW = columnWidths(d.all)
70	d.refilter()
71}
72
73// Close hides the dialog.
74func (d *modelDialog) Close() { d.active = false }
75
76// Active reports whether the dialog is visible and consumes input.
77func (d *modelDialog) Active() bool { return d != nil && d.active }
78
79// refilter rebuilds view from all according to query, and snaps the
80// cursor to either the current model (if visible) or the first row.
81func (d *modelDialog) refilter() {
82	needle := normalizeModelQuery(d.query)
83	if needle == "" {
84		d.view = append([]provider.Model(nil), d.all...)
85	} else {
86		out := make([]provider.Model, 0, len(d.all))
87		for _, m := range d.all {
88			if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
89				out = append(out, m)
90			}
91		}
92		d.view = out
93	}
94	d.cursor = 0
95	for i, m := range d.view {
96		if m.ID == d.current {
97			d.cursor = i
98			break
99		}
100	}
101}
102
103// sortedModels returns a fresh slice sorted by provider, then model id.
104func sortedModels(in []provider.Model) []provider.Model {
105	out := append([]provider.Model(nil), in...)
106	sort.SliceStable(out, func(i, j int) bool {
107		if out[i].Provider != out[j].Provider {
108			return out[i].Provider < out[j].Provider
109		}
110		return out[i].ID < out[j].ID
111	})
112	return out
113}
114
115// normalizeModelQuery lowercases and strips punctuation so fuzzy
116// substring matching works on both the query and haystacks. "opus46"
117// and "opus-4-6" both become "opus46".
118func normalizeModelQuery(s string) string {
119	var sb strings.Builder
120	sb.Grow(len(s))
121	for _, r := range s {
122		switch {
123		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
124			sb.WriteRune(r)
125		case r >= 'A' && r <= 'Z':
126			sb.WriteRune(r + ('a' - 'A'))
127		}
128	}
129	return sb.String()
130}
131
132// Render returns the dialog lines.
133func (d *modelDialog) Render(th tui.Theme, width int) []string {
134	if !d.Active() {
135		return nil
136	}
137	var lines []string
138	lines = append(lines, frameHeader(th, "model", width))
139
140	hint := "pick a model (↑/↓, enter, esc to cancel)"
141	if d.query != "" {
142		hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
143		if len(d.view) != 1 {
144			hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
145		}
146	} else {
147		hint += " - type to filter"
148	}
149	lines = append(lines, th.FG256(th.Muted, hint))
150
151	if len(d.view) == 0 {
152		msg := "  no models match " + fmt.Sprintf("%q", d.query)
153		if len(d.all) == 0 {
154			msg = "  no credentials found - run /login to add an api key or subscription"
155		}
156		lines = append(lines, th.FG256(th.Muted, msg))
157		lines = append(lines, frameRule(th, width))
158		return lines
159	}
160
161	// Scroll window so very tall catalogs still fit in a short tui.
162	const visible = 14
163	start := 0
164	end := len(d.view)
165	if end > visible {
166		start = d.cursor - visible/2
167		if start < 0 {
168			start = 0
169		}
170		if start+visible > end {
171			start = end - visible
172		}
173		end = start + visible
174	}
175
176	// Column widths were computed once on Open() across the full catalog
177	// (d.provW / d.idW). Reusing them keeps the layout rock-stable while
178	// scrolling and filtering — recomputing per visible window made the
179	// columns visibly jitter.
180	provW, idW := d.provW, d.idW
181	for i := start; i < end; i++ {
182		m := d.view[i]
183		reason := " "
184		if m.Reasoning {
185			reason = "✦"
186		}
187		tag := ""
188		switch {
189		case m.Speculative:
190			tag = "[speculative] "
191		case m.Source == "live":
192			tag = "[live] "
193		}
194		curMark := "  "
195		if m.ID == d.current {
196			curMark = "● "
197		}
198		plain := fmt.Sprintf(" %s%s   %s %s  %s%s",
199			curMark,
200			padRight(m.Provider, provW),
201			padRight(m.ID, idW),
202			reason, tag, m.DisplayName)
203		if i == d.cursor {
204			lines = append(lines, th.PadHighlight(plain, width))
205		} else {
206			lines = append(lines, th.FG256(th.Muted, plain))
207		}
208	}
209
210	if start > 0 {
211		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("   ... %d more above", start)))
212	}
213	if end < len(d.view) {
214		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("   ... %d more below", len(d.view)-end)))
215	}
216	if len(d.view) > visible {
217		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("   (%d/%d)", d.cursor+1, len(d.view))))
218	}
219
220	lines = append(lines, frameRule(th, width))
221	return lines
222}
223
224// columnWidths returns the display-cell width of the longest provider
225// name and the longest model id in rows. Each column is clamped to a
226// minimum so a single-row filter still renders sensibly.
227func columnWidths(rows []provider.Model) (provW, idW int) {
228	const minProv, minID = 6, 12
229	provW, idW = minProv, minID
230	for _, m := range rows {
231		if w := runewidth.StringWidth(m.Provider); w > provW {
232			provW = w
233		}
234		if w := runewidth.StringWidth(m.ID); w > idW {
235			idW = w
236		}
237	}
238	return
239}
240
241// padRight returns s padded with spaces on the right so its display
242// width equals w. Truncates with an ellipsis when s is too wide to
243// avoid blowing out the column.
244func padRight(s string, w int) string {
245	cw := runewidth.StringWidth(s)
246	if cw == w {
247		return s
248	}
249	if cw < w {
250		return s + strings.Repeat(" ", w-cw)
251	}
252	return runewidth.Truncate(s, w, "…")
253}
254
255// HandleKey advances the dialog and returns an action to apply, if any.
256func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
257	switch k.Kind {
258	case tui.KeyUp:
259		if d.cursor > 0 {
260			d.cursor--
261		}
262	case tui.KeyDown:
263		if d.cursor < len(d.view)-1 {
264			d.cursor++
265		}
266	case tui.KeyPageUp:
267		if len(d.view) > 0 {
268			d.cursor -= 14
269			if d.cursor < 0 {
270				d.cursor = 0
271			}
272		}
273	case tui.KeyPageDown:
274		if len(d.view) > 0 {
275			d.cursor += 14
276			if d.cursor >= len(d.view) {
277				d.cursor = len(d.view) - 1
278			}
279		}
280	case tui.KeyBackspace:
281		if len(d.query) > 0 {
282			// Drop one rune from the query.
283			r := []rune(d.query)
284			d.query = string(r[:len(r)-1])
285			d.refilter()
286		}
287	case tui.KeyRune:
288		if k.Alt || k.Ctrl {
289			break
290		}
291		// Only printable ASCII is useful for narrowing.
292		if k.Rune >= 0x20 && k.Rune < 0x7f {
293			d.query += string(k.Rune)
294			d.refilter()
295		}
296	case tui.KeyEsc:
297		d.Close()
298		return modelDialogAction{Close: true}
299	case tui.KeyEnter:
300		if len(d.view) == 0 {
301			d.Close()
302			return modelDialogAction{Close: true}
303		}
304		m := d.view[d.cursor]
305		d.Close()
306		return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
307	}
308	return modelDialogAction{}
309}

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