Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901package tui
import ( "albedo/cli/internal/config" "albedo/cli/internal/daemon" "cmp" "context" "errors" "fmt" "maps" "slices" "strings" "unicode"
"charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/sahilm/fuzzy")
// The /model picker lists every saved profile's models at once, grouped by// profile, and lets each row carry a reasoning effort before you switch.
type ModelPickerSelectMsg struct { // RaiseCap is set when you changed the model's context cap here: true // raises it to the provider's maximum, false restores the default window. RaiseCap *bool Model string Provider string // Effort is the chosen reasoning level. Empty lets the daemon choose. Effort string}
type ModelPickerCancelMsg struct{}
type modelCatalogLoadedMsg struct { Err error Profile string Models []daemon.Model}
// profileCatalog is one profile's models: the ones the session and the// profile already use, then whatever the daemon lists.type profileCatalog struct { settings config.Settings name string models []daemon.Model loading bool failed bool}
// modelRow is a row you can pick: a profile's model, or the search text sent// to a profile as a model id.type modelRow struct { profile string // hits are the byte offsets of the id's characters the search matched. hits []int model daemon.Model typed bool}
func (r modelRow) key() string { if r.typed { return "typed\x00" + r.profile } return r.profile + "\x00" + r.model.ID}
type ModelPickerModel struct { Conn *daemon.Connection // caps are cap choices made here, by model id: the cap is per model, so // the same model under two profiles shares it. caps map[string]bool efforts map[string]string // The session's selection when the picker opened. Profile, Model, Effort string Error string rows []modelRow
catalogs []profileCatalog search textinput.Model Width, Height int cursor int Saving bool // browsing is set once you move through the list, so arrows change the // effort even while a search is typed. browsing bool}
func NewModelPickerModel(conn *daemon.Connection, profiles config.Profiles, model, profile, effort string) ModelPickerModel { if profile == "" { profile = profiles.Active } names := slices.Sorted(maps.Keys(profiles.Providers)) slices.SortStableFunc(names, func(a, b string) int { if a == b { return 0 } if a == profile { return -1 } if b == profile { return 1 } return 0 })
catalogs := make([]profileCatalog, 0, len(names)) for _, name := range names { settings := profiles.Providers[name] seeds := []string{settings.Model} if name == profile { seeds = []string{model, settings.Model} } catalogs = append(catalogs, profileCatalog{ name: name, settings: settings, loading: true, models: mergeModels(seeds, nil), }) }
search := newField() search.Placeholder = "Search models or type a model ID" st := search.Styles() st.Focused.Placeholder, st.Blurred.Placeholder = DefaultStyles.Faint, DefaultStyles.Faint search.SetStyles(st) search.Focus()
m := ModelPickerModel{ Conn: conn, Profile: profile, Model: model, Effort: effort, catalogs: catalogs, efforts: map[string]string{}, caps: map[string]bool{}, search: search, } m.refilter(true) current := modelRow{profile: profile, model: daemon.Model{ID: model}}.key() m.cursor = max(0, slices.IndexFunc(m.rows, func(r modelRow) bool { return r.key() == current })) return m}
// mergeModels lists seeds first, then the rest of listed, each id once. A// listed entry lends its catalog facts to the seed of the same id.func mergeModels(seeds []string, listed []daemon.Model) []daemon.Model { byID := make(map[string]daemon.Model, len(listed)) for _, m := range listed { byID[m.ID] = m } seen := map[string]bool{"": true} var out []daemon.Model add := func(m daemon.Model) { if !seen[m.ID] { seen[m.ID] = true out = append(out, m) } } for _, id := range seeds { m, ok := byID[id] if !ok { m = daemon.Model{ID: id} } add(m) } for _, m := range listed { add(m) } return out}
func (m *ModelPickerModel) SetSize(width, height int) { m.Width = width m.Height = height m.search.SetWidth(max(1, width-6))}
func (m ModelPickerModel) Init() tea.Cmd { cmds := []tea.Cmd{textinput.Blink} for _, c := range m.catalogs { cmds = append(cmds, m.listCmd(c)) } return tea.Batch(cmds...)}
func (m ModelPickerModel) listCmd(c profileCatalog) tea.Cmd { conn := m.Conn return func() tea.Msg { if conn == nil { return modelCatalogLoadedMsg{Profile: c.name, Err: errors.New("daemon connection unavailable")} } models, err := daemon.ListProfileModels(context.Background(), conn, c.settings) return modelCatalogLoadedMsg{Profile: c.name, Models: models, Err: err} }}
func validateModelID(m string) error { m = strings.TrimSpace(m) if m == "" || utf16Length(m) > 512 || strings.ContainsFunc(m, func(r rune) bool { return r < 0x20 || r == 0x7f }) { return errors.New("enter a model id of 1–512 characters") } return nil}
// searchKey folds case and separators, so "gpt.5" finds "gpt-5.1".const searchSeparators = " -_.:/"
var searchNoise = strings.NewReplacer(" ", "", "-", "", "_", "", ".", "", ":", "", "/", "")
func searchKey(s string) string { return searchNoise.Replace(strings.ToLower(s)) }
// matchRank orders search matches, compared element by element: an exact id// first, then words that start the id or one of its parts, then the fuzzy// score. A fuzzy bonus never outweighs a word typed from the start.type matchRank [3]int
func (a matchRank) compare(b matchRank) int { return slices.Compare(a[:], b[:]) }
// matchModel ranks a model against the search words. Each word must fuzzy// match the model id or the profile name.func matchModel(words []string, profile, id string) (rank matchRank, hits []int, ok bool) { if whole := strings.Join(words, ""); whole != "" && (whole == searchKey(id) || whole == searchKey(profile+id)) { rank[0] = 1 } fields := []string{id, profile} for _, word := range words { var best matchRank var bestHits []int found := fuzzy.Find(word, fields) for i, f := range found { if r := (matchRank{0, startTier(fields[f.Index], word), f.Score}); i == 0 || r.compare(best) > 0 { best, bestHits = r, nil if f.Index == 0 { bestHits = f.MatchedIndexes } } } if len(found) == 0 { return matchRank{}, nil, false } rank[1] += best[1] rank[2] += best[2] hits = append(hits, bestHits...) } return rank, hits, true}
// startTier is 2 when word starts s, 1 when it starts a part of s after a// separator, else 0. Separators fold as in searchKey, so "gpt5" starts "gpt-5".func startTier(s, word string) int { if strings.HasPrefix(searchKey(s), word) { return 2 } for i := 1; i < len(s); i++ { if strings.ContainsRune(searchSeparators, rune(s[i-1])) && strings.HasPrefix(searchKey(s[i:]), word) { return 1 } } return 0}
// refilter rebuilds the rows from the catalogs and the search text. The// cursor stays on its row unless reset.func (m *ModelPickerModel) refilter(reset bool) { keep := "" if r, ok := m.highlighted(); ok && !reset { keep = r.key() } query := strings.TrimSpace(m.search.Value()) var words []string for field := range strings.FieldsSeq(query) { if word := searchKey(field); word != "" { words = append(words, word) } }
// Rows rank within their profile, and profiles by their best // row. Ties, and an empty search, keep the catalog order. type scored struct { row modelRow rank matchRank } var groups [][]scored var typed []modelRow canType := query != "" && !strings.ContainsFunc(query, unicode.IsSpace) && validateModelID(query) == nil for _, c := range m.catalogs { var group []scored for _, model := range c.models { if rank, hits, ok := matchModel(words, c.name, model.ID); ok { group = append(group, scored{modelRow{profile: c.name, model: model, hits: hits}, rank}) } } if len(group) > 0 { slices.SortStableFunc(group, func(a, b scored) int { return b.rank.compare(a.rank) }) groups = append(groups, group) } // An id has no spaces, so a search of several words offers none. if canType && !slices.ContainsFunc(c.models, func(model daemon.Model) bool { return model.ID == query }) { typed = append(typed, modelRow{profile: c.name, model: daemon.Model{ID: query}, typed: true}) } } slices.SortStableFunc(groups, func(a, b []scored) int { return b[0].rank.compare(a[0].rank) }) m.rows = nil for _, group := range groups { for _, s := range group { m.rows = append(m.rows, s.row) } } m.rows = append(m.rows, typed...) m.cursor = max(0, slices.IndexFunc(m.rows, func(r modelRow) bool { return r.key() == keep }))}
func (m ModelPickerModel) highlighted() (modelRow, bool) { if m.cursor < 0 || m.cursor >= len(m.rows) { return modelRow{}, false } return m.rows[m.cursor], true}
func (m ModelPickerModel) catalog(profile string) profileCatalog { if i := slices.IndexFunc(m.catalogs, func(c profileCatalog) bool { return c.name == profile }); i >= 0 { return m.catalogs[i] } return profileCatalog{}}
// effort is the level a row would switch with: your pick, else the session's// level when the model has it, else the daemon's default.func (m ModelPickerModel) effort(r modelRow) string { levels := r.model.Efforts switch { case len(levels) == 0: return "" case slices.Contains(levels, m.efforts[r.key()]): return m.efforts[r.key()] case slices.Contains(levels, m.Effort): return m.Effort } return defaultEffort(levels)}
// defaultEffort mirrors the daemon: medium, else the first of high, xhigh or// max, else the highest level.func defaultEffort(levels []string) string { if slices.Contains(levels, "medium") { return "medium" } if i := slices.IndexFunc(levels, func(l string) bool { return l == "high" || l == "xhigh" || l == "max" }); i >= 0 { return levels[i] } return levels[len(levels)-1]}
// effortSteps are the levels that fill a square. None and off fill nothing.func effortSteps(levels []string) []string { return slices.DeleteFunc(slices.Clone(levels), func(l string) bool { return l == "none" || l == "off" })}
// stepEffort moves the highlighted row's effort one level, stopping at the// ends. It reports whether the row has levels to step through.func (m *ModelPickerModel) stepEffort(by int) bool { r, ok := m.highlighted() if !ok || len(r.model.Efforts) == 0 { return false } levels := r.model.Efforts i := slices.Index(levels, m.effort(r)) + by m.efforts[r.key()] = levels[min(max(i, 0), len(levels)-1)] return true}
// raisable reports whether a model's provider offers a window past its default.func raisable(model daemon.Model) bool { return model.MaxContext > model.Context && model.Context > 0}
// raised is the cap a row would switch with: your choice here, else the saved one.func (m ModelPickerModel) raised(r modelRow) bool { if raised, ok := m.caps[r.model.ID]; ok { return raised } return r.model.Raised}
// window is the context a row would switch with.func (m ModelPickerModel) window(r modelRow) int { if raisable(r.model) && m.raised(r) { return r.model.MaxContext } return r.model.Context}
func (m *ModelPickerModel) move(by int) { if len(m.rows) > 0 { m.cursor = min(max(m.cursor+by, 0), len(m.rows)-1) m.browsing = true }}
func (m ModelPickerModel) Update(msg tea.Msg) (ModelPickerModel, tea.Cmd) { switch msg := msg.(type) { case modelCatalogLoadedMsg: i := slices.IndexFunc(m.catalogs, func(c profileCatalog) bool { return c.name == msg.Profile }) if i < 0 { return m, nil } m.catalogs = slices.Clone(m.catalogs) c := &m.catalogs[i] c.loading, c.failed = false, msg.Err != nil seeds := make([]string, len(c.models)) for j, model := range c.models { seeds[j] = model.ID } c.models = mergeModels(seeds, msg.Models) m.refilter(false) return m, nil
case tea.KeyPressMsg: if m.Saving { return m, nil } switch msg.String() { case "esc", "ctrl+c", "ctrl+d": return m, func() tea.Msg { return ModelPickerCancelMsg{} } case "enter": r, ok := m.highlighted() if !ok { return m, nil } if err := validateModelID(r.model.ID); err != nil { m.Error = err.Error() return m, nil } m.Error = "" choice := ModelPickerSelectMsg{Model: r.model.ID, Provider: r.profile, Effort: m.effort(r)} if raised, changed := m.caps[r.model.ID]; changed && raised != r.model.Raised { choice.RaiseCap = &raised } return m, func() tea.Msg { return choice } case "up", "ctrl+p", "down", "ctrl+n", "pgup", "pgdown": 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()]) return m, nil case "tab": if r, ok := m.highlighted(); ok && raisable(r.model) { m.caps[r.model.ID] = !m.raised(r) return m, nil } case "left", "right": // Arrows edit a typed search until you move into the list. by := map[string]int{"left": -1, "right": 1}[msg.String()] if (m.search.Value() == "" || m.browsing) && m.stepEffort(by) { return m, nil } } }
before := m.search.Value() var cmd tea.Cmd m.search, cmd = m.search.Update(msg) if m.search.Value() != before { m.browsing = false m.Error = "" m.refilter(true) } return m, cmd}
// ── view ───────────────────────────────────────────────────────────────────
// modelColumns are the widths every listed row shares, so the effort ladders// line up.type modelColumns struct{ name, slots, label, tag int }
func (c modelColumns) cluster() int { switch { case c.slots == 0: return 0 case c.label == 0: return c.slots + 4 } return c.slots + 5 + c.label}
// The row prefix is the selection bar and a space.const modelRowPrefix = 2
func (m ModelPickerModel) layout(width int) modelColumns { var c modelColumns for _, r := range m.rows { if r.typed { continue } c.name = max(c.name, ansi.StringWidth(r.model.ID)) c.slots = max(c.slots, len(effortSteps(r.model.Efforts))) for _, level := range r.model.Efforts { c.label = max(c.label, ansi.StringWidth(level)) } c.tag = max(c.tag, ansi.StringWidth(m.tag(r))) } room := width - modelRowPrefix - 1 if c.tag > 0 { room -= c.tag + 2 } // Narrow rows shorten names first, then drop the label, then the ladder. longest := c.name over := func() bool { return c.name+min(c.cluster(), 1)*2+c.cluster() > room } fit := func() { c.name = min(longest, max(min(longest, 16), room-min(c.cluster(), 1)*2-c.cluster())) } if over() { fit() } if over() { c.label = 0 fit() } if over() { c.slots = 0 } c.name = max(1, min(c.name, room)) return c}
// tag names a listed row's standing: the session's model, or the model the// profile opens new sessions with.func (m ModelPickerModel) tag(r modelRow) string { switch { case r.typed: return "" case r.profile == m.Profile && r.model.ID == m.Model: return "current" case r.model.ID == m.catalog(r.profile).settings.Model: return "default" } return ""}
func (m ModelPickerModel) View() string { width, height := cmp.Or(m.Width, 80), cmp.Or(m.Height, 24) roomy := height >= 12
lines := []string{" " + titleRule(width-1, brand("albedo")+" "+DefaultStyles.Muted.Render("/model"), m.selection())} if roomy { lines = append(lines, "") } lines = append(lines, " "+promptLead()+m.search.View()) if height >= 9 { lines = append(lines, "") }
paned := width >= 96 && height >= 14 var tail []string if roomy { tail = append(tail, "") } if r, ok := m.highlighted(); ok && !paned && height >= 16 { tail = append(tail, " "+m.summary(r)) } tail = append(tail, m.footer(width)) body := max(1, height-len(lines)-len(tail))
if paned { paneW := min(max(width/3, 34), 48) list := m.list(width-paneW-ansi.StringWidth(svSep()), body) pane := m.details(paneW, body) for i := range body { lines = append(lines, list[i]+svSep()+pane[i]) } } else { lines = append(lines, m.list(width, body)...) } lines = append(lines, tail...)
if len(lines) > height { lines = append(lines[:max(0, height-1)], lines[len(lines)-1]) } for i, l := range lines { lines[i] = ansi.Truncate(l, width, "…") } return strings.Join(lines, "\n")}
// selection is the session's profile, model and effort for the title.func (m ModelPickerModel) selection() string { model := DefaultStyles.Muted.Render(m.Model) if m.Effort != "" { model += DefaultStyles.Faint.Render(":" + m.Effort) } return DefaultStyles.Faint.Render(m.Profile) + DefaultStyles.Decor.Render(" · ") + model}
// list renders the rows grouped by profile at exactly width × height.func (m ModelPickerModel) list(width, height int) []string { cols := m.layout(width) var all []string selectedAt := -1 section := func(label string, count int) { if len(all) > 0 && height >= 10 { all = append(all, "") } all = append(all, sectionRule(label, count, width)) } add := func(i int) { if i == m.cursor { selectedAt = len(all) } all = append(all, m.row(m.rows[i], i == m.cursor, width, cols)) }
i := 0 for i < len(m.rows) && !m.rows[i].typed { start, profile := i, m.rows[i].profile for i < len(m.rows) && !m.rows[i].typed && m.rows[i].profile == profile { i++ } section(profile, i-start) for j := start; j < i; j++ { add(j) } switch c := m.catalog(profile); { case c.loading: all = append(all, DefaultStyles.Faint.Render(" listing models…")) case c.failed: all = append(all, DefaultStyles.Faint.Render(" Could not list models. Type a model ID to continue.")) } } if i < len(m.rows) { section("as typed", len(m.rows)-i) for ; i < len(m.rows); i++ { add(i) } } if len(m.rows) == 0 { all = append(all, DefaultStyles.Faint.Render(" no models match")) } return scrollWindow(all, selectedAt, width, height)}
func (m ModelPickerModel) row(r modelRow, selected bool, width int, cols modelColumns) string { bar, nameStyle := " ", lipgloss.NewStyle() if selected { bar, nameStyle = selectBar(), DefaultStyles.Bold } line := bar + " " if r.typed { line += nameStyle.Render(ansi.Truncate(r.model.ID, max(1, width/2), "…")) + DefaultStyles.Faint.Render(" on "+r.profile) } else { line += markedCell(r.model.ID, r.hits, cols.name, nameStyle) if cols.cluster() > 0 { line += " " + m.ladder(r, selected, cols) } if tag := m.tag(r); tag != "" { style := DefaultStyles.Faint if tag == "current" { style = DefaultStyles.Success } line += strings.Repeat(" ", max(2, width-ansi.StringWidth(line)-ansi.StringWidth(tag)-1)) + style.Render(tag) } } if selected { return selectedLine(line, width) } return line}
// markedCell fits s to width like svCell and draws the characters at hits in// the prompt color, so a search shows what it matched.func markedCell(s string, hits []int, width int, base lipgloss.Style) string { if width <= 0 { return "" } plain := ansi.Truncate(s, width, "…") kept := len(plain) if plain != s { kept -= len("…") } hit := DefaultStyles.Prompt.Inherit(base) var b strings.Builder run, marked := "", false flush := func() { if run != "" { style := base if marked { style = hit } b.WriteString(style.Render(run)) } } for i, r := range plain { if m := i < kept && slices.Contains(hits, i); m != marked { flush() run, marked = "", m } run += string(r) } flush() return b.String() + strings.Repeat(" ", max(0, width-ansi.StringWidth(plain)))}
// ladder draws a row's effort as squares filling toward the highest level,// with arrows on the selected row where there is a level to step to.func (m ModelPickerModel) ladder(r modelRow, selected bool, cols modelColumns) string { levels := r.model.Efforts if len(levels) == 0 { return strings.Repeat(" ", cols.cluster()) } effort := m.effort(r) steps := effortSteps(levels) filled := slices.Index(steps, effort) + 1 var squares strings.Builder for i := range cols.slots { switch { case i >= len(steps): squares.WriteByte(' ') case i >= filled: squares.WriteString(DefaultStyles.Decor.Render("▱")) case selected: squares.WriteString(brandInk(float64(i) / float64(max(1, len(steps)-1))).Render("▰")) default: squares.WriteString(DefaultStyles.Muted.Render("▰")) } } arrow := func(glyph string, open bool) string { if !selected { return " " } if open { return DefaultStyles.Muted.Render(glyph) } return DefaultStyles.Decor.Render(glyph) } at := slices.Index(levels, effort) out := arrow("‹", at > 0) + " " + squares.String() + " " + arrow("›", at < len(levels)-1) if cols.label > 0 { label := DefaultStyles.Faint if selected { label = lipgloss.NewStyle() } out += " " + label.Render(svCell(effort, cols.label, false)) } return out}
// facts are what the catalog knows about a row's model, as label and value.func (m ModelPickerModel) facts(r modelRow) [][2]string { var facts [][2]string if r.model.Context > 0 { facts = append(facts, [2]string{"context", compactTokens(m.window(r)) + " tokens"}) } if raisable(r.model) { state := DefaultStyles.Faint.Render("default") + DefaultStyles.Decor.Render(" · ") + DefaultStyles.Faint.Render("tab raises to "+compactTokens(r.model.MaxContext)) if m.raised(r) { state = brandInk(1).Bold(true).Render("raised") + DefaultStyles.Decor.Render(" · ") + DefaultStyles.Faint.Render("tab restores "+compactTokens(r.model.Context)) } facts = append(facts, [2]string{"cap", state}) } if r.model.Output > 0 { facts = append(facts, [2]string{"output", compactTokens(r.model.Output) + " tokens"}) } if len(r.model.Input) > 0 { facts = append(facts, [2]string{"input", strings.Join(r.model.Input, ", ")}) } return facts}
// compactTokens writes a token count as 8k, 400k or 1.05m.func compactTokens(n int) string { switch { case n >= 1_000_000: return strings.TrimSuffix(strings.TrimRight(fmt.Sprintf("%.2f", float64(n)/1e6), "0"), ".") + "m" case n >= 1000: return fmt.Sprintf("%dk", n/1000) } return fmt.Sprint(n)}
// summary is the highlighted row's facts on one line, for narrow screens.func (m ModelPickerModel) summary(r modelRow) string { if r.typed { return DefaultStyles.Faint.Render("sent to " + r.profile + " as typed") } var parts []string if r.model.Context > 0 { parts = append(parts, compactTokens(m.window(r))+" context") } if raisable(r.model) && m.raised(r) { parts = append(parts, "cap raised") } if r.model.Output > 0 { parts = append(parts, compactTokens(r.model.Output)+" output") } if len(r.model.Input) > 0 { parts = append(parts, strings.Join(r.model.Input, ", ")) } if effort := m.effort(r); effort != "" { parts = append(parts, effort+" effort") } return DefaultStyles.Faint.Render(strings.Join(parts, " · "))}
// details renders the highlighted row's pane at exactly width × height.func (m ModelPickerModel) details(width, height int) []string { inner := max(1, width-2) r, ok := m.highlighted() if !ok { return paneBox([]string{"", DefaultStyles.Muted.Render("nothing matches"), DefaultStyles.Faint.Render("Type a model ID to use it as entered")}, inner, width, height) } var lines []string for _, l := range svWrap(r.model.ID, inner, 2) { lines = append(lines, DefaultStyles.Bold.Render(l)) } meta := []string{DefaultStyles.Muted.Render(r.profile)} if protocol := m.catalog(r.profile).settings.Protocol; protocol != "" { meta = append(meta, DefaultStyles.Faint.Render(protocol)) } if tag := m.tag(r); tag != "" { style := DefaultStyles.Faint if tag == "current" { style = DefaultStyles.Success } meta = append(meta, style.Render(tag)) } lines = append(lines, strings.Join(meta, DefaultStyles.Decor.Render(" · ")), DefaultStyles.Decor.Render(strings.Repeat("─", inner)))
note := func(text string) { for _, l := range svWrap(text, inner, 3) { lines = append(lines, DefaultStyles.Faint.Render(l)) } } if r.typed { note(r.profile + " does not list this id, so it is sent as typed and the daemon picks the effort.") } else { facts := m.facts(r) if levels := r.model.Efforts; len(levels) > 0 { chosen := m.effort(r) marks := make([]string, len(levels)) for i, level := range levels { marks[i] = DefaultStyles.Faint.Render(level) if level == chosen { marks[i] = brandInk(1).Bold(true).Render(level) } } facts = append(facts, [2]string{"effort", strings.Join(marks, DefaultStyles.Decor.Render(" · "))}) } for _, f := range facts { lines = append(lines, DefaultStyles.Muted.Render(svCell(f[0], 9, false))+f[1]) } if len(facts) == 0 { note("the catalog knows nothing else about this model.") } } lines = append(lines, "") note("enter switches this session and makes it the default for new sessions.") return paneBox(lines, inner, width, height)}
func (m ModelPickerModel) footer(width int) string { hints := []hint{{"↑↓", "move"}, {"←→", "effort"}} if r, ok := m.highlighted(); ok && raisable(r.model) { hints = append(hints, hint{"tab", "cap"}) } left := " " + keyHints(append(hints, hint{"enter", "switch"}, hint{"esc", "back"})...) loading, models := 0, 0 for _, c := range m.catalogs { if c.loading { loading++ } models += len(c.models) } var right string switch { case m.Saving: right = DefaultStyles.Busy.Render("switching…") case m.Error != "": right = DefaultStyles.Error.Render(m.Error) case loading > 0: right = DefaultStyles.Faint.Render(fmt.Sprintf("%d of %d profiles listed…", len(m.catalogs)-loading, len(m.catalogs))) default: right = DefaultStyles.Faint.Render(counted(models, "model")) + DefaultStyles.Decor.Render(" · ") + DefaultStyles.Faint.Render(counted(len(m.catalogs), "profile")) } gap := width - ansi.StringWidth(left) - ansi.StringWidth(right) - 1 if gap >= 3 { return left + strings.Repeat(" ", gap) + right } if m.Error != "" { return " " + right } return left}
func counted(n int, noun string) string { if n == 1 { return "1 " + noun } return fmt.Sprintf("%d %ss", n, noun)}