v0.38.0 58133ea

model_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"sort"
6	"strings"
7
8	"github.com/bnema/zut/packages/provider"
9	"github.com/bnema/zut/packages/tui"
10	"github.com/mattn/go-runewidth"
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	ReasoningChanged bool
43	Reasoning        string
44	Close            bool
45}
46
47func newModelDialog() *modelDialog {
48	return &modelDialog{}
49}
50
51// Open shows the dialog. current is the currently active model id so
52// it can be pre-selected.
53func (d *modelDialog) Open(current string, loggedInProviders []string, reasoning ...string) {
54	d.active = true
55	// Only surface models the user can actually reach: a provider is
56	// shown only when it has a resolvable credential (api key, oauth
57	// subscription, or the always-available ollama). An empty
58	// loggedInProviders list therefore yields an empty picker rather
59	// than dumping the entire ~900-model catalog.
60	provSet := map[string]bool{}
61	for _, p := range loggedInProviders {
62		provSet[p] = true
63	}
64	var filtered []provider.Model
65	for _, m := range provider.Active() {
66		if provSet[m.Provider] {
67			filtered = append(filtered, m)
68		}
69	}
70	d.all = sortedModels(filtered)
71	d.current = current
72	d.reasoning = ""
73	d.showReasoning = len(reasoning) > 0
74	if d.showReasoning {
75		d.reasoning = provider.NormalizeReasoning(reasoning[0])
76	}
77	d.query = ""
78	d.provW, d.idW = columnWidths(d.all)
79	d.refilter()
80}
81
82// Close hides the dialog.
83func (d *modelDialog) Close() { d.active = false }
84
85// Active reports whether the dialog is visible and consumes input.
86func (d *modelDialog) Active() bool { return d != nil && d.active }
87
88// refilter rebuilds view from all according to query, and snaps the
89// cursor to either the current model (if visible) or the first row.
90func (d *modelDialog) refilter() {
91	needle := normalizeModelQuery(d.query)
92	if needle == "" {
93		d.view = append([]provider.Model(nil), d.all...)
94	} else {
95		out := make([]provider.Model, 0, len(d.all))
96		for _, m := range d.all {
97			if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
98				out = append(out, m)
99			}
100		}
101		d.view = out
102	}
103	d.cursor = 0
104	for i, m := range d.view {
105		if m.ID == d.current {
106			d.cursor = i
107			break
108		}
109	}
110}
111
112// sortedModels returns a fresh slice sorted by provider, then model id.
113func sortedModels(in []provider.Model) []provider.Model {
114	out := append([]provider.Model(nil), in...)
115	sort.SliceStable(out, func(i, j int) bool {
116		if out[i].Provider != out[j].Provider {
117			return out[i].Provider < out[j].Provider
118		}
119		return out[i].ID < out[j].ID
120	})
121	return out
122}
123
124// normalizeModelQuery lowercases and strips punctuation so fuzzy
125// substring matching works on both the query and haystacks. "opus46"
126// and "opus-4-6" both become "opus46".
127func normalizeModelQuery(s string) string {
128	var sb strings.Builder
129	sb.Grow(len(s))
130	for _, r := range s {
131		switch {
132		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
133			sb.WriteRune(r)
134		case r >= 'A' && r <= 'Z':
135			sb.WriteRune(r + ('a' - 'A'))
136		}
137	}
138	return sb.String()
139}
140
141// Render returns the dialog lines.
142func (d *modelDialog) Render(th tui.Theme, width int) []string {
143	if !d.Active() {
144		return nil
145	}
146	var lines []string
147	lines = append(lines, frameHeader(th, "model", width))
148
149	hint := "pick a model (↑/↓, enter, esc to cancel)"
150	if d.query != "" {
151		hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
152		if len(d.view) != 1 {
153			hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
154		}
155	} else {
156		hint += " - type to filter"
157	}
158	lines = append(lines, th.FGColor(th.Muted, hint))
159	if d.showReasoning {
160		reasoning, adjustable := d.cursorReasoning()
161		if reasoning == "" {
162			reasoning = "off"
163		}
164		help := "  unavailable for highlighted model"
165		if adjustable {
166			help = "  ←/→ or H/L to change"
167		}
168		lines = append(lines,
169			th.FGColor(th.Muted, "✦ reasoning: ")+th.FGColor(reasoningLevelColor(th, reasoning), reasoning)+th.FGColor(th.Muted, help),
170		)
171	}
172
173	if len(d.view) == 0 {
174		msg := "  no models match " + fmt.Sprintf("%q", d.query)
175		if len(d.all) == 0 {
176			msg = "  no credentials found - run /login to add an api key or subscription"
177		}
178		lines = append(lines, th.FGColor(th.Muted, msg))
179		lines = append(lines, frameRule(th, width))
180		return lines
181	}
182
183	// Scroll window so very tall catalogs still fit in a short tui.
184	const visible = 14
185	start := 0
186	end := len(d.view)
187	if end > visible {
188		start = d.cursor - visible/2
189		if start < 0 {
190			start = 0
191		}
192		if start+visible > end {
193			start = end - visible
194		}
195		end = start + visible
196	}
197
198	// Column widths were computed once on Open() across the full catalog
199	// (d.provW / d.idW). Reusing them keeps the layout rock-stable while
200	// scrolling and filtering — recomputing per visible window made the
201	// columns visibly jitter.
202	provW, idW := d.provW, d.idW
203	for i := start; i < end; i++ {
204		m := d.view[i]
205		reason := " "
206		if m.Reasoning {
207			reason = "✦"
208		}
209		tag := ""
210		switch {
211		case m.Speculative:
212			tag = "[speculative] "
213		case m.Source == "live":
214			tag = "[live] "
215		}
216		curMark := "  "
217		if m.ID == d.current {
218			curMark = "● "
219		}
220		plain := fmt.Sprintf(" %s%s   %s %s  %s%s",
221			curMark,
222			padRight(m.Provider, provW),
223			padRight(m.ID, idW),
224			reason, tag, m.DisplayName)
225		if i == d.cursor {
226			lines = append(lines, th.PadHighlight(plain, width))
227		} else {
228			lines = append(lines, th.FGColor(th.Muted, plain))
229		}
230	}
231
232	if start > 0 {
233		lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf("   ... %d more above", start)))
234	}
235	if end < len(d.view) {
236		lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf("   ... %d more below", len(d.view)-end)))
237	}
238	if len(d.view) > visible {
239		lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf("   (%d/%d)", d.cursor+1, len(d.view))))
240	}
241
242	lines = append(lines, frameRule(th, width))
243	return lines
244}
245
246// columnWidths returns the display-cell width of the longest provider
247// name and the longest model id in rows. Each column is clamped to a
248// minimum so a single-row filter still renders sensibly.
249func columnWidths(rows []provider.Model) (provW, idW int) {
250	const minProv, minID = 6, 12
251	provW, idW = minProv, minID
252	for _, m := range rows {
253		if w := runewidth.StringWidth(m.Provider); w > provW {
254			provW = w
255		}
256		if w := runewidth.StringWidth(m.ID); w > idW {
257			idW = w
258		}
259	}
260	return
261}
262
263// padRight returns s padded with spaces on the right so its display
264// width equals w. Truncates with an ellipsis when s is too wide to
265// avoid blowing out the column.
266func padRight(s string, w int) string {
267	cw := runewidth.StringWidth(s)
268	if cw == w {
269		return s
270	}
271	if cw < w {
272		return s + strings.Repeat(" ", w-cw)
273	}
274	return runewidth.Truncate(s, w, "…")
275}
276
277// reasoningLevelColor makes the active reasoning depth legible at a glance:
278// low levels use the theme accent, medium/high use the warning color, and the
279// most expensive levels use the explicit thinking-max color.
280func reasoningLevelColor(th tui.Theme, level string) tui.TerminalColor {
281	switch provider.NormalizeReasoning(level) {
282	case "minimum", "low":
283		return th.Accent
284	case "medium", "high":
285		return th.Warning
286	case "xhigh", "max":
287		return th.ThinkingMax
288	default:
289		return th.Muted
290	}
291}
292
293// cursorReasoning returns the effective level for the model under the cursor
294// and whether that model allows it to be adjusted.
295func (d *modelDialog) cursorReasoning() (string, bool) {
296	if len(d.view) == 0 || d.cursor < 0 || d.cursor >= len(d.view) {
297		return d.reasoning, false
298	}
299	model := d.view[d.cursor]
300	levels := provider.AvailableReasoningLevels(model)
301	return provider.ClampReasoningForModel(model, d.reasoning), len(levels) > 1
302}
303
304// stepReasoning moves through the levels supported by the model under the
305// cursor. It stops at each end rather than wrapping, matching arrow-key
306// expectations and preventing an accidental jump from max to off.
307func (d *modelDialog) stepReasoning(direction int) modelDialogAction {
308	if len(d.view) == 0 || d.cursor < 0 || d.cursor >= len(d.view) {
309		return modelDialogAction{}
310	}
311	model := d.view[d.cursor]
312	levels := provider.AvailableReasoningLevels(model)
313	current := provider.ClampReasoningForModel(model, d.reasoning)
314	idx := 0
315	for i, level := range levels {
316		if level == current {
317			idx = i
318			break
319		}
320	}
321	next := idx + direction
322	if next < 0 || next >= len(levels) {
323		return modelDialogAction{}
324	}
325	d.reasoning = levels[next]
326	return modelDialogAction{ReasoningChanged: true, Reasoning: d.reasoning}
327}
328
329// HandleKey advances the dialog and returns an action to apply, if any.
330func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
331	switch k.Kind {
332	case tui.KeyLeft:
333		if d.showReasoning {
334			return d.stepReasoning(-1)
335		}
336	case tui.KeyRight:
337		if d.showReasoning {
338			return d.stepReasoning(1)
339		}
340	case tui.KeyUp:
341		if d.cursor > 0 {
342			d.cursor--
343		}
344	case tui.KeyDown:
345		if d.cursor < len(d.view)-1 {
346			d.cursor++
347		}
348	case tui.KeyPageUp:
349		if len(d.view) > 0 {
350			d.cursor -= 14
351			if d.cursor < 0 {
352				d.cursor = 0
353			}
354		}
355	case tui.KeyPageDown:
356		if len(d.view) > 0 {
357			d.cursor += 14
358			if d.cursor >= len(d.view) {
359				d.cursor = len(d.view) - 1
360			}
361		}
362	case tui.KeyBackspace:
363		if len(d.query) > 0 {
364			// Drop one rune from the query.
365			r := []rune(d.query)
366			d.query = string(r[:len(r)-1])
367			d.refilter()
368		}
369	case tui.KeyPaste:
370		d.query += singleLinePaste(k.Paste)
371		d.refilter()
372	case tui.KeyRune:
373		if k.Alt || k.Ctrl {
374			break
375		}
376		if d.showReasoning {
377			switch k.Rune {
378			case 'H':
379				return d.stepReasoning(-1)
380			case 'L':
381				return d.stepReasoning(1)
382			}
383		}
384		// Only printable ASCII is useful for narrowing.
385		if k.Rune >= 0x20 && k.Rune < 0x7f {
386			d.query += string(k.Rune)
387			d.refilter()
388		}
389	case tui.KeyEsc:
390		d.Close()
391		return modelDialogAction{Close: true}
392	case tui.KeyEnter:
393		if len(d.view) == 0 {
394			d.Close()
395			return modelDialogAction{Close: true}
396		}
397		m := d.view[d.cursor]
398		d.Close()
399		return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
400	}
401	return modelDialogAction{}
402}

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