okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: listView, confirm and one fuzzy matcher for list screens to share8101869

main
9.8 KiB360 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/config"
5	"albedo/cli/internal/daemon"
6	"context"
7	"errors"
8	"maps"
9	"slices"
10	"strings"
11
12	"charm.land/bubbles/v2/textinput"
13	tea "charm.land/bubbletea/v2"
14)
15
16// The /model picker lists every saved profile's models at once, grouped by
17// profile, and lets each row carry a reasoning effort before you switch.
18
19type ModelPickerSelectMsg struct {
20	// RaiseCap is set when you changed the model's context cap here: true
21	// raises it to the provider's maximum, false restores the default window.
22	RaiseCap *bool
23	CapKey   string
24	Model    string
25	Provider string
26	// Effort is the chosen reasoning level. Empty lets the daemon choose.
27	Effort string
28}
29
30type ModelPickerCancelMsg struct{}
31
32type modelCatalogLoadedMsg struct {
33	Generation int
34	Err        error
35	Profile    string
36	Models     []daemon.Model
37}
38
39// profileCatalog is one profile's models: the ones the session and the
40// profile already use, then whatever the daemon lists.
41type profileCatalog struct {
42	settings config.Settings
43	name     string
44	models   []daemon.Model
45	loading  bool
46	failed   bool
47}
48
49// modelRow is a row you can pick: a profile's model, or the search text sent
50// to a profile as a model id.
51type modelRow struct {
52	profile string
53	// hits are the byte offsets of the id's characters the search matched.
54	hits  []int
55	model daemon.Model
56	typed bool
57}
58
59func (r modelRow) key() string {
60	if r.typed {
61		return "typed\x00" + r.profile
62	}
63	return r.profile + "\x00" + r.model.ID
64}
65
66type ModelPickerModel struct {
67	Generation  int
68	readCtx     context.Context
69	cancelReads context.CancelFunc
70	Conn        *daemon.Connection
71	// caps are cap choices made here, by model id: the cap is per model, so
72	// the same model under two profiles shares it.
73	caps    map[string]bool
74	efforts map[string]string
75	// The session's selection when the picker opened.
76	Profile, Model, Effort string
77	Error                  string
78	rows                   []modelRow
79
80	catalogs      []profileCatalog
81	search        textinput.Model
82	Width, Height int
83	cursor        int
84	Saving        bool
85	// browsing is set once you move through the list, so arrows change the
86	// effort even while a search is typed.
87	browsing bool
88}
89
90func NewModelPickerModel(conn *daemon.Connection, profiles config.Profiles, model, profile, effort string) ModelPickerModel {
91	if profile == "" {
92		profile = profiles.Active
93	}
94	names := slices.Sorted(maps.Keys(profiles.Providers))
95	slices.SortStableFunc(names, func(a, b string) int {
96		if a == b {
97			return 0
98		}
99		if a == profile {
100			return -1
101		}
102		if b == profile {
103			return 1
104		}
105		return 0
106	})
107
108	catalogs := make([]profileCatalog, 0, len(names))
109	for _, name := range names {
110		settings := profiles.Providers[name]
111		seeds := []string{settings.Model}
112		if name == profile {
113			seeds = []string{model, settings.Model}
114		}
115		catalogs = append(catalogs, profileCatalog{
116			name:     name,
117			settings: settings,
118			loading:  true,
119			models:   mergeModels(seeds, nil),
120		})
121	}
122
123	search := newFilter("Search models or type a model ID")
124
125	readCtx, cancelReads := context.WithCancel(context.Background())
126	m := ModelPickerModel{
127		Generation:  nextPageGeneration(),
128		readCtx:     readCtx,
129		cancelReads: cancelReads,
130		Conn:        conn,
131		Profile:     profile,
132		Model:       model,
133		Effort:      effort,
134		catalogs:    catalogs,
135		efforts:     map[string]string{},
136		caps:        map[string]bool{},
137		search:      search,
138	}
139	m.refilter(true)
140	current := modelRow{profile: profile, model: daemon.Model{ID: model}}.key()
141	m.cursor = max(0, slices.IndexFunc(m.rows, func(r modelRow) bool { return r.key() == current }))
142	return m
143}
144
145func (m *ModelPickerModel) SetSize(width, height int) {
146	m.Width = width
147	m.Height = height
148	m.search.SetWidth(max(1, width-6))
149}
150
151func (m *ModelPickerModel) Close() {
152	if m.cancelReads != nil {
153		m.cancelReads()
154	}
155	m.Generation = nextPageGeneration()
156}
157
158func (m ModelPickerModel) Init() tea.Cmd {
159	cmds := []tea.Cmd{textinput.Blink}
160	for _, c := range m.catalogs {
161		cmds = append(cmds, m.listCmd(c))
162	}
163	return tea.Batch(cmds...)
164}
165
166func (m ModelPickerModel) listCmd(c profileCatalog) tea.Cmd {
167	conn, ctx, generation := m.Conn, m.readCtx, m.Generation
168	return func() tea.Msg {
169		if conn == nil {
170			return modelCatalogLoadedMsg{Generation: generation, Profile: c.name, Err: errors.New("daemon connection unavailable")}
171		}
172		models, err := daemon.ListProfileModels(ctx, conn, c.settings)
173		return modelCatalogLoadedMsg{Generation: generation, Profile: c.name, Models: models, Err: err}
174	}
175}
176
177func utf16Length(s string) int {
178	length := 0
179	for _, char := range s {
180		length++
181		if char > 0xffff {
182			length++
183		}
184	}
185	return length
186}
187
188func validateModelID(m string) error {
189	m = strings.TrimSpace(m)
190	if m == "" || utf16Length(m) > 512 || strings.ContainsFunc(m, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
191		return errors.New("enter a model id of 1–512 characters")
192	}
193	return nil
194}
195
196// searchKey folds case and separators, so "gpt.5" finds "gpt-5.1".
197const searchSeparators = " -_.:/"
198
199func (m ModelPickerModel) highlighted() (modelRow, bool) {
200	if m.cursor < 0 || m.cursor >= len(m.rows) {
201		return modelRow{}, false
202	}
203	return m.rows[m.cursor], true
204}
205
206func (m ModelPickerModel) catalog(profile string) profileCatalog {
207	if i := slices.IndexFunc(m.catalogs, func(c profileCatalog) bool { return c.name == profile }); i >= 0 {
208		return m.catalogs[i]
209	}
210	return profileCatalog{}
211}
212
213// effort is the level a row would switch with: your pick, else the session's
214// level when the model has it, else the daemon's default.
215func (m ModelPickerModel) effort(r modelRow) string {
216	levels := r.model.Efforts
217	switch {
218	case len(levels) == 0:
219		return ""
220	case slices.Contains(levels, m.efforts[r.key()]):
221		return m.efforts[r.key()]
222	case slices.Contains(levels, m.Effort):
223		return m.Effort
224	}
225	return defaultEffort(levels)
226}
227
228// defaultEffort mirrors the daemon: medium, else the first of high, xhigh or
229// max, else the highest level.
230func defaultEffort(levels []string) string {
231	if slices.Contains(levels, "medium") {
232		return "medium"
233	}
234	if i := slices.IndexFunc(levels, func(l string) bool { return l == "high" || l == "xhigh" || l == "max" }); i >= 0 {
235		return levels[i]
236	}
237	return levels[len(levels)-1]
238}
239
240// effortSteps are the levels that fill a square. None and off fill nothing.
241func effortSteps(levels []string) []string {
242	return slices.DeleteFunc(slices.Clone(levels), func(l string) bool { return l == "none" || l == "off" })
243}
244
245// stepEffort moves the highlighted row's effort one level, stopping at the
246// ends. It reports whether the row has levels to step through.
247func (m *ModelPickerModel) stepEffort(by int) bool {
248	r, ok := m.highlighted()
249	if !ok || len(r.model.Efforts) == 0 {
250		return false
251	}
252	levels := r.model.Efforts
253	i := slices.Index(levels, m.effort(r)) + by
254	m.efforts[r.key()] = levels[min(max(i, 0), len(levels)-1)]
255	return true
256}
257
258// raisable reports whether a model's provider offers a window past its default.
259func raisable(model daemon.Model) bool {
260	return model.MaxContext > model.Context && model.Context > 0
261}
262
263// raised is the cap a row would switch with: your choice here, else the saved one.
264func (m ModelPickerModel) raised(r modelRow) bool {
265	if raised, ok := m.caps[r.model.CapKey]; ok {
266		return raised
267	}
268	return r.model.Raised
269}
270
271// window is the context a row would switch with.
272func (m ModelPickerModel) window(r modelRow) int {
273	if raisable(r.model) && m.raised(r) {
274		return r.model.MaxContext
275	}
276	return r.model.Context
277}
278
279func (m *ModelPickerModel) move(by int) {
280	if len(m.rows) > 0 {
281		m.cursor = min(max(m.cursor+by, 0), len(m.rows)-1)
282		m.browsing = true
283	}
284}
285
286func (m ModelPickerModel) Update(msg tea.Msg) (ModelPickerModel, tea.Cmd) {
287	switch msg := msg.(type) {
288	case modelCatalogLoadedMsg:
289		if msg.Generation != m.Generation {
290			return m, nil
291		}
292		i := slices.IndexFunc(m.catalogs, func(c profileCatalog) bool { return c.name == msg.Profile })
293		if i < 0 {
294			return m, nil
295		}
296		m.catalogs = slices.Clone(m.catalogs)
297		c := &m.catalogs[i]
298		c.loading, c.failed = false, msg.Err != nil
299		seeds := make([]string, len(c.models))
300		for j, model := range c.models {
301			seeds[j] = model.ID
302		}
303		c.models = mergeModels(seeds, msg.Models)
304		m.refilter(false)
305		return m, nil
306
307	case tea.KeyPressMsg:
308		if m.Saving {
309			return m, nil
310		}
311		switch msg.String() {
312		case "esc", "ctrl+c", "ctrl+d":
313			return m, func() tea.Msg { return ModelPickerCancelMsg{} }
314		case "enter":
315			r, ok := m.highlighted()
316			if !ok {
317				return m, nil
318			}
319			if err := validateModelID(r.model.ID); err != nil {
320				m.Error = err.Error()
321				return m, nil
322			}
323			m.Error = ""
324			choice := ModelPickerSelectMsg{Model: r.model.ID, CapKey: r.model.CapKey, Provider: r.profile, Effort: m.effort(r)}
325			if raised, changed := m.caps[r.model.CapKey]; changed && raised != r.model.Raised {
326				choice.RaiseCap = &raised
327			}
328			return m, func() tea.Msg { return choice }
329		case "up", "ctrl+p", "down", "ctrl+n", "pgup", "pgdown":
330			m.move(map[string]int{"up": -1, "ctrl+p": -1, "down": 1, "ctrl+n": 1, "pgup": -max(1, m.Height/2), "pgdown": max(1, m.Height/2)}[msg.String()])
331			return m, nil
332		case "tab":
333			if r, ok := m.highlighted(); ok && raisable(r.model) {
334				m.caps[r.model.CapKey] = !m.raised(r)
335				return m, nil
336			}
337		case "left", "right":
338			// Arrows edit a typed search until you move into the list.
339			by := map[string]int{"left": -1, "right": 1}[msg.String()]
340			if (m.search.Value() == "" || m.browsing) && m.stepEffort(by) {
341				return m, nil
342			}
343		}
344	}
345
346	before := m.search.Value()
347	var cmd tea.Cmd
348	m.search, cmd = m.search.Update(msg)
349	if m.search.Value() != before {
350		m.browsing = false
351		m.Error = ""
352		m.refilter(true)
353	}
354	return m, cmd
355}
356
357// ── view ───────────────────────────────────────────────────────────────────
358
359// The row prefix is the selection bar and a space.
360const modelRowPrefix = 2