skills.go

  1package model
  2
  3import (
  4	"fmt"
  5	"path/filepath"
  6	"slices"
  7	"strings"
  8	"sync"
  9
 10	"charm.land/lipgloss/v2"
 11	"github.com/charmbracelet/crush/internal/skills"
 12	"github.com/charmbracelet/crush/internal/ui/common"
 13	"github.com/charmbracelet/crush/internal/ui/styles"
 14)
 15
 16type skillStatusItem struct {
 17	icon  string
 18	name  string
 19	title string
 20	// description is reserved for future use (e.g. showing error details).
 21	description string
 22}
 23
 24var builtinSkillsCache struct {
 25	once   sync.Once
 26	skills []*skills.Skill
 27}
 28
 29func cachedBuiltinSkills() []*skills.Skill {
 30	builtinSkillsCache.once.Do(func() {
 31		builtinSkillsCache.skills = skills.DiscoverBuiltin()
 32	})
 33	return builtinSkillsCache.skills
 34}
 35
 36// skillsInfo renders the skill discovery status section showing loaded and
 37// invalid skills.
 38func (m *UI) skillsInfo(width, maxItems int, isSection bool) string {
 39	t := m.com.Styles
 40
 41	title := t.Resource.Heading.Render("Skills")
 42	if isSection {
 43		title = common.Section(t, title, width)
 44	}
 45
 46	items := m.skillStatusItems()
 47	if len(items) == 0 {
 48		list := t.Resource.AdditionalText.Render("None")
 49		return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
 50	}
 51
 52	list := skillsList(t, items, width, maxItems)
 53	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
 54}
 55
 56func (m *UI) skillStatusItems() []skillStatusItem {
 57	t := m.com.Styles
 58	var items []skillStatusItem
 59	stateNames := make(map[string]struct{}, len(m.skillStates))
 60
 61	disabledSet := make(map[string]bool)
 62	if m.com != nil && m.com.Workspace != nil {
 63		if cfg := m.com.Config(); cfg != nil {
 64			for _, name := range cfg.Options.DisabledSkills {
 65				disabledSet[name] = true
 66			}
 67		}
 68	}
 69
 70	states := slices.Clone(m.skillStates)
 71	slices.SortStableFunc(states, func(a, b *skills.SkillState) int {
 72		return strings.Compare(a.Path, b.Path)
 73	})
 74	for _, state := range states {
 75		name := state.Name
 76		if name == "" {
 77			name = filepath.Base(filepath.Dir(state.Path))
 78		}
 79		if disabledSet[name] {
 80			continue
 81		}
 82		stateNames[name] = struct{}{}
 83		icon := t.Resource.OnlineIcon.String()
 84		if state.State == skills.StateError {
 85			icon = t.Resource.ErrorIcon.String()
 86		}
 87		items = append(items, skillStatusItem{
 88			icon:  icon,
 89			name:  name,
 90			title: t.Resource.Name.Render(name),
 91		})
 92	}
 93
 94	builtin := cachedBuiltinSkills()
 95	slices.SortStableFunc(builtin, func(a, b *skills.Skill) int {
 96		return strings.Compare(a.Name, b.Name)
 97	})
 98	for _, skill := range builtin {
 99		if _, ok := stateNames[skill.Name]; ok {
100			continue
101		}
102		if disabledSet[skill.Name] {
103			continue
104		}
105		items = append(items, skillStatusItem{
106			icon:  t.Resource.OnlineIcon.String(),
107			name:  skill.Name,
108			title: t.Resource.Name.Render(skill.Name),
109		})
110	}
111
112	slices.SortStableFunc(items, func(a, b skillStatusItem) int {
113		return strings.Compare(a.name, b.name)
114	})
115
116	return items
117}
118
119func skillsList(t *styles.Styles, items []skillStatusItem, width, maxItems int) string {
120	if maxItems <= 0 {
121		return ""
122	}
123
124	if len(items) > maxItems {
125		visibleItems := items[:maxItems-1]
126		remaining := len(items) - (maxItems - 1)
127		items = append(visibleItems, skillStatusItem{
128			name:  "more",
129			title: t.Resource.AdditionalText.Render(fmt.Sprintf("…and %d more", remaining)),
130		})
131	}
132
133	renderedItems := make([]string, 0, len(items))
134	for _, item := range items {
135		renderedItems = append(renderedItems, common.Status(t, common.StatusOpts{
136			Icon:        item.icon,
137			Title:       item.title,
138			Description: item.description,
139		}, width))
140	}
141	return lipgloss.JoinVertical(lipgloss.Left, renderedItems...)
142}