v0.0.42 ac6d556

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) {
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}

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