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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426package tui
import ( "albedo/cli/internal/daemon" "context" "errors" "fmt" "strings"
tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi")
type ExtensionItem struct { Name string `json:"name"` Description string `json:"description"` Enabled bool `json:"enabled"` Context bool `json:"context"` Tools []string `json:"tools"` PythonModules []string `json:"python_modules"` Requires []string `json:"requires"` Plugins []string `json:"plugins"` // Overridden means this session has its own choice; otherwise it follows // GlobalEnabled, the default for sessions without one. Overridden bool `json:"overridden"` GlobalEnabled bool `json:"global_enabled"`}
type ExtensionPickerDoneMsg struct{}type ExtensionPickerChangedMsg struct{}
type extensionsLoadedMsg struct { Extensions []ExtensionItem Err error Gen int // NoGlobal reports a daemon that predates global extension defaults. NoGlobal bool}
type extensionToggledMsg struct { Extensions []ExtensionItem Err error Gen int}
type ExtensionPickerModel struct { Conn *daemon.Connection SessionID string Extensions []ExtensionItem Cursor int // Session scopes changes to this session; the page opens on the global // defaults, so a session only diverges once a change is made here. Session bool NoGlobal bool Confirming bool // Inheriting confirms dropping this session's choice instead of a toggle. Inheriting bool Saving bool Loading bool Error string Generation int Width int Height int Styles Styles}
func NewExtensionPickerModel(conn *daemon.Connection, sessionID string) ExtensionPickerModel { return ExtensionPickerModel{ Conn: conn, SessionID: sessionID, Loading: true, Styles: DefaultStyles, }}
func (m *ExtensionPickerModel) SetSize(width, height int) { m.Width = width m.Height = height}
func (m ExtensionPickerModel) Init() tea.Cmd { return m.loadExtensionsCmd(m.Generation)}
func (m ExtensionPickerModel) loadExtensionsCmd(gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil { return extensionsLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen} }
// Check capability health, err := daemon.Request[struct { Capabilities []string `json:"capabilities"` }](context.Background(), m.Conn, "/health", nil) noGlobal := false if err == nil { hasCap := false noGlobal = true for _, c := range health.Capabilities { if c == "session_extensions" { hasCap = true } if c == "global_extensions" { noGlobal = false } } if !hasCap { return extensionsLoadedMsg{ Err: errors.New("daemon upgrade needed for /extensions; when ready, run albedo daemon --stop, then albedo (this clears python variables)"), Gen: gen, } } }
path := fmt.Sprintf("/sessions/%s/extensions", m.SessionID) items, err := daemon.Request[[]ExtensionItem](context.Background(), m.Conn, path, nil) return extensionsLoadedMsg{Extensions: items, Err: err, Gen: gen, NoGlobal: noGlobal} }}
// changeExtensionCmd sends one change: scope "global" or "session" with a// value, or "inherit" to drop this session's choice.func (m ExtensionPickerModel) changeExtensionCmd(name, scope string, enabled bool, gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil { return extensionToggledMsg{Err: errors.New("daemon connection unavailable"), Gen: gen} } path := fmt.Sprintf("/sessions/%s/extensions", m.SessionID) body := map[string]any{"name": name, "scope": scope} if scope != "inherit" { body["enabled"] = enabled } if m.NoGlobal { // Older daemons only know session choices. body = map[string]any{"name": name, "enabled": enabled} } updated, err := daemon.Request[[]ExtensionItem](context.Background(), m.Conn, path, body) return extensionToggledMsg{Extensions: updated, Err: err, Gen: gen} }}
func (m ExtensionPickerModel) capabilitiesList(ext ExtensionItem) []string { var caps []string if ext.Context { caps = append(caps, "context") } if len(ext.Tools) > 0 { caps = append(caps, fmt.Sprintf("tools (%s)", strings.Join(ext.Tools, ", "))) } if len(ext.PythonModules) > 0 { caps = append(caps, fmt.Sprintf("python modules (%s)", strings.Join(ext.PythonModules, ", "))) } return caps}
func (m ExtensionPickerModel) Update(msg tea.Msg) (ExtensionPickerModel, tea.Cmd) { switch msg := msg.(type) { case extensionsLoadedMsg: if msg.Gen != m.Generation { return m, nil } m.Loading = false if msg.Err != nil { m.Error = msg.Err.Error() return m, nil } m.Extensions = msg.Extensions m.Error = "" m.NoGlobal = msg.NoGlobal if m.NoGlobal { m.Session = true } if m.Cursor >= len(m.Extensions) { m.Cursor = max(0, len(m.Extensions)-1) } return m, nil
case extensionToggledMsg: if msg.Gen != m.Generation { return m, nil } m.Saving = false if msg.Err != nil { m.Error = msg.Err.Error() return m, nil } name := "" if m.Cursor < len(m.Extensions) { name = m.Extensions[m.Cursor].Name } m.Extensions = msg.Extensions for i, ext := range m.Extensions { if ext.Name == name { m.Cursor = i break } } m.Confirming = false m.Inheriting = false m.Error = "" return m, func() tea.Msg { return ExtensionPickerChangedMsg{} }
case tea.KeyMsg: if msg.Type == tea.KeyEsc || (msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyCtrlD) { if m.Confirming { m.Confirming = false m.Inheriting = false m.Error = "" return m, nil } return m, func() tea.Msg { return ExtensionPickerDoneMsg{} } }
if m.Loading || m.Saving { return m, nil }
if len(m.Extensions) == 0 && m.Error != "" { if strings.ToLower(msg.String()) == "r" { m.Loading = true m.Error = "" m.Generation++ return m, m.loadExtensionsCmd(m.Generation) } return m, nil }
if m.Confirming { if msg.Type == tea.KeyEnter && len(m.Extensions) > m.Cursor { ext := m.Extensions[m.Cursor] m.Saving = true m.Error = "" m.Generation++ switch { case m.Inheriting: return m, m.changeExtensionCmd(ext.Name, "inherit", false, m.Generation) case m.Session: return m, m.changeExtensionCmd(ext.Name, "session", !ext.Enabled, m.Generation) default: return m, m.changeExtensionCmd(ext.Name, "global", !ext.GlobalEnabled, m.Generation) } } return m, nil }
switch msg.Type { case tea.KeyUp, tea.KeyCtrlP: if m.Cursor > 0 { m.Cursor-- } case tea.KeyDown, tea.KeyCtrlN: if m.Cursor < len(m.Extensions)-1 { m.Cursor++ } case tea.KeySpace, tea.KeyEnter: if len(m.Extensions) > 0 && m.Cursor < len(m.Extensions) { m.Error = "" m.Confirming = true } case tea.KeyRunes: switch msg.String() { case "g": if !m.NoGlobal { m.Session = false } case "s": m.Session = true case "x": if m.Session && !m.NoGlobal && m.Cursor < len(m.Extensions) && m.Extensions[m.Cursor].Overridden { m.Error = "" m.Confirming = true m.Inheriting = true } } } } return m, nil}
func (m ExtensionPickerModel) View() string { var b strings.Builder
head := func(scope string) string { return titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/extensions"), m.Styles.Faint.Render(scope)) + "\n" } if m.Session { b.WriteString(head("this session")) hint := "choices here apply to this session only · g global defaults" if m.NoGlobal { hint = "this daemon only supports per-session choices; restart it for global defaults" } b.WriteString(m.Styles.Faint.Render(inkWrap(hint, m.Width))) } else { b.WriteString(head("global defaults")) b.WriteString(m.Styles.Faint.Render(inkWrap("every session without its own choice follows these · s this session only", m.Width))) } b.WriteString("\n") b.WriteString(DefaultStyles.Warning.Render(ansi.Wrap("changes reload workers and available plugins, bust prompt-cache reuse, and may reset unsavable python variables", max(1, m.Width), " "))) b.WriteString("\n")
if m.Error != "" { b.WriteString(DefaultStyles.Error.Render(m.Error)) b.WriteString("\n") }
if m.Loading { b.WriteString(m.Styles.Faint.Render("loading extensions…\n")) return b.String() }
if len(m.Extensions) == 0 { if m.Error != "" { b.WriteString(keyHints(hint{"r", "retry"}, hint{"esc", "return to chat"}) + "\n") } else { b.WriteString(m.Styles.Faint.Render("no matches\nno extensions installed for this session") + "\n" + inkWrap(keyHints(hint{"↑↓", "select"}, hint{"enter/space", "toggle"}, hint{"esc", "return to chat"}), m.Width)) } return b.String() }
lines := make([]string, len(m.Extensions)) for i, ext := range m.Extensions { on, scope := ext.GlobalEnabled, "" if m.Session { on, scope = ext.Enabled, " follows global" if ext.Overridden { scope = " this session" } } else if ext.Overridden { state := "off" if ext.Enabled { state = "on" } scope = " this session: " + state } status := m.Styles.Faint.Render("off") if on { status = DefaultStyles.Success.Render("on ") } lines[i] = status + " " + ext.Name + m.Styles.Faint.Render(scope) if ext.Description != "" { lines[i] += DefaultStyles.Faint.Render(" " + ext.Description) } } b.WriteString(selectableRows(lines, m.Cursor, m.Height, m.Height, m.Width, m.Styles)) b.WriteByte('\n')
// Current item details if m.Cursor < len(m.Extensions) { current := m.Extensions[m.Cursor] b.WriteString("\n") if current.Description != "" { b.WriteString(current.Description) b.WriteString("\n") }
pluginsStr := "not reported" if len(current.Plugins) > 0 { pluginsStr = strings.Join(current.Plugins, ", ") } b.WriteString(m.Styles.Faint.Render("plugins: " + pluginsStr)) b.WriteString("\n")
caps := m.capabilitiesList(current) capsStr := "not reported" if len(caps) > 0 { capsStr = strings.Join(caps, " · ") } b.WriteString(m.Styles.Faint.Render("capabilities: " + capsStr)) b.WriteString("\n")
reqStr := "none" if len(current.Requires) > 0 { reqStr = strings.Join(current.Requires, ", ") } b.WriteString(m.Styles.Faint.Render("requires: " + reqStr)) b.WriteString("\n")
if m.Confirming { on := current.GlobalEnabled if m.Session { on = current.Enabled } actionWord := "enable" if on { actionWord = "disable" } choice := hint{"enter", "confirm"} if m.Error != "" { choice = hint{"enter", "retry"} } var confirmMsg string switch { case m.Inheriting: confirmMsg = fmt.Sprintf("drop this session's choice for %s and follow the global default?", current.Name) case m.Session: confirmMsg = fmt.Sprintf("%s %s for this session only and reload its workers?", actionWord, current.Name) default: confirmMsg = fmt.Sprintf("%s %s for every session that follows the global default?", actionWord, current.Name) } b.WriteString(DefaultStyles.Warning.Render(confirmMsg) + " " + keyHints(choice, hint{"esc", "cancel"})) b.WriteString("\n") } }
if m.Saving { currName := "extension" if m.Cursor < len(m.Extensions) { currName = m.Extensions[m.Cursor].Name } b.WriteString(m.Styles.Faint.Render(fmt.Sprintf("reloading workers for %s…", currName))) } else if m.Confirming { b.WriteString(m.Styles.Faint.Render("waiting for confirmation")) } else { keys := []hint{{"↑↓", "select"}, {"enter/space", "toggle"}, {"s", "this session"}, {"esc", "return to chat"}} if m.Session { keys = []hint{{"↑↓", "select"}, {"enter/space", "toggle"}, {"x", "follow global"}, {"g", "global defaults"}, {"esc", "return to chat"}} if m.NoGlobal { keys = []hint{{"↑↓", "select"}, {"enter/space", "toggle"}, {"esc", "return to chat"}} } } b.WriteString(inkWrap(keyHints(keys...), m.Width)) }
return b.String()}