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}