v0.3.48 9b7bb6a

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

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