package 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 utf16Length(s string) int {
length := 0
for _, char := range s {
length++
if char > 0xffff {
length++
}
}
return length
}
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)
}