v0.1.21 b2fa0e3

model_dialog.go RawHistoryFolder

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, loggedInProviders []string) {
42	d.active = true
43	all := provider.Active()
44	if len(loggedInProviders) > 0 {
45		provSet := map[string]bool{}
46		for _, p := range loggedInProviders {
47			provSet[p] = true
48		}
49		var filtered []provider.Model
50		for _, m := range all {
51			if provSet[m.Provider] {
52				filtered = append(filtered, m)
53			}
54		}
55		all = filtered
56	}
57	d.all = sortedModels(all)
58	d.current = current
59	d.query = ""
60	d.refilter()
61}
62
63// Close hides the dialog.
64func (d *modelDialog) Close() { d.active = false }
65
66// Active reports whether the dialog is visible and consumes input.
67func (d *modelDialog) Active() bool { return d != nil && d.active }
68
69// refilter rebuilds view from all according to query, and snaps the
70// cursor to either the current model (if visible) or the first row.
71func (d *modelDialog) refilter() {
72	needle := normalizeModelQuery(d.query)
73	if needle == "" {
74		d.view = append([]provider.Model(nil), d.all...)
75	} else {
76		out := make([]provider.Model, 0, len(d.all))
77		for _, m := range d.all {
78			if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
79				out = append(out, m)
80			}
81		}
82		d.view = out
83	}
84	d.cursor = 0
85	for i, m := range d.view {
86		if m.ID == d.current {
87			d.cursor = i
88			break
89		}
90	}
91}
92
93// sortedModels returns a fresh slice sorted by provider, then model id.
94func sortedModels(in []provider.Model) []provider.Model {
95	out := append([]provider.Model(nil), in...)
96	sort.SliceStable(out, func(i, j int) bool {
97		if out[i].Provider != out[j].Provider {
98			return out[i].Provider < out[j].Provider
99		}
100		return out[i].ID < out[j].ID
101	})
102	return out
103}
104
105// normalizeModelQuery lowercases and strips punctuation so fuzzy
106// substring matching works on both the query and haystacks. "opus46"
107// and "opus-4-6" both become "opus46".
108func normalizeModelQuery(s string) string {
109	var sb strings.Builder
110	sb.Grow(len(s))
111	for _, r := range s {
112		switch {
113		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
114			sb.WriteRune(r)
115		case r >= 'A' && r <= 'Z':
116			sb.WriteRune(r + ('a' - 'A'))
117		}
118	}
119	return sb.String()
120}
121
122// Render returns the dialog lines.
123func (d *modelDialog) Render(th tui.Theme, width int) []string {
124	if !d.Active() {
125		return nil
126	}
127	var lines []string
128	lines = append(lines, frameHeader(th, "model", width))
129
130	hint := "pick a model (↑/↓, enter, esc to cancel)"
131	if d.query != "" {
132		hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
133		if len(d.view) != 1 {
134			hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
135		}
136	} else {
137		hint += " - type to filter"
138	}
139	lines = append(lines, th.FG256(th.Muted, hint))
140
141	if len(d.view) == 0 {
142		lines = append(lines, th.FG256(th.Muted, "  no models match "+fmt.Sprintf("%q", d.query)))
143		lines = append(lines, frameRule(th, width))
144		return lines
145	}
146
147	// Scroll window so very tall catalogs still fit in a short tui.
148	const visible = 14
149	start := 0
150	end := len(d.view)
151	if end > visible {
152		start = d.cursor - visible/2
153		if start < 0 {
154			start = 0
155		}
156		if start+visible > end {
157			start = end - visible
158		}
159		end = start + visible
160	}
161
162	for i := start; i < end; i++ {
163		m := d.view[i]
164		prov := m.Provider
165		id := m.ID
166		reason := " "
167		if m.Reasoning {
168			reason = "✦"
169		}
170		name := m.DisplayName
171		tag := ""
172		switch {
173		case m.Speculative:
174			tag = "[speculative] "
175		case m.Source == "live":
176			tag = "[live] "
177		}
178		curMark := "  "
179		if m.ID == d.current {
180			curMark = "● "
181		}
182		plain := fmt.Sprintf(" %s%-10s %-28s %s  %s%s", curMark, prov, id, reason, tag, name)
183		if i == d.cursor {
184			lines = append(lines, th.PadHighlight(plain, width))
185		} else {
186			lines = append(lines, th.FG256(th.Muted, plain))
187		}
188	}
189
190	if start > 0 {
191		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("   … %d more above", start)))
192	}
193	if end < len(d.view) {
194		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("   … %d more below", len(d.view)-end)))
195	}
196
197	lines = append(lines, frameRule(th, width))
198	return lines
199}
200
201// HandleKey advances the dialog and returns an action to apply, if any.
202func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
203	switch k.Kind {
204	case tui.KeyUp:
205		if d.cursor > 0 {
206			d.cursor--
207		}
208	case tui.KeyDown:
209		if d.cursor < len(d.view)-1 {
210			d.cursor++
211		}
212	case tui.KeyBackspace:
213		if len(d.query) > 0 {
214			// Drop one rune from the query.
215			r := []rune(d.query)
216			d.query = string(r[:len(r)-1])
217			d.refilter()
218		}
219	case tui.KeyRune:
220		if k.Alt || k.Ctrl {
221			break
222		}
223		// Only printable ASCII is useful for narrowing.
224		if k.Rune >= 0x20 && k.Rune < 0x7f {
225			d.query += string(k.Rune)
226			d.refilter()
227		}
228	case tui.KeyEsc:
229		d.Close()
230		return modelDialogAction{Close: true}
231	case tui.KeyEnter:
232		if len(d.view) == 0 {
233			d.Close()
234			return modelDialogAction{Close: true}
235		}
236		m := d.view[d.cursor]
237		d.Close()
238		return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
239	}
240	return modelDialogAction{}
241}

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