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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467package tui
import ( "albedo/cli/internal/config" "albedo/cli/internal/daemon" "context" "errors" "maps" "slices" "strings"
"charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2")
// 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 { Model string Provider string // Effort is the chosen reasoning level. Empty lets the daemon choose. Effort string}
// ModelPickerCapsMsg asks the app to save a change to a model's context. The// cap and the compaction window belong to the model, for every session, so// they save as soon as you make them rather than with a switch. Zero// CompactAt clears the window.type ModelPickerCapsMsg struct { Generation int Caps daemon.ModelCapsRequest}
// modelCapsSavedMsg answers a ModelPickerCapsMsg with the models settings'// new ETag.type modelCapsSavedMsg struct { Generation int ETag string Err error}
type ModelPickerCancelMsg struct{}
type modelCatalogLoadedMsg struct { Generation int 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 { Generation int readCtx context.Context cancelReads context.CancelFunc Conn *daemon.Connection efforts map[string]string // saving is the cap change waiting on the daemon; keys wait with it. saving *daemon.ModelCapsRequest // edit is the window field in the details pane, live while editing. edit textinput.Model editing bool // 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 := newFilter("Search models or type a model ID")
readCtx, cancelReads := context.WithCancel(context.Background()) m := ModelPickerModel{ Generation: nextPageGeneration(), readCtx: readCtx, cancelReads: cancelReads, Conn: conn, Profile: profile, Model: model, Effort: effort, catalogs: catalogs, efforts: map[string]string{}, edit: newWindowField(), 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}
func (m *ModelPickerModel) SetSize(width, height int) { m.Width = width m.Height = height m.search.SetWidth(max(1, width-6))}
func (m *ModelPickerModel) Close() { if m.cancelReads != nil { m.cancelReads() } m.Generation = nextPageGeneration()}
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, ctx, generation := m.Conn, m.readCtx, m.Generation return func() tea.Msg { if conn == nil { return modelCatalogLoadedMsg{Generation: generation, Profile: c.name, Err: errors.New("daemon connection unavailable")} } models, err := daemon.ListProfileModels(ctx, conn, c.settings) return modelCatalogLoadedMsg{Generation: generation, 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 = " -_.:/"
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}
// compactable reports whether a row's model has a window to compact below.func compactable(r modelRow) bool { return !r.typed && r.model.Context > 0}
// window is the context a row would switch with.func (m ModelPickerModel) window(r modelRow) int { if raisable(r.model) && r.model.Raised { 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: if msg.Generation != m.Generation { return m, nil } 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 modelCapsSavedMsg: if msg.Generation != m.Generation || m.saving == nil { return m, nil } saved := *m.saving m.saving = nil if msg.Err != nil { m.Error = operationError(msg.Err, "Model settings error: ", "Model settings may have changed; check them before trying again.") return m, nil } m.applyCaps(saved) return m, nil
case tea.KeyPressMsg: if m.Saving || m.saving != nil { return m, nil } if m.editing { return m.updateEdit(msg) } 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)} 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) { return m.saveCaps(daemon.ModelCapsRequest{CapKey: r.model.CapKey, Raise: new(!r.model.Raised)}) } case "ctrl+e": if r, ok := m.highlighted(); ok && compactable(r) { m.editing, m.Error = true, "" m.edit.SetValue(compactTokensOrEmpty(r.model.CompactAt)) m.edit.CursorEnd() 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}
// updateEdit is the window field's keys: enter saves the value, esc drops it.func (m ModelPickerModel) updateEdit(msg tea.KeyPressMsg) (ModelPickerModel, tea.Cmd) { r, ok := m.highlighted() switch msg.String() { case "esc", "ctrl+c": m.editing, m.Error = false, "" case "enter": if !ok { break } tokens, err := m.parseWindow(r) if err != nil { m.Error = err.Error() break } m.editing = false if tokens != r.model.CompactAt { return m.saveCaps(daemon.ModelCapsRequest{CapKey: r.model.CapKey, CompactAt: &tokens}) } default: var cmd tea.Cmd m.edit, cmd = m.edit.Update(msg) m.Error = "" return m, cmd } return m, nil}
// parseWindow reads the field's text as the row's compaction window. An empty// field clears it. It fails, saying why, when the text is no window the model// can compact below.func (m ModelPickerModel) parseWindow(r modelRow) (int, error) { raw := strings.TrimSpace(m.edit.Value()) if raw == "" { return 0, nil } tokens, err := daemon.ParseTokenWindow(raw) if err != nil { return 0, err } if limit := m.window(r); int(tokens) > limit { return 0, errors.New("past the model's " + compactTokens(limit) + " window") } return int(tokens), nil}
// saveCaps hands the app a cap change to save now. The keys wait for its answer.func (m ModelPickerModel) saveCaps(caps daemon.ModelCapsRequest) (ModelPickerModel, tea.Cmd) { m.saving, m.Error = &caps, "" ask := ModelPickerCapsMsg{Generation: m.Generation, Caps: caps} return m, func() tea.Msg { return ask }}
// applyCaps shows a saved change on every row of the model, whichever profile// lists it: the cap belongs to the model.func (m *ModelPickerModel) applyCaps(caps daemon.ModelCapsRequest) { m.catalogs = slices.Clone(m.catalogs) for i := range m.catalogs { c := &m.catalogs[i] c.models = slices.Clone(c.models) for j := range c.models { if c.models[j].CapKey != caps.CapKey { continue } if caps.Raise != nil { c.models[j].Raised = *caps.Raise } if caps.CompactAt != nil { c.models[j].CompactAt = *caps.CompactAt } } } m.refilter(false)}
// ── view ───────────────────────────────────────────────────────────────────
// The row prefix is the selection bar and a space.const modelRowPrefix = 2