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		if _, exists := stateNames[name]; exists {
 83			continue
 84		}
 85		stateNames[name] = struct{}{}
 86		icon := t.Resource.OnlineIcon.String()
 87		if state.State == skills.StateError {
 88			icon = t.Resource.ErrorIcon.String()
 89		}
 90		items = append(items, skillStatusItem{
 91			icon:  icon,
 92			name:  name,
 93			title: t.Resource.Name.Render(name),
 94		})
 95	}
 96
 97	builtin := cachedBuiltinSkills()
 98	slices.SortStableFunc(builtin, func(a, b *skills.Skill) int {
 99		return strings.Compare(a.Name, b.Name)
100	})
101	for _, skill := range builtin {
102		if _, ok := stateNames[skill.Name]; ok {
103			continue
104		}
105		if disabledSet[skill.Name] {
106			continue
107		}
108		items = append(items, skillStatusItem{
109			icon:  t.Resource.OnlineIcon.String(),
110			name:  skill.Name,
111			title: t.Resource.Name.Render(skill.Name),
112		})
113	}
114
115	slices.SortStableFunc(items, func(a, b skillStatusItem) int {
116		return strings.Compare(a.name, b.name)
117	})
118
119	return items
120}
121
122func skillsList(t *styles.Styles, items []skillStatusItem, width, maxItems int) string {
123	if maxItems <= 0 {
124		return ""
125	}
126
127	if len(items) > maxItems {
128		visibleItems := items[:maxItems-1]
129		remaining := len(items) - (maxItems - 1)
130		items = append(visibleItems, skillStatusItem{
131			name:  "more",
132			title: t.Resource.AdditionalText.Render(fmt.Sprintf("…and %d more", remaining)),
133		})
134	}
135
136	renderedItems := make([]string, 0, len(items))
137	for _, item := range items {
138		renderedItems = append(renderedItems, common.Status(t, common.StatusOpts{
139			Icon:        item.icon,
140			Title:       item.title,
141			Description: item.description,
142		}, width))
143	}
144	return lipgloss.JoinVertical(lipgloss.Left, renderedItems...)
145}