on main help sign in
feat(ui): add skills discovery status to sidebar and landing page (#2384)
b37649ehuaiyuWangh
internal/app/app.go+2
@@ -34,6 +34,7 @@ import (
34 "github.com/charmbracelet/crush/internal/pubsub"
34 "github.com/charmbracelet/crush/internal/pubsub"
35 "github.com/charmbracelet/crush/internal/session"
35 "github.com/charmbracelet/crush/internal/session"
36 "github.com/charmbracelet/crush/internal/shell"
36 "github.com/charmbracelet/crush/internal/shell"
37+ "github.com/charmbracelet/crush/internal/skills"
37 "github.com/charmbracelet/crush/internal/ui/anim"
38 "github.com/charmbracelet/crush/internal/ui/anim"
38 "github.com/charmbracelet/crush/internal/ui/styles"
39 "github.com/charmbracelet/crush/internal/ui/styles"
39 "github.com/charmbracelet/crush/internal/update"
40 "github.com/charmbracelet/crush/internal/update"
@@ -481,6 +482,7 @@ func (app *App) setupEvents() {
481 setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
482 setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
482 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
483 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
483 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
484 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
485+ setupSubscriber(ctx, app.serviceEventsWG, "skills", skills.SubscribeEvents, app.events)
484 cleanupFunc := func(context.Context) error {
486 cleanupFunc := func(context.Context) error {
485 cancel()
487 cancel()
486 app.serviceEventsWG.Wait()
488 app.serviceEventsWG.Wait()
internal/skills/skills.go+48
@@ -3,6 +3,7 @@
3package skills
3package skills
4
4
5import (
5import (
6+ "context"
6 "errors"
7 "errors"
7 "fmt"
8 "fmt"
8 "log/slog"
9 "log/slog"
@@ -15,6 +16,7 @@ import (
15 "sync"
16 "sync"
16
17
17 "github.com/charlievieth/fastwalk"
18 "github.com/charlievieth/fastwalk"
19+ "github.com/charmbracelet/crush/internal/pubsub"
18 "gopkg.in/yaml.v3"
20 "gopkg.in/yaml.v3"
19)
21)
20
22
@@ -43,6 +45,36 @@ type Skill struct {
43 Builtin bool `yaml:"-" json:"builtin"`
45 Builtin bool `yaml:"-" json:"builtin"`
44}
46}
45
47
48+// DiscoveryState represents the outcome of discovering a single skill file.
49+type DiscoveryState int
50+
51+const (
52+ // StateNormal indicates the skill was parsed and validated successfully.
53+ StateNormal DiscoveryState = iota
54+ // StateError indicates discovery encountered a scan/parse/validate error.
55+ StateError
56+)
57+
58+// SkillState represents the latest discovery status of a skill file.
59+type SkillState struct {
60+ Name string
61+ Path string
62+ State DiscoveryState
63+ Err error
64+}
65+
66+// Event is published when skill discovery completes.
67+type Event struct {
68+ States []*SkillState
69+}
70+
71+var broker = pubsub.NewBroker[Event]()
72+
73+// SubscribeEvents returns a channel that receives events when skill discovery state changes.
74+func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
75+ return broker.Subscribe(ctx)
76+}
77+
46// Validate checks if the skill meets spec requirements.
78// Validate checks if the skill meets spec requirements.
47func (s *Skill) Validate() error {
79func (s *Skill) Validate() error {
48 var errs []error
80 var errs []error
@@ -141,8 +173,19 @@ func splitFrontmatter(content string) (frontmatter, body string, err error) {
141// Discover finds all valid skills in the given paths.
173// Discover finds all valid skills in the given paths.
142func Discover(paths []string) []*Skill {
174func Discover(paths []string) []*Skill {
143 var skills []*Skill
175 var skills []*Skill
176+ var states []*SkillState
144 var mu sync.Mutex
177 var mu sync.Mutex
145 seen := make(map[string]bool)
178 seen := make(map[string]bool)
179+ addState := func(name, path string, state DiscoveryState, err error) {
180+ mu.Lock()
181+ states = append(states, &SkillState{
182+ Name: name,
183+ Path: path,
184+ State: state,
185+ Err: err,
186+ })
187+ mu.Unlock()
188+ }
146
189
147 for _, base := range paths {
190 for _, base := range paths {
148 // We use fastwalk with Follow: true instead of filepath.WalkDir because
191 // We use fastwalk with Follow: true instead of filepath.WalkDir because
@@ -156,6 +199,7 @@ func Discover(paths []string) []*Skill {
156 err := fastwalk.Walk(&conf, base, func(path string, d os.DirEntry, err error) error {
199 err := fastwalk.Walk(&conf, base, func(path string, d os.DirEntry, err error) error {
157 if err != nil {
200 if err != nil {
158 slog.Warn("Failed to walk skills path entry", "base", base, "path", path, "error", err)
201 slog.Warn("Failed to walk skills path entry", "base", base, "path", path, "error", err)
202+ addState("", path, StateError, err)
159 return nil
203 return nil
160 }
204 }
161 if d.IsDir() || d.Name() != SkillFileName {
205 if d.IsDir() || d.Name() != SkillFileName {
@@ -171,16 +215,19 @@ func Discover(paths []string) []*Skill {
171 skill, err := Parse(path)
215 skill, err := Parse(path)
172 if err != nil {
216 if err != nil {
173 slog.Warn("Failed to parse skill file", "path", path, "error", err)
217 slog.Warn("Failed to parse skill file", "path", path, "error", err)
218+ addState("", path, StateError, err)
174 return nil
219 return nil
175 }
220 }
176 if err := skill.Validate(); err != nil {
221 if err := skill.Validate(); err != nil {
177 slog.Warn("Skill validation failed", "path", path, "error", err)
222 slog.Warn("Skill validation failed", "path", path, "error", err)
223+ addState(skill.Name, path, StateError, err)
178 return nil
224 return nil
179 }
225 }
180 slog.Debug("Successfully loaded skill", "name", skill.Name, "path", path)
226 slog.Debug("Successfully loaded skill", "name", skill.Name, "path", path)
181 mu.Lock()
227 mu.Lock()
182 skills = append(skills, skill)
228 skills = append(skills, skill)
183 mu.Unlock()
229 mu.Unlock()
230+ addState(skill.Name, path, StateNormal, nil)
184 return nil
231 return nil
185 })
232 })
186 if err != nil {
233 if err != nil {
@@ -198,6 +245,7 @@ func Discover(paths []string) []*Skill {
198 return left < right
245 return left < right
199 })
246 })
200
247
248+ broker.Publish(pubsub.UpdatedEvent, Event{States: states})
201 return skills
249 return skills
202}
250}
203
251
internal/skills/skills_test.go+56−2
@@ -1,6 +1,7 @@
1package skills
1package skills
2
2
3import (
3import (
4+ "context"
4 "os"
5 "os"
5 "path/filepath"
6 "path/filepath"
6 "strings"
7 "strings"
@@ -215,8 +216,7 @@ func TestSkillValidate(t *testing.T) {
215}
216}
216
217
217func TestDiscover(t *testing.T) {
218func TestDiscover(t *testing.T) {
218− t.Parallel()
219+ // Not parallel: shares global broker with other Discover tests.
219−
220 tmpDir := t.TempDir()
220 tmpDir := t.TempDir()
221
221
222 // Create valid skill 1.
222 // Create valid skill 1.
@@ -248,7 +248,31 @@ description: Name doesn't match directory.
248---
248---
249`), 0o644))
249`), 0o644))
250
250
251+ ctx, cancel := context.WithCancel(context.Background())
252+ defer cancel()
253+ ch := SubscribeEvents(ctx)
254+
251 skills := Discover([]string{tmpDir})
255 skills := Discover([]string{tmpDir})
256+
257+ evt := <-ch
258+ states := evt.Payload.States
259+ var normalCount int
260+ var errorCount int
261+ var hasInvalidDir bool
262+ for _, state := range states {
263+ if state.State == StateNormal {
264+ normalCount++
265+ }
266+ if state.State == StateError {
267+ errorCount++
268+ if strings.Contains(state.Path, "invalid-dir") {
269+ hasInvalidDir = true
270+ }
271+ }
272+ }
273+ require.Equal(t, 2, normalCount)
274+ require.Equal(t, 1, errorCount)
275+ require.True(t, hasInvalidDir)
252 require.Len(t, skills, 2)
276 require.Len(t, skills, 2)
253 require.Equal(t, []string{"skill-two", "skill-one"}, []string{skills[0].Name, skills[1].Name})
277 require.Equal(t, []string{"skill-two", "skill-one"}, []string{skills[0].Name, skills[1].Name})
254
278
@@ -260,6 +284,36 @@ description: Name doesn't match directory.
260 require.True(t, names["skill-two"])
284 require.True(t, names["skill-two"])
261}
285}
262
286
287+func TestDiscoverEmptyDir(t *testing.T) {
288+ // Not parallel: shares global broker with other Discover tests.
289+
290+ tmpDir := t.TempDir()
291+
292+ ctx, cancel := context.WithCancel(context.Background())
293+ defer cancel()
294+ ch := SubscribeEvents(ctx)
295+
296+ skills := Discover([]string{tmpDir})
297+
298+ evt := <-ch
299+ require.Empty(t, evt.Payload.States)
300+ require.Empty(t, skills)
301+}
302+
303+func TestDiscoverMissingPath(t *testing.T) {
304+ // Not parallel: shares global broker with other Discover tests.
305+
306+ ctx, cancel := context.WithCancel(context.Background())
307+ defer cancel()
308+ ch := SubscribeEvents(ctx)
309+
310+ skills := Discover([]string{filepath.Join(t.TempDir(), "missing")})
311+
312+ evt := <-ch
313+ require.Empty(t, evt.Payload.States)
314+ require.Empty(t, skills)
315+}
316+
263func TestToPromptXML(t *testing.T) {
317func TestToPromptXML(t *testing.T) {
264 t.Parallel()
318 t.Parallel()
265
319
internal/ui/model/landing.go+3−2
@@ -39,12 +39,13 @@ func (m *UI) landingView() string {
39 layout.Fill(1),
39 layout.Fill(1),
40 ).Split(m.layout.main).Assign(new(image.Rectangle), &remainingHeightArea)
40 ).Split(m.layout.main).Assign(new(image.Rectangle), &remainingHeightArea)
41
41
42− mcpLspSectionWidth := min(30, (width-1)/2)
42+ mcpLspSectionWidth := min(30, (width-2)/3)
43
43
44 lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
44 lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
45 mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
45 mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
46+ skillsSection := m.skillsInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
46
47
47− content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
48+ content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection, " ", skillsSection)
48
49
49 return lipgloss.NewStyle().
50 return lipgloss.NewStyle().
50 Width(width).
51 Width(width).
internal/ui/model/sidebar.go+79−35
@@ -56,51 +56,73 @@ func (m *UI) modelInfo(width int) string {
56 return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
56 return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
57}
57}
58
58
59−// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
59+// getDynamicHeightLimits will give us the num of items to show in each section based on the height
60// some items are more important than others.
60// some items are more important than others.
61−func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) {
61+func getDynamicHeightLimits(availableHeight, fileCount, lspCount, mcpCount, skillCount int) (maxFiles, maxLSPs, maxMCPs, maxSkills int) {
62 const (
62 const (
63− minItemsPerSection = 2
63+ minItemsPerSection = 2
64+ // Keep these high so dynamic layout uses available sidebar space
65+ // instead of hitting small hard limits.
64− defaultMaxFilesShown = 10
66+ defaultMaxFilesShown = 1000
65− defaultMaxLSPsShown = 8
67+ defaultMaxLSPsShown = 1000
66− defaultMaxMCPsShown = 8
68+ defaultMaxMCPsShown = 1000
69+ defaultMaxSkillsShown = 1000
67 minAvailableHeightLimit = 10
70 minAvailableHeightLimit = 10
68 )
71 )
69
72
70− // If we have very little space, use minimum values
71 if availableHeight < minAvailableHeightLimit {
73 if availableHeight < minAvailableHeightLimit {
72− return minItemsPerSection, minItemsPerSection, minItemsPerSection
74+ return minItemsPerSection, minItemsPerSection, minItemsPerSection, minItemsPerSection
73 }
75 }
74
76
75− // Distribute available height among the three sections
77+ maxFiles = minItemsPerSection
76− // Give priority to files, then LSPs, then MCPs
78+ maxLSPs = minItemsPerSection
77− totalSections := 3
79+ maxMCPs = minItemsPerSection
78− heightPerSection := availableHeight / totalSections
80+ maxSkills = minItemsPerSection
79−
81+
80− // Calculate limits for each section, ensuring minimums
82+ remainingHeight := max(0, availableHeight-(minItemsPerSection*4))
81− maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection))
83+
82− maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection))
84+ sectionValues := []*int{&maxFiles, &maxLSPs, &maxMCPs, &maxSkills}
83− maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection))
85+ sectionCaps := []int{defaultMaxFilesShown, defaultMaxLSPsShown, defaultMaxMCPsShown, defaultMaxSkillsShown}
84−
86+ sectionNeeds := []int{max(0, fileCount-maxFiles), max(0, lspCount-maxLSPs), max(0, mcpCount-maxMCPs), max(0, skillCount-maxSkills)}
85− // If we have extra space, give it to files first
87+
88+ for remainingHeight > 0 {
89+ allocated := false
90+ for i, section := range sectionValues {
86− remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
91+ if remainingHeight == 0 {
87− if remainingHeight > 0 {
92+ break
88− extraForFiles := min(remainingHeight, defaultMaxFilesShown-maxFiles)
93+ }
89− maxFiles += extraForFiles
94+ if sectionNeeds[i] == 0 || *section >= sectionCaps[i] {
90− remainingHeight -= extraForFiles
95+ continue
91−
96+ }
97+ *section = *section + 1
98+ sectionNeeds[i]--
99+ remainingHeight--
100+ allocated = true
101+ }
102+ if !allocated {
103+ break
104+ }
105+ }
106+
92− if remainingHeight > 0 {
107+ for remainingHeight > 0 {
93− extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs)
108+ allocated := false
94− maxLSPs += extraForLSPs
109+ for i, section := range sectionValues {
95− remainingHeight -= extraForLSPs
96−
97− if remainingHeight > 0 {
110+ if remainingHeight == 0 {
98− maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs)
111+ break
112+ }
113+ if *section >= sectionCaps[i] {
114+ continue
99 }
115 }
116+ *section = *section + 1
117+ remainingHeight--
118+ allocated = true
119+ }
120+ if !allocated {
121+ break
100 }
122 }
101 }
123 }
102
124
103− return maxFiles, maxLSPs, maxMCPs
125+ return maxFiles, maxLSPs, maxMCPs, maxSkills
104}
126}
105
127
106// sidebar renders the chat sidebar containing session title, working
128// sidebar renders the chat sidebar containing session title, working
@@ -142,11 +164,31 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
142 layout.Len(lipgloss.Height(sidebarHeader)),
164 layout.Len(lipgloss.Height(sidebarHeader)),
143 layout.Fill(1),
165 layout.Fill(1),
144 ).Split(m.layout.sidebar).Assign(new(image.Rectangle), &remainingHeightArea)
166 ).Split(m.layout.sidebar).Assign(new(image.Rectangle), &remainingHeightArea)
145− remainingHeight := remainingHeightArea.Dy() - 10
167+ remainingHeight := remainingHeightArea.Dy() - 6
168+ filesCount := 0
169+ for _, f := range m.sessionFiles {
170+ if f.Additions == 0 && f.Deletions == 0 {
171+ continue
172+ }
173+ filesCount++
174+ }
175+
176+ lspsCount := len(m.lspStates)
177+
178+ mcpsCount := 0
179+ for _, mcpCfg := range m.com.Config().MCP.Sorted() {
180+ if _, ok := m.mcpStates[mcpCfg.Name]; ok {
181+ mcpsCount++
182+ }
183+ }
184+
185+ skillsCount := len(m.skillStatusItems())
186+
146− maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight)
187+ maxFiles, maxLSPs, maxMCPs, maxSkills := getDynamicHeightLimits(remainingHeight, filesCount, lspsCount, mcpsCount, skillsCount)
147
188
148 lspSection := m.lspInfo(width, maxLSPs, true)
189 lspSection := m.lspInfo(width, maxLSPs, true)
149 mcpSection := m.mcpInfo(width, maxMCPs, true)
190 mcpSection := m.mcpInfo(width, maxMCPs, true)
191+ skillsSection := m.skillsInfo(width, maxSkills, true)
150 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
192 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
151
193
152 uv.NewStyledString(
194 uv.NewStyledString(
@@ -162,6 +204,8 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
162 lspSection,
204 lspSection,
163 "",
205 "",
164 mcpSection,
206 mcpSection,
207+ "",
208+ skillsSection,
165 ),
209 ),
166 ),
210 ),
167 ).Draw(scr, area)
211 ).Draw(scr, area)
internal/ui/model/skills.gonew file+127
@@ -0,0 +1,127 @@
1+package model
2+
3+import (
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+
16+type 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+
24+var builtinSkillsCache struct {
25+ once sync.Once
26+ skills []*skills.Skill
27+}
28+
29+func 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.
38+func (m *UI) skillsInfo(width, maxItems int, isSection bool) string {
39+ t := m.com.Styles
40+
41+ title := t.ResourceGroupTitle.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.ResourceAdditionalText.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+
56+func (m *UI) skillStatusItems() []skillStatusItem {
57+ t := m.com.Styles
58+ var items []skillStatusItem
59+ stateNames := make(map[string]struct{}, len(m.skillStates))
60+
61+ states := slices.Clone(m.skillStates)
62+ slices.SortStableFunc(states, func(a, b *skills.SkillState) int {
63+ return strings.Compare(a.Path, b.Path)
64+ })
65+ for _, state := range states {
66+ name := state.Name
67+ if name == "" {
68+ name = filepath.Base(filepath.Dir(state.Path))
69+ }
70+ stateNames[name] = struct{}{}
71+ icon := t.ResourceOnlineIcon.String()
72+ if state.State == skills.StateError {
73+ icon = t.ResourceErrorIcon.String()
74+ }
75+ items = append(items, skillStatusItem{
76+ icon: icon,
77+ name: name,
78+ title: t.ResourceName.Render(name),
79+ })
80+ }
81+
82+ builtin := cachedBuiltinSkills()
83+ slices.SortStableFunc(builtin, func(a, b *skills.Skill) int {
84+ return strings.Compare(a.Name, b.Name)
85+ })
86+ for _, skill := range builtin {
87+ if _, ok := stateNames[skill.Name]; ok {
88+ continue
89+ }
90+ items = append(items, skillStatusItem{
91+ icon: t.ResourceOnlineIcon.String(),
92+ name: skill.Name,
93+ title: t.ResourceName.Render(skill.Name),
94+ })
95+ }
96+
97+ slices.SortStableFunc(items, func(a, b skillStatusItem) int {
98+ return strings.Compare(a.name, b.name)
99+ })
100+
101+ return items
102+}
103+
104+func skillsList(t *styles.Styles, items []skillStatusItem, width, maxItems int) string {
105+ if maxItems <= 0 {
106+ return ""
107+ }
108+
109+ if len(items) > maxItems {
110+ visibleItems := items[:maxItems-1]
111+ remaining := len(items) - (maxItems - 1)
112+ items = append(visibleItems, skillStatusItem{
113+ name: "more",
114+ title: t.ResourceAdditionalText.Render(fmt.Sprintf("…and %d more", remaining)),
115+ })
116+ }
117+
118+ renderedItems := make([]string, 0, len(items))
119+ for _, item := range items {
120+ renderedItems = append(renderedItems, common.Status(t, common.StatusOpts{
121+ Icon: item.icon,
122+ Title: item.title,
123+ Description: item.description,
124+ }, width))
125+ }
126+ return lipgloss.JoinVertical(lipgloss.Left, renderedItems...)
127+}
internal/ui/model/skills_test.gonew file+58
@@ -0,0 +1,58 @@
1+package model
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/skills"
7+ "github.com/charmbracelet/crush/internal/ui/common"
8+ uistyles "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// TestSkillStatusItemsIncludesBuiltinSkills verifies sidebar skills include
13+// both runtime-discovered skill states and builtin skills that may not have
14+// emitted a SkillState event yet.
15+func TestSkillStatusItemsIncludesBuiltinSkills(t *testing.T) {
16+ t.Parallel()
17+
18+ st := uistyles.DefaultStyles()
19+ ui := &UI{
20+ com: &common.Common{Styles: &st},
21+ skillStates: []*skills.SkillState{
22+ {Name: "go-doc", Path: "/tmp/go-doc/SKILL.md", State: skills.StateNormal},
23+ },
24+ }
25+
26+ items := ui.skillStatusItems()
27+ require.NotEmpty(t, items)
28+
29+ var hasGoDoc bool
30+ for _, item := range items {
31+ if item.title == st.ResourceName.Render("go-doc") {
32+ hasGoDoc = true
33+ break
34+ }
35+ }
36+ require.True(t, hasGoDoc)
37+
38+ builtinSkills := skills.DiscoverBuiltin()
39+ require.NotEmpty(t, builtinSkills)
40+
41+ var hasBuiltin bool
42+ for _, skill := range builtinSkills {
43+ if skill.Name == "go-doc" {
44+ continue
45+ }
46+ expected := st.ResourceName.Render(skill.Name)
47+ for _, item := range items {
48+ if item.title == expected {
49+ hasBuiltin = true
50+ break
51+ }
52+ }
53+ if hasBuiltin {
54+ break
55+ }
56+ }
57+ require.True(t, hasBuiltin)
58+}
internal/ui/model/ui.go+10−3
@@ -38,6 +38,7 @@ import (
38 "github.com/charmbracelet/crush/internal/permission"
38 "github.com/charmbracelet/crush/internal/permission"
39 "github.com/charmbracelet/crush/internal/pubsub"
39 "github.com/charmbracelet/crush/internal/pubsub"
40 "github.com/charmbracelet/crush/internal/session"
40 "github.com/charmbracelet/crush/internal/session"
41+ "github.com/charmbracelet/crush/internal/skills"
41 "github.com/charmbracelet/crush/internal/ui/anim"
42 "github.com/charmbracelet/crush/internal/ui/anim"
42 "github.com/charmbracelet/crush/internal/ui/attachments"
43 "github.com/charmbracelet/crush/internal/ui/attachments"
43 "github.com/charmbracelet/crush/internal/ui/chat"
44 "github.com/charmbracelet/crush/internal/ui/chat"
@@ -220,6 +221,9 @@ type UI struct {
220 // mcp
221 // mcp
221 mcpStates map[string]mcp.ClientInfo
222 mcpStates map[string]mcp.ClientInfo
222
223
224+ // skills
225+ skillStates []*skills.SkillState
226+
223 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
227 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
224 sidebarLogo string
228 sidebarLogo string
225
229
@@ -619,6 +623,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
619 cmds = append(cmds, m.handleFileEvent(msg.Payload))
623 cmds = append(cmds, m.handleFileEvent(msg.Payload))
620 case pubsub.Event[app.LSPEvent]:
624 case pubsub.Event[app.LSPEvent]:
621 m.lspStates = app.GetLSPStates()
625 m.lspStates = app.GetLSPStates()
626+ case pubsub.Event[skills.Event]:
627+ m.skillStates = msg.Payload.States
622 case pubsub.Event[mcp.Event]:
628 case pubsub.Event[mcp.Event]:
623 switch msg.Payload.Type {
629 switch msg.Payload.Type {
624 case mcp.EventStateChanged:
630 case mcp.EventStateChanged:
@@ -3535,13 +3541,14 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3535 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3541 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3536
3542
3537 const maxSectionWidth = 50
3543 const maxSectionWidth = 50
3538− sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3544+ sectionWidth := max(1, min(maxSectionWidth, width/4-2)) // account for spacing between sections
3539− maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3545+ maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3540
3546
3541 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3547 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3542 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3548 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3549+ skillsSection := m.skillsInfo(sectionWidth, maxItemsPerSection, false)
3543 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3550 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3544− sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3551+ sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection, " ", skillsSection)
3545 uv.NewStyledString(
3552 uv.NewStyledString(
3546 s.CompactDetails.View.
3553 s.CompactDetails.View.
3547 Width(area.Dx()).
3554 Width(area.Dx()).