okami.mom/albedo

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

dawntui: one listFrame lays out sessions, model and folder screens instead of three copiesf758a72

main
11.9 KiB419 linesraw
1package tui
2
3import (
4	"cmp"
5	"fmt"
6	"slices"
7	"strings"
8
9	"charm.land/lipgloss/v2"
10	"github.com/charmbracelet/x/ansi"
11)
12
13// modelColumns are the widths every listed row shares, so the effort ladders
14// line up.
15type modelColumns struct{ name, slots, label, tag int }
16
17func (c modelColumns) cluster() int {
18	switch {
19	case c.slots == 0:
20		return 0
21	case c.label == 0:
22		return c.slots + 4
23	}
24	return c.slots + 5 + c.label
25}
26
27func (m ModelPickerModel) layout(width int) modelColumns {
28	var c modelColumns
29	for _, r := range m.rows {
30		if r.typed {
31			continue
32		}
33		c.name = max(c.name, ansi.StringWidth(r.model.ID))
34		c.slots = max(c.slots, len(effortSteps(r.model.Efforts)))
35		for _, level := range r.model.Efforts {
36			c.label = max(c.label, ansi.StringWidth(level))
37		}
38		c.tag = max(c.tag, ansi.StringWidth(m.tag(r)))
39	}
40	room := width - modelRowPrefix - 1
41	if c.tag > 0 {
42		room -= c.tag + 2
43	}
44	// Narrow rows shorten names first, then drop the label, then the ladder.
45	longest := c.name
46	over := func() bool { return c.name+min(c.cluster(), 1)*2+c.cluster() > room }
47	fit := func() { c.name = min(longest, max(min(longest, 16), room-min(c.cluster(), 1)*2-c.cluster())) }
48	if over() {
49		fit()
50	}
51	if over() {
52		c.label = 0
53		fit()
54	}
55	if over() {
56		c.slots = 0
57	}
58	c.name = max(1, min(c.name, room))
59	return c
60}
61
62// tag names a listed row's standing: the session's model, or the model the
63// profile opens new sessions with.
64func (m ModelPickerModel) tag(r modelRow) string {
65	switch {
66	case r.typed:
67		return ""
68	case r.profile == m.Profile && r.model.ID == m.Model:
69		return "current"
70	case r.model.ID == m.catalog(r.profile).settings.Model:
71		return "default"
72	}
73	return ""
74}
75
76func (m ModelPickerModel) View() string {
77	width := cmp.Or(m.Width, 80)
78	f := listFrame{
79		title:  brand("albedo") + " " + DefaultStyles.Muted.Render("/model"),
80		right:  m.selection(),
81		filter: promptLead() + m.search.View(),
82		list:   m.list,
83		pane:   m.details,
84		footer: m.footer(width),
85	}
86	if r, ok := m.highlighted(); ok {
87		f.summary = m.summary(r)
88	}
89	return f.view(m.Width, m.Height)
90}
91
92// selection is the session's profile, model and effort for the title.
93func (m ModelPickerModel) selection() string {
94	model := DefaultStyles.Muted.Render(m.Model)
95	if m.Effort != "" {
96		model += DefaultStyles.Faint.Render(":" + m.Effort)
97	}
98	return DefaultStyles.Faint.Render(m.Profile) + DefaultStyles.Decor.Render(" · ") + model
99}
100
101// list renders the rows grouped by profile at exactly width × height.
102func (m ModelPickerModel) list(width, height int) []string {
103	cols := m.layout(width)
104	// Rules and notes are drawn here; a model row only once it shows.
105	type line struct {
106		text string
107		row  int
108	}
109	var all []line
110	selectedAt := -1
111	note := func(text string) { all = append(all, line{text: text, row: -1}) }
112	section := func(label string, count int) {
113		if len(all) > 0 && height >= 10 {
114			note("")
115		}
116		note(sectionRule(label, count, width))
117	}
118	add := func(i int) {
119		if i == m.cursor {
120			selectedAt = len(all)
121		}
122		all = append(all, line{row: i})
123	}
124
125	i := 0
126	for i < len(m.rows) && !m.rows[i].typed {
127		start, profile := i, m.rows[i].profile
128		for i < len(m.rows) && !m.rows[i].typed && m.rows[i].profile == profile {
129			i++
130		}
131		section(profile, i-start)
132		for j := start; j < i; j++ {
133			add(j)
134		}
135		switch c := m.catalog(profile); {
136		case c.loading:
137			note(DefaultStyles.Faint.Render("  listing models…"))
138		case c.failed:
139			note(DefaultStyles.Faint.Render("  Could not list models. Type a model ID to continue."))
140		}
141	}
142	if i < len(m.rows) {
143		section("as typed", len(m.rows)-i)
144		for ; i < len(m.rows); i++ {
145			add(i)
146		}
147	}
148	if len(m.rows) == 0 {
149		note(DefaultStyles.Faint.Render("  no models match"))
150	}
151	return drawWindow(len(all), func(i int) string {
152		l := all[i]
153		if l.row < 0 {
154			return l.text
155		}
156		return m.row(m.rows[l.row], l.row == m.cursor, width, cols)
157	}, selectedAt, width, height)
158}
159
160func (m ModelPickerModel) row(r modelRow, selected bool, width int, cols modelColumns) string {
161	bar, nameStyle := " ", lipgloss.NewStyle()
162	if selected {
163		bar, nameStyle = selectBar(), DefaultStyles.Bold
164	}
165	line := bar + " "
166	if r.typed {
167		line += nameStyle.Render(ansi.Truncate(r.model.ID, max(1, width/2), "…")) + DefaultStyles.Faint.Render("  on "+r.profile)
168	} else {
169		line += markedCell(r.model.ID, r.hits, cols.name, nameStyle)
170		if cols.cluster() > 0 {
171			line += "  " + m.ladder(r, selected, cols)
172		}
173		if tag := m.tag(r); tag != "" {
174			style := DefaultStyles.Faint
175			if tag == "current" {
176				style = DefaultStyles.Success
177			}
178			line += strings.Repeat(" ", max(2, width-ansi.StringWidth(line)-ansi.StringWidth(tag)-1)) + style.Render(tag)
179		}
180	}
181	if selected {
182		return selectedLine(line, width)
183	}
184	return line
185}
186
187// markedCell fits s to width like svCell and draws the characters at hits in
188// the prompt color, so a search shows what it matched.
189func markedCell(s string, hits []int, width int, base lipgloss.Style) string {
190	if width <= 0 {
191		return ""
192	}
193	plain := ansi.Truncate(s, width, "…")
194	kept := len(plain)
195	if plain != s {
196		kept -= len("…")
197	}
198	hit := DefaultStyles.Prompt.Inherit(base)
199	var b strings.Builder
200	start, marked := 0, false
201	flush := func(end int) {
202		if end > start {
203			style := base
204			if marked {
205				style = hit
206			}
207			b.WriteString(style.Render(plain[start:end]))
208		}
209	}
210	for i := range plain {
211		if m := i < kept && slices.Contains(hits, i); m != marked {
212			flush(i)
213			start, marked = i, m
214		}
215	}
216	flush(len(plain))
217	return b.String() + strings.Repeat(" ", max(0, width-ansi.StringWidth(plain)))
218}
219
220// ladder draws a row's effort as squares filling toward the highest level,
221// with arrows on the selected row where there is a level to step to.
222func (m ModelPickerModel) ladder(r modelRow, selected bool, cols modelColumns) string {
223	levels := r.model.Efforts
224	if len(levels) == 0 {
225		return strings.Repeat(" ", cols.cluster())
226	}
227	effort := m.effort(r)
228	steps := effortSteps(levels)
229	filled := slices.Index(steps, effort) + 1
230	var squares strings.Builder
231	for i := range cols.slots {
232		switch {
233		case i >= len(steps):
234			squares.WriteByte(' ')
235		case i >= filled:
236			squares.WriteString(DefaultStyles.Decor.Render("▱"))
237		case selected:
238			squares.WriteString(brandInk(float64(i) / float64(max(1, len(steps)-1))).Render("▰"))
239		default:
240			squares.WriteString(DefaultStyles.Muted.Render("▰"))
241		}
242	}
243	arrow := func(glyph string, open bool) string {
244		if !selected {
245			return " "
246		}
247		if open {
248			return DefaultStyles.Muted.Render(glyph)
249		}
250		return DefaultStyles.Decor.Render(glyph)
251	}
252	at := slices.Index(levels, effort)
253	out := arrow("‹", at > 0) + " " + squares.String() + " " + arrow("›", at < len(levels)-1)
254	if cols.label > 0 {
255		label := DefaultStyles.Faint
256		if selected {
257			label = lipgloss.NewStyle()
258		}
259		out += " " + label.Render(svCell(effort, cols.label, false))
260	}
261	return out
262}
263
264// facts are what the catalog knows about a row's model, as label and value.
265func (m ModelPickerModel) facts(r modelRow) [][2]string {
266	var facts [][2]string
267	if r.model.Context > 0 {
268		facts = append(facts, [2]string{"context", compactTokens(m.window(r)) + " tokens"})
269	}
270	if raisable(r.model) {
271		state := DefaultStyles.Faint.Render("default") + DefaultStyles.Decor.Render(" · ") +
272			DefaultStyles.Faint.Render("tab raises to "+compactTokens(r.model.MaxContext))
273		if m.raised(r) {
274			state = brandInk(1).Bold(true).Render("raised") + DefaultStyles.Decor.Render(" · ") +
275				DefaultStyles.Faint.Render("tab restores "+compactTokens(r.model.Context))
276		}
277		facts = append(facts, [2]string{"cap", state})
278	}
279	if r.model.Output > 0 {
280		facts = append(facts, [2]string{"output", compactTokens(r.model.Output) + " tokens"})
281	}
282	if len(r.model.Input) > 0 {
283		facts = append(facts, [2]string{"input", strings.Join(r.model.Input, ", ")})
284	}
285	return facts
286}
287
288// compactTokens writes a token count as 8k, 400k or 1.05m.
289func compactTokens(n int) string {
290	switch {
291	case n >= 1_000_000:
292		return strings.TrimSuffix(strings.TrimRight(fmt.Sprintf("%.2f", float64(n)/1e6), "0"), ".") + "m"
293	case n >= 1000:
294		return fmt.Sprintf("%dk", n/1000)
295	}
296	return fmt.Sprint(n)
297}
298
299// summary is the highlighted row's facts on one line, for narrow screens.
300func (m ModelPickerModel) summary(r modelRow) string {
301	if r.typed {
302		return DefaultStyles.Faint.Render("sent to " + r.profile + " as typed")
303	}
304	var parts []string
305	if r.model.Context > 0 {
306		parts = append(parts, compactTokens(m.window(r))+" context")
307	}
308	if raisable(r.model) && m.raised(r) {
309		parts = append(parts, "cap raised")
310	}
311	if r.model.Output > 0 {
312		parts = append(parts, compactTokens(r.model.Output)+" output")
313	}
314	if len(r.model.Input) > 0 {
315		parts = append(parts, strings.Join(r.model.Input, ", "))
316	}
317	if effort := m.effort(r); effort != "" {
318		parts = append(parts, effort+" effort")
319	}
320	return DefaultStyles.Faint.Render(strings.Join(parts, " · "))
321}
322
323// details renders the highlighted row's pane at exactly width × height.
324func (m ModelPickerModel) details(width, height int) []string {
325	inner := max(1, width-2)
326	r, ok := m.highlighted()
327	if !ok {
328		return paneBox([]string{"", DefaultStyles.Muted.Render("nothing matches"), DefaultStyles.Faint.Render("Type a model ID to use it as entered")}, inner, width, height)
329	}
330	var lines []string
331	for _, l := range svWrap(r.model.ID, inner, 2) {
332		lines = append(lines, DefaultStyles.Bold.Render(l))
333	}
334	meta := []string{DefaultStyles.Muted.Render(r.profile)}
335	if protocol := m.catalog(r.profile).settings.Protocol; protocol != "" {
336		meta = append(meta, DefaultStyles.Faint.Render(protocol))
337	}
338	if tag := m.tag(r); tag != "" {
339		style := DefaultStyles.Faint
340		if tag == "current" {
341			style = DefaultStyles.Success
342		}
343		meta = append(meta, style.Render(tag))
344	}
345	lines = append(lines, strings.Join(meta, DefaultStyles.Decor.Render(" · ")), DefaultStyles.Decor.Render(strings.Repeat("─", inner)))
346
347	note := func(text string) {
348		for _, l := range svWrap(text, inner, 3) {
349			lines = append(lines, DefaultStyles.Faint.Render(l))
350		}
351	}
352	if r.typed {
353		note(r.profile + " does not list this id, so it is sent as typed and the daemon picks the effort.")
354	} else {
355		facts := m.facts(r)
356		if levels := r.model.Efforts; len(levels) > 0 {
357			chosen := m.effort(r)
358			marks := make([]string, len(levels))
359			for i, level := range levels {
360				marks[i] = DefaultStyles.Faint.Render(level)
361				if level == chosen {
362					marks[i] = brandInk(1).Bold(true).Render(level)
363				}
364			}
365			facts = append(facts, [2]string{"effort", strings.Join(marks, DefaultStyles.Decor.Render(" · "))})
366		}
367		for _, f := range facts {
368			lines = append(lines, DefaultStyles.Muted.Render(svCell(f[0], 9, false))+f[1])
369		}
370		if len(facts) == 0 {
371			note("the catalog knows nothing else about this model.")
372		}
373	}
374	lines = append(lines, "")
375	note("enter switches this session and makes it the default for new sessions.")
376	return paneBox(lines, inner, width, height)
377}
378
379func (m ModelPickerModel) footer(width int) string {
380	hints := []hint{{"↑↓", "move"}, {"←→", "effort"}}
381	if r, ok := m.highlighted(); ok && raisable(r.model) {
382		hints = append(hints, hint{"tab", "cap"})
383	}
384	left := " " + keyHints(append(hints, hint{"enter", "switch"}, hint{"esc", "back"})...)
385	loading, models := 0, 0
386	for _, c := range m.catalogs {
387		if c.loading {
388			loading++
389		}
390		models += len(c.models)
391	}
392	var right string
393	switch {
394	case m.Saving:
395		right = DefaultStyles.Busy.Render("switching…")
396	case m.Error != "":
397		right = DefaultStyles.Error.Render(m.Error)
398	case loading > 0:
399		right = DefaultStyles.Faint.Render(fmt.Sprintf("%d of %d profiles listed…", len(m.catalogs)-loading, len(m.catalogs)))
400	default:
401		right = DefaultStyles.Faint.Render(counted(models, "model")) + DefaultStyles.Decor.Render(" · ") +
402			DefaultStyles.Faint.Render(counted(len(m.catalogs), "profile"))
403	}
404	gap := width - ansi.StringWidth(left) - ansi.StringWidth(right) - 1
405	if gap >= 3 {
406		return left + strings.Repeat(" ", gap) + right
407	}
408	if m.Error != "" {
409		return " " + right
410	}
411	return left
412}
413
414func counted(n int, noun string) string {
415	if n == 1 {
416		return "1 " + noun
417	}
418	return fmt.Sprintf("%d %ss", n, noun)
419}