package tui
import (
"albedo/cli/internal/daemon"
"context"
"errors"
"fmt"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
type ExtensionItem = daemon.ExtensionSummary
type ExtensionPickerDoneMsg struct{}
type ExtensionPickerChangedMsg struct{}
type extensionsLoadedMsg struct {
Err error
Extensions []ExtensionItem
Gen int
}
type extensionToggledMsg struct {
Err error
Extensions []ExtensionItem
Gen int
}
type ExtensionPickerModel struct {
Styles Styles
Conn *daemon.Connection
SessionID string
Extensions []ExtensionItem
page
// 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
Confirming bool
// Inheriting confirms dropping this session's choice instead of a toggle.
Inheriting bool
}
func NewExtensionPickerModel(conn *daemon.Connection, sessionID string) ExtensionPickerModel {
return ExtensionPickerModel{
Conn: conn,
SessionID: sessionID,
page: page{Loading: true, Generation: nextPageGeneration()},
Styles: DefaultStyles,
}
}
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}
}
items, err := daemon.ListExtensions(context.Background(), m.Conn, m.SessionID)
return extensionsLoadedMsg{Extensions: items, Err: err, Gen: gen}
}
}
// 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}
}
body := daemon.ExtensionSelectionRequest{Name: name, Scope: scope}
if scope != "inherit" {
body.Enabled = &enabled
}
updated, err := daemon.SelectExtension(context.Background(), m.Conn, m.SessionID, 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 !m.settle(msg.Gen, msg.Err, &m.Loading) {
return m, nil
}
m.Extensions = msg.Extensions
m.Error = ""
if m.Cursor >= len(m.Extensions) {
m.Cursor = max(0, len(m.Extensions)-1)
}
return m, nil
case extensionToggledMsg:
if !m.settle(msg.Gen, msg.Err, &m.Saving) {
return m, nil
}
name := ""
if m.Cursor < len(m.Extensions) {
name = m.Extensions[m.Cursor].Name
}
m.Extensions = msg.Extensions
if idx := slices.IndexFunc(m.Extensions, func(e ExtensionItem) bool { return e.Name == name }); idx >= 0 {
m.Cursor = idx
}
m.Confirming, m.Inheriting, m.Error = false, false, ""
return m, func() tea.Msg { return ExtensionPickerChangedMsg{} }
case tea.KeyPressMsg:
switch msg.String() {
case "esc", "ctrl+c", "ctrl+d":
if m.Confirming {
m.Confirming, m.Inheriting, m.Error = false, false, ""
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.EqualFold(msg.String(), "r") {
m.Loading, m.Error = true, ""
m.Generation = nextPageGeneration()
return m, m.loadExtensionsCmd(m.Generation)
}
return m, nil
}
if m.Confirming {
if msg.String() == "enter" && len(m.Extensions) > m.Cursor {
ext := m.Extensions[m.Cursor]
m.Saving, m.Error = true, ""
m.Generation = nextPageGeneration()
scope, val := "global", !ext.GlobalEnabled
if m.Inheriting {
scope, val = "inherit", false
} else if m.Session {
scope, val = "session", !ext.Enabled
}
return m, m.changeExtensionCmd(ext.Name, scope, val, m.Generation)
}
return m, nil
}
if m.step(msg.String(), len(m.Extensions)) {
return m, nil
}
switch msg.String() {
case "space", "enter":
if len(m.Extensions) > 0 && m.Cursor < len(m.Extensions) {
if reason := m.Extensions[m.Cursor].Quarantined; reason != "" {
m.Error = reason
return m, nil
}
m.Error = ""
m.Confirming = true
}
case "g":
m.Session = false
case "s":
m.Session = true
case "o":
if m.Cursor < len(m.Extensions) && m.Extensions[m.Cursor].Name == "webhooks" && m.Extensions[m.Cursor].Enabled {
return m, func() tea.Msg { return ChatOpenWebhooksPageMsg{} }
}
case "x":
if m.Session && m.Cursor < len(m.Extensions) && m.Extensions[m.Cursor].Overridden {
m.Error = ""
m.Confirming, m.Inheriting = true, true
}
}
}
return m, nil
}
func (m ExtensionPickerModel) View() string {
var b strings.Builder
scope, note := "global defaults", "Sessions without their own choice use these defaults · s to change this session only"
if m.Session {
scope, note = "this session", "Changes here affect only this session · g to edit global defaults"
}
b.WriteString(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/extensions"), m.Styles.Faint.Render(scope)))
b.WriteByte('\n')
b.WriteString(m.Styles.Faint.Render(inkWrap(note, m.Width)))
b.WriteByte('\n')
b.WriteString(DefaultStyles.Warning.Render(ansi.Wrap("Changes reload the affected sessions, prevent reuse of cached prompts, and may lose Python variables that cannot be saved.", max(1, m.Width), " ")))
b.WriteByte('\n')
if m.Error != "" {
b.WriteString(DefaultStyles.Error.Render(m.Error))
b.WriteByte('\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"}))
b.WriteByte('\n')
} else {
b.WriteString(m.Styles.Faint.Render("No extensions are available for this session."))
b.WriteByte('\n')
b.WriteString(inkWrap(keyHints(hint{"↑↓", "select"}, hint{"enter/space", "toggle"}, hint{"esc", "return to chat"}), m.Width))
}
return b.String()
}
if !m.Saving && m.Confirming && m.Cursor < len(m.Extensions) {
current := m.Extensions[m.Cursor]
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("This session will follow the global default for %s. Remove its own choice?", current.Name)
case m.Session:
confirmMsg = fmt.Sprintf("This will reload this session. %s %s for this session only?", actionWord, current.Name)
default:
confirmMsg = fmt.Sprintf("Sessions following global defaults will use this change. %s %s globally?", actionWord, current.Name)
}
for line := range strings.SplitSeq(ansi.Wrap(confirmMsg, max(1, m.Width), " "), "\n") {
b.WriteString(DefaultStyles.Warning.Render(line))
b.WriteByte('\n')
}
b.WriteString(inkWrap(keyHints(choice, hint{"esc", "cancel"}), m.Width))
b.WriteByte('\n')
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
}
var status string
switch {
case ext.Quarantined != "":
status, scope = DefaultStyles.Error.Render("bad"), " quarantined"
case on:
status = DefaultStyles.Success.Render("on ")
default:
status = m.Styles.Faint.Render("off")
}
line := status + " " + ext.Name + m.Styles.Faint.Render(scope)
if ext.Description != "" {
line += DefaultStyles.Faint.Render(" " + ext.Description)
}
lines[i] = line
}
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.WriteByte('\n')
if current.Description != "" {
b.WriteString(current.Description)
b.WriteByte('\n')
}
joinOr := func(items []string, sep, empty string) string {
if len(items) == 0 {
return empty
}
return strings.Join(items, sep)
}
b.WriteString(m.Styles.Faint.Render("plugins: " + joinOr(current.Plugins, ", ", "not reported")))
b.WriteByte('\n')
b.WriteString(m.Styles.Faint.Render("capabilities: " + joinOr(m.capabilitiesList(current), " · ", "not reported")))
b.WriteByte('\n')
b.WriteString(m.Styles.Faint.Render("requires: " + joinOr(current.Requires, ", ", "none")))
b.WriteByte('\n')
if current.Quarantined != "" {
b.WriteString(DefaultStyles.Error.Render(inkWrap("quarantined: "+current.Quarantined, m.Width)))
b.WriteByte('\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 %s…", currName)))
} else if m.Confirming {
b.WriteString(m.Styles.Faint.Render("waiting for confirmation"))
} else {
keys := []hint{{"↑↓", "select"}, {"enter/space", "toggle"}}
if !m.Session {
keys = append(keys, hint{"s", "this session"})
} else {
keys = append(keys, hint{"x", "follow global"}, hint{"g", "global defaults"})
}
keys = append(keys, hint{"esc", "return to chat"})
if m.Cursor < len(m.Extensions) && m.Extensions[m.Cursor].Name == "webhooks" && m.Extensions[m.Cursor].Enabled {
keys = append(keys, hint{"o", "open webhooks"})
}
b.WriteString(inkWrap(keyHints(keys...), m.Width))
}
return b.String()
}