on main help sign in
feat(skills): add descriptions to skill picker and use attachements

Co-authored-by: Amolith <amolith@secluded.site> Assisted-by: Crush:deepseek-v4-pro

commit55721653ceb67f04abd53c278ade01d05043bf7d authorKieran Klukas <kieran@dunkirk.sh> dateWed 20 May 2026, 12:42 (5 months ago) parentdd4318a
23 files changed+543−39
internal/backend/backend.go+7−2 internal/backend/config.go+45 internal/client/config.go+36 internal/cmd/root.go+8−2 internal/message/attachment.go+3−2 internal/proto/proto.go+29 internal/server/config.go+51 internal/server/server.go+2 internal/skills/catalog.go+152 internal/skills/manager.go+59−12 internal/ui/attachments/attachments.go+8−3 internal/ui/chat/messages.go+1 internal/ui/chat/prefix_cache_test.go+1 internal/ui/chat/version_bump_test.go+2 internal/ui/dialog/actions.go+6 internal/ui/dialog/commands.go+14−5 internal/ui/dialog/commands_item.go+42−13 internal/ui/model/ui.go+29 internal/ui/styles/quickstyle.go+1 internal/ui/styles/styles.go+2 internal/workspace/app_workspace.go+11 internal/workspace/client_workspace.go+31 internal/workspace/workspace.go+3
internal/backend/backend.go+7−2
@@ -112,8 +112,12 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
112112 // hosts multiple workspaces concurrently, so the manager is
113113 // constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
114114 // cross-talk between workspaces.
115− allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
116− skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
115+ discoveryCfg := skillsDiscoveryConfig(cfg)
116+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
117+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
118+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
119+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
120+ )
117121
118122 appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
119123 if err != nil {
@@ -173,6 +177,7 @@ func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
173177 return skills.DiscoveryConfig{
174178 SkillsPaths: paths,
175179 DisabledSkills: disabled,
180+ WorkingDir: cfg.WorkingDir(),
176181 Resolver: resolver,
177182 }
178183}
internal/backend/config.go+45
@@ -10,6 +10,8 @@ import (
1010 "github.com/charmbracelet/crush/internal/commands"
1111 "github.com/charmbracelet/crush/internal/config"
1212 "github.com/charmbracelet/crush/internal/oauth"
13+ "github.com/charmbracelet/crush/internal/proto"
14+ "github.com/charmbracelet/crush/internal/skills"
1315)
1416
1517// MCPResourceContents holds the contents of an MCP resource returned
@@ -116,6 +118,49 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
116118 return agent.InitializePrompt(ws.Cfg)
117119}
118120
121+// ReadSkill reads a skill's content by ID.
122+func (b *Backend) ReadSkill(ctx context.Context, workspaceID, skillID string) ([]byte, proto.SkillReadResult, error) {
123+ ws, err := b.GetWorkspace(workspaceID)
124+ if err != nil {
125+ return nil, proto.SkillReadResult{}, err
126+ }
127+
128+ mgr := ws.Skills
129+ content, result, err := skills.ReadContent(
130+ mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID,
131+ )
132+ if err != nil {
133+ return nil, proto.SkillReadResult{}, err
134+ }
135+ return content, proto.SkillReadResult{
136+ Name: result.Name,
137+ Description: result.Description,
138+ Source: string(result.Source),
139+ Builtin: result.Builtin,
140+ }, nil
141+}
142+
143+// ListSkills returns the effective visible skills for a workspace.
144+func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
145+ ws, err := b.GetWorkspace(workspaceID)
146+ if err != nil {
147+ return nil, err
148+ }
149+ mgr := ws.Skills
150+ entries := skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir())
151+ result := make([]proto.SkillInfo, len(entries))
152+ for i, entry := range entries {
153+ result[i] = proto.SkillInfo{
154+ ID: entry.ID,
155+ Name: entry.Name,
156+ Description: entry.Description,
157+ Label: entry.Label,
158+ Source: string(entry.Source),
159+ }
160+ }
161+ return result, nil
162+}
163+
119164// EnableDockerMCP validates Docker MCP availability, stages the
120165// configuration, starts the MCP client, and persists the config.
121166func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
internal/client/config.go+36
@@ -216,6 +216,42 @@ func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, er
216216 return result.Prompt, nil
217217}
218218
219+// ListSkills retrieves the visible skills for a workspace.
220+func (c *Client) ListSkills(ctx context.Context, id string) ([]proto.SkillInfo, error) {
221+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/skills", id), nil, nil)
222+ if err != nil {
223+ return nil, fmt.Errorf("failed to list skills: %w", err)
224+ }
225+ defer rsp.Body.Close()
226+ if rsp.StatusCode != http.StatusOK {
227+ return nil, fmt.Errorf("failed to list skills: status code %d", rsp.StatusCode)
228+ }
229+ var skills []proto.SkillInfo
230+ if err := json.NewDecoder(rsp.Body).Decode(&skills); err != nil {
231+ return nil, fmt.Errorf("failed to decode skills: %w", err)
232+ }
233+ return skills, nil
234+}
235+
236+// ReadSkill reads a skill's content by ID from the server.
237+func (c *Client) ReadSkill(ctx context.Context, id, skillID string) (*proto.ReadSkillResponse, error) {
238+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/skills/read", id), nil, jsonBody(proto.ReadSkillRequest{
239+ SkillID: skillID,
240+ }), http.Header{"Content-Type": []string{"application/json"}})
241+ if err != nil {
242+ return nil, fmt.Errorf("failed to read skill: %w", err)
243+ }
244+ defer rsp.Body.Close()
245+ if rsp.StatusCode != http.StatusOK {
246+ return nil, fmt.Errorf("failed to read skill: status code %d", rsp.StatusCode)
247+ }
248+ var result proto.ReadSkillResponse
249+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
250+ return nil, fmt.Errorf("failed to decode skill response: %w", err)
251+ }
252+ return &result, nil
253+}
254+
219255// MCPResourceContents holds the contents of an MCP resource.
220256type MCPResourceContents struct {
221257 URI string `json:"uri"`
internal/cmd/root.go+8−2
@@ -291,8 +291,13 @@ func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error
291291 // workspace per process, so WithGlobalMirror keeps the package
292292 // globals (which the TUI reads via skills.GetLatestStates) in sync
293293 // with the manager.
294− allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
295− skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
294+ discoveryCfg := localSkillsDiscoveryConfig(store)
295+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
296+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
297+ skills.WithGlobalMirror(),
298+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
299+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
300+ )
296301
297302 appInstance, err := app.New(ctx, conn, store, skillsMgr)
298303 if err != nil {
@@ -326,6 +331,7 @@ func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfi
326331 return skills.DiscoveryConfig{
327332 SkillsPaths: paths,
328333 DisabledSkills: disabled,
334+ WorkingDir: store.WorkingDir(),
329335 Resolver: resolver,
330336 }
331337}
internal/message/attachment.go+3−2
@@ -12,8 +12,9 @@ type Attachment struct {
1212 Content []byte
1313}
1414
15−func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
16−func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
15+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
16+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
17+func (a Attachment) IsMarkdown() bool { return a.MimeType == "text/markdown" }
1718
1819// ContainsTextAttachment returns true if any of the attachments is a text attachment.
1920func ContainsTextAttachment(attachments []Attachment) bool {
internal/proto/proto.go+29
@@ -32,6 +32,35 @@ type Error struct {
3232 Message string `json:"message"`
3333}
3434
35+// SkillInfo describes a visible skill exposed to a frontend.
36+type SkillInfo struct {
37+ ID string `json:"id"`
38+ Name string `json:"name"`
39+ Description string `json:"description"`
40+ Label string `json:"label"`
41+ Source string `json:"source"`
42+}
43+
44+// ReadSkillRequest is the request body for reading a skill's content.
45+type ReadSkillRequest struct {
46+ SkillID string `json:"skill_id"`
47+}
48+
49+// ReadSkillResponse is the response for reading a skill's content.
50+type ReadSkillResponse struct {
51+ Content []byte `json:"content"`
52+ Result SkillReadResult `json:"result"`
53+}
54+
55+// SkillReadResult holds metadata about a skill returned alongside its
56+// content.
57+type SkillReadResult struct {
58+ Name string `json:"name"`
59+ Description string `json:"description"`
60+ Source string `json:"source"`
61+ Builtin bool `json:"builtin"`
62+}
63+
3564// AgentInfo represents information about the agent.
3665type AgentInfo struct {
3766 IsBusy bool `json:"is_busy"`
internal/server/config.go+51
@@ -266,6 +266,57 @@ func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter
266266 jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
267267}
268268
269+// handleGetWorkspaceSkills returns the effective visible skills for a workspace.
270+//
271+// @Summary List visible skills
272+// @Tags skills
273+// @Produce json
274+// @Param id path string true "Workspace ID"
275+// @Success 200 {array} proto.SkillInfo
276+// @Failure 404 {object} proto.Error
277+// @Failure 500 {object} proto.Error
278+// @Router /workspaces/{id}/skills [get]
279+func (c *controllerV1) handleGetWorkspaceSkills(w http.ResponseWriter, r *http.Request) {
280+ id := r.PathValue("id")
281+ skills, err := c.backend.ListSkills(id)
282+ if err != nil {
283+ c.handleError(w, r, err)
284+ return
285+ }
286+ jsonEncode(w, skills)
287+}
288+
289+// handlePostWorkspaceSkillRead reads a skill's content by ID.
290+//
291+// @Summary Read skill content
292+// @Tags skills
293+// @Accept json
294+// @Produce json
295+// @Param id path string true "Workspace ID"
296+// @Param request body proto.ReadSkillRequest true "Read skill request"
297+// @Success 200 {object} proto.ReadSkillResponse
298+// @Failure 400 {object} proto.Error
299+// @Failure 404 {object} proto.Error
300+// @Failure 500 {object} proto.Error
301+// @Router /workspaces/{id}/skills/read [post]
302+func (c *controllerV1) handlePostWorkspaceSkillRead(w http.ResponseWriter, r *http.Request) {
303+ id := r.PathValue("id")
304+
305+ var req proto.ReadSkillRequest
306+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
307+ c.server.logError(r, "Failed to decode request", "error", err)
308+ jsonError(w, http.StatusBadRequest, "failed to decode request")
309+ return
310+ }
311+
312+ content, result, err := c.backend.ReadSkill(r.Context(), id, req.SkillID)
313+ if err != nil {
314+ c.handleError(w, r, err)
315+ return
316+ }
317+ jsonEncode(w, proto.ReadSkillResponse{Content: content, Result: result})
318+}
319+
269320// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
270321//
271322// @Summary Enable Docker MCP
internal/server/server.go+2
@@ -157,6 +157,8 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
157157 mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
158158 mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
159159 mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
160+ mux.HandleFunc("GET /v1/workspaces/{id}/skills", c.handleGetWorkspaceSkills)
161+ mux.HandleFunc("POST /v1/workspaces/{id}/skills/read", c.handlePostWorkspaceSkillRead)
160162 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
161163 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
162164 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
internal/skills/catalog.gonew file+152
@@ -0,0 +1,152 @@
1+package skills
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// SourceType describes where a visible skill comes from.
12+type SourceType string
13+
14+const (
15+ SourceSystem SourceType = "system"
16+ SourceUser SourceType = "user"
17+ SourceProject SourceType = "project"
18+)
19+
20+// CatalogEntry describes an effective visible skill for frontend display.
21+type CatalogEntry struct {
22+ ID string `json:"id"`
23+ Name string `json:"name"`
24+ Description string `json:"description"`
25+ Label string `json:"label"`
26+ Source SourceType `json:"source"`
27+}
28+
29+// SkillReadResult holds metadata about a skill returned alongside its
30+// content.
31+type SkillReadResult struct {
32+ Name string `json:"name"`
33+ Description string `json:"description"`
34+ Source SourceType `json:"source"`
35+ Builtin bool `json:"builtin"`
36+}
37+
38+// ErrSkillNotFound is returned when a skill ID is not part of the
39+// effective visible skill set.
40+var ErrSkillNotFound = errors.New("skill not found")
41+
42+// Catalog builds a slice of CatalogEntry values from pre-discovered
43+// skills. The skillPaths and workingDir parameters are used only for
44+// labelling (system / user / project); pass nil/empty when labels are
45+// not needed.
46+func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogEntry {
47+ entries := make([]CatalogEntry, 0, len(active))
48+ for _, skill := range active {
49+ label, source := skillLabel(skillPaths, workingDir, skill)
50+ entries = append(entries, CatalogEntry{
51+ ID: skill.SkillFilePath,
52+ Name: skill.Name,
53+ Description: skill.Description,
54+ Label: label,
55+ Source: source,
56+ })
57+ }
58+ return entries
59+}
60+
61+// FindEffective returns the named skill from the given active skill
62+// set.
63+func FindEffective(active []*Skill, skillID string) (*Skill, error) {
64+ for _, skill := range active {
65+ if skill.SkillFilePath == skillID {
66+ return skill, nil
67+ }
68+ }
69+ return nil, fmt.Errorf("%w: %s", ErrSkillNotFound, skillID)
70+}
71+
72+// ReadContent reads the contents of a visible skill by ID and returns
73+// the raw bytes along with metadata about the skill.
74+func ReadContent(active []*Skill, skillPaths []string, workingDir string, skillID string) ([]byte, SkillReadResult, error) {
75+ skill, err := FindEffective(active, skillID)
76+ if err != nil {
77+ return nil, SkillReadResult{}, err
78+ }
79+
80+ _, source := skillLabel(skillPaths, workingDir, skill)
81+ result := SkillReadResult{
82+ Name: skill.Name,
83+ Description: skill.Description,
84+ Source: source,
85+ Builtin: skill.Builtin,
86+ }
87+
88+ if skill.Builtin {
89+ embeddedPath := "builtin/" + strings.TrimPrefix(skill.SkillFilePath, BuiltinPrefix)
90+ content, err := BuiltinFS().ReadFile(embeddedPath)
91+ if err != nil {
92+ return nil, SkillReadResult{}, fmt.Errorf("read builtin skill %q: %w", skillID, err)
93+ }
94+ return content, result, nil
95+ }
96+
97+ content, err := os.ReadFile(skill.SkillFilePath)
98+ if err != nil {
99+ return nil, SkillReadResult{}, fmt.Errorf("read skill %q: %w", skillID, err)
100+ }
101+ return content, result, nil
102+}
103+
104+func skillLabel(skillPaths []string, workingDir string, skill *Skill) (string, SourceType) {
105+ if skill.Builtin {
106+ return string(SourceSystem) + ":" + skill.Name, SourceSystem
107+ }
108+
109+ cleanFile := filepath.Clean(skill.SkillFilePath)
110+ for _, base := range skillPaths {
111+ cleanBase := filepath.Clean(base)
112+ rel, err := filepath.Rel(cleanBase, cleanFile)
113+ if err != nil || escapesParent(rel) {
114+ continue
115+ }
116+
117+ source := SourceUser
118+ prefix := string(SourceUser) + ":"
119+ if isProjectSkillPath(cleanBase, workingDir) {
120+ source = SourceProject
121+ prefix = string(SourceProject) + ":"
122+ }
123+ return prefix + filepath.Base(filepath.Dir(cleanFile)), source
124+ }
125+
126+ return string(SourceUser) + ":" + filepath.Base(filepath.Dir(cleanFile)), SourceUser
127+}
128+
129+func escapesParent(rel string) bool {
130+ return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
131+}
132+
133+func isProjectSkillPath(basePath, workingDir string) bool {
134+ if workingDir == "" {
135+ return false
136+ }
137+ absBase, err := filepath.Abs(basePath)
138+ if err != nil {
139+ return false
140+ }
141+ absWD, err := filepath.Abs(workingDir)
142+ if err != nil {
143+ return false
144+ }
145+ cleanBase := filepath.Clean(absBase)
146+ cleanWD := filepath.Clean(absWD)
147+ rel, err := filepath.Rel(cleanWD, cleanBase)
148+ if err != nil {
149+ return false
150+ }
151+ return !escapesParent(rel)
152+}
internal/skills/manager.go+59−12
@@ -27,6 +27,12 @@ type Manager struct {
2727 activeSkills []*Skill
2828 states []*SkillState
2929
30+ // resolvedPaths are the expanded SkillsPaths used during discovery.
31+ // Stored so Catalog/ReadContent can label skills without
32+ // re-resolving.
33+ resolvedPaths []string
34+ workingDir string
35+
3036 broker *pubsub.Broker[Event]
3137 globalMirror bool
3238}
@@ -44,6 +50,23 @@ func WithGlobalMirror() ManagerOption {
4450 }
4551}
4652
53+// WithResolvedPaths stores the expanded skills directory paths that
54+// were used during discovery. Catalog and ReadContent use these for
55+// source labelling.
56+func WithResolvedPaths(paths []string) ManagerOption {
57+ return func(m *Manager) {
58+ m.resolvedPaths = paths
59+ }
60+}
61+
62+// WithWorkingDir stores the workspace working directory. Catalog and
63+// ReadContent use it to distinguish project skills from user skills.
64+func WithWorkingDir(dir string) ManagerOption {
65+ return func(m *Manager) {
66+ m.workingDir = dir
67+ }
68+}
69+
4770// NewManager constructs a workspace-scoped Manager with the given
4871// pre-computed discovery results. The slices are stored as-is; callers
4972// should not mutate them afterwards.
@@ -78,6 +101,18 @@ func (m *Manager) ActiveSkills() []*Skill {
78101 return m.activeSkills
79102}
80103
104+// ResolvedPaths returns the expanded skills directory paths stored at
105+// construction time.
106+func (m *Manager) ResolvedPaths() []string {
107+ return m.resolvedPaths
108+}
109+
110+// WorkingDir returns the workspace working directory stored at
111+// construction time.
112+func (m *Manager) WorkingDir() string {
113+ return m.workingDir
114+}
115+
81116// States returns a clone of the latest discovery state snapshot.
82117func (m *Manager) States() []*SkillState {
83118 m.mu.RLock()
@@ -140,18 +175,8 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
140175 discovered := append([]*Skill(nil), builtin...)
141176
142177 var userStates []*SkillState
143− var userPaths []string
144− if len(cfg.SkillsPaths) > 0 {
145− userPaths = make([]string, 0, len(cfg.SkillsPaths))
146− for _, pth := range cfg.SkillsPaths {
147− expanded := home.Long(pth)
148− if strings.HasPrefix(expanded, "$") && cfg.Resolver != nil {
149− if resolved, err := cfg.Resolver(expanded); err == nil {
150− expanded = resolved
151− }
152− }
153− userPaths = append(userPaths, expanded)
154− }
178+ userPaths := cfg.ResolvePaths()
179+ if len(userPaths) > 0 {
155180 var userSkills []*Skill
156181 userSkills, userStates = DiscoverWithStates(userPaths)
157182 discovered = append(discovered, userSkills...)
@@ -175,6 +200,28 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
175200type DiscoveryConfig struct {
176201 SkillsPaths []string
177202 DisabledSkills []string
203+ WorkingDir string
178204 // Resolver expands $VAR-style references in paths. May be nil.
179205 Resolver func(string) (string, error)
180206}
207+
208+// ResolvePaths expands home-directory and $VAR references in
209+// SkillsPaths. This is the canonical path-resolution logic used by
210+// DiscoverFromConfig; callers that need the resolved list (e.g. for
211+// Catalog labels) can call this directly.
212+func (c DiscoveryConfig) ResolvePaths() []string {
213+ if len(c.SkillsPaths) == 0 {
214+ return nil
215+ }
216+ out := make([]string, 0, len(c.SkillsPaths))
217+ for _, pth := range c.SkillsPaths {
218+ expanded := home.Long(pth)
219+ if strings.HasPrefix(expanded, "$") && c.Resolver != nil {
220+ if resolved, err := c.Resolver(expanded); err == nil {
221+ expanded = resolved
222+ }
223+ }
224+ out = append(out, expanded)
225+ }
226+ return out
227+}
internal/ui/attachments/attachments.go+8−3
@@ -82,25 +82,27 @@ func (m *Attachments) Render(width int) string {
8282// styles in place.
8383func (m *Attachments) Renderer() *Renderer { return m.renderer }
8484
85−func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
85+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) *Renderer {
8686 return &Renderer{
8787 normalStyle: normalStyle,
8888 textStyle: textStyle,
8989 imageStyle: imageStyle,
90+ skillStyle: skillStyle,
9091 deletingStyle: deletingStyle,
9192 }
9293}
9394
9495// SetStyles updates the renderer styles in place.
95−func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
96+func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) {
9697 r.normalStyle = normalStyle
9798 r.textStyle = textStyle
9899 r.imageStyle = imageStyle
100+ r.skillStyle = skillStyle
99101 r.deletingStyle = deletingStyle
100102}
101103
102104type Renderer struct {
103− normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
105+ normalStyle, textStyle, imageStyle, skillStyle, deletingStyle lipgloss.Style
104106}
105107
106108func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
@@ -143,5 +145,8 @@ func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
143145 if a.IsImage() {
144146 return r.imageStyle
145147 }
148+ if a.IsMarkdown() {
149+ return r.skillStyle
150+ }
146151 return r.textStyle
147152}
internal/ui/chat/messages.go+1
@@ -372,6 +372,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
372372 sty.Attachments.Deleting,
373373 sty.Attachments.Image,
374374 sty.Attachments.Text,
375+ sty.Attachments.Skill,
375376 )
376377 return []MessageItem{NewUserMessageItem(sty, msg, r)}
377378 case message.Assistant:
internal/ui/chat/prefix_cache_test.go+1
@@ -116,6 +116,7 @@ func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
116116 sty.Attachments.Deleting,
117117 sty.Attachments.Image,
118118 sty.Attachments.Text,
119+ sty.Attachments.Skill,
119120 )
120121 item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
121122
internal/ui/chat/version_bump_test.go+2
@@ -83,6 +83,7 @@ func TestUserMessageItem_MutatorsBumpVersion(t *testing.T) {
8383 sty.Attachments.Deleting,
8484 sty.Attachments.Image,
8585 sty.Attachments.Text,
86+ sty.Attachments.Skill,
8687 )
8788 msg := &message.Message{
8889 ID: "u-mut",
@@ -253,6 +254,7 @@ func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
253254 sty.Attachments.Deleting,
254255 sty.Attachments.Image,
255256 sty.Attachments.Text,
257+ sty.Attachments.Skill,
256258 )
257259 msg := &message.Message{
258260 ID: "u-fin",
internal/ui/dialog/actions.go+6
@@ -74,6 +74,12 @@ type (
7474 Args map[string]string // Actual argument values
7575 Skill *skills.Skill // Set when this is a skill command
7676 }
77+ // ActionAttachSkill is sent when a skill is selected from the commands
78+ // dialog to be attached to the conversation as a markdown attachment.
79+ ActionAttachSkill struct {
80+ ID string
81+ Name string
82+ }
7783 // ActionRunMCPPrompt is a message to run a custom command.
7884 ActionRunMCPPrompt struct {
7985 Title string
internal/ui/dialog/commands.go+14−5
@@ -390,12 +390,21 @@ func (c *Commands) setCommandItems(commandType CommandType) {
390390 }
391391 case UserCommands:
392392 for _, cmd := range c.customCommands {
393− action := ActionRunCustomCommand{
394− Content: cmd.Content,
395− Arguments: cmd.Arguments,
396− Skill: cmd.Skill,
393+ var action Action
394+ if cmd.Skill != nil {
395+ action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
396+ } else {
397+ action = ActionRunCustomCommand{
398+ Content: cmd.Content,
399+ Arguments: cmd.Arguments,
400+ Skill: cmd.Skill,
401+ }
402+ }
403+ item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
404+ if cmd.Skill != nil {
405+ item = item.WithDescription(cmd.Skill.Description)
397406 }
398− commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
407+ commandItems = append(commandItems, item)
399408 }
400409 case MCPPrompts:
401410 for _, cmd := range c.mcpPrompts {
internal/ui/dialog/commands_item.go+42−13
@@ -3,23 +3,26 @@ package dialog
33import (
44 "strings"
55
6+ "charm.land/lipgloss/v2"
67 "github.com/charmbracelet/crush/internal/ui/list"
78 "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/charmbracelet/x/ansi"
810 "github.com/sahilm/fuzzy"
911)
1012
1113// CommandItem wraps a uicmd.Command to implement the ListItem interface.
1214type CommandItem struct {
1315 *list.Versioned
14− id string
15− title string
16− shortcut string
17− action Action
18− aliases []string
19− t *styles.Styles
20− m fuzzy.Match
21− cache map[int]string
22− focused bool
16+ id string
17+ title string
18+ shortcut string
19+ description string
20+ action Action
21+ aliases []string
22+ t *styles.Styles
23+ m fuzzy.Match
24+ cache map[int]string
25+ focused bool
2326}
2427
2528var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
@@ -48,12 +51,23 @@ func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
4851 return c
4952}
5053
54+// WithDescription returns the CommandItem with a description displayed below
55+// the title.
56+func (c *CommandItem) WithDescription(desc string) *CommandItem {
57+ c.description = desc
58+ return c
59+}
60+
5161// Filter implements ListItem.
5262func (c *CommandItem) Filter() string {
53− if len(c.aliases) == 0 {
54− return c.title
63+ base := c.title
64+ if len(c.aliases) > 0 {
65+ base = c.title + " " + strings.Join(c.aliases, " ")
5566 }
56− return c.title + " " + strings.Join(c.aliases, " ")
67+ if c.description != "" {
68+ base = base + " " + c.description
69+ }
70+ return base
5771}
5872
5973// ID implements ListItem.
@@ -103,5 +117,20 @@ func (c *CommandItem) Render(width int) string {
103117 InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
104118 InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
105119 }
106− return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
120+ rendered := renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
121+ if c.description != "" {
122+ descStyle := c.t.Dialog.SecondaryText
123+ if c.focused {
124+ descStyle = c.t.Dialog.SelectedItem
125+ }
126+ contentWidth := max(0, width-descStyle.GetHorizontalFrameSize()+1)
127+ description := ansi.Truncate(strings.TrimSpace(c.description), contentWidth, "...")
128+ descVisWidth := lipgloss.Width(description)
129+ gap := strings.Repeat(" ", max(0, contentWidth-descVisWidth))
130+ if description == "" {
131+ description = " "
132+ }
133+ rendered = lipgloss.JoinVertical(lipgloss.Left, rendered, descStyle.Render(description+gap))
134+ }
135+ return rendered
107136}
internal/ui/model/ui.go+29
@@ -317,6 +317,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
317317 com.Styles.Attachments.Deleting,
318318 com.Styles.Attachments.Image,
319319 com.Styles.Attachments.Text,
320+ com.Styles.Attachments.Skill,
320321 ),
321322 attachments.Keymap{
322323 DeleteMode: keyMap.Editor.AttachmentDeleteMode,
@@ -1551,6 +1552,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
15511552 }
15521553 cmds = append(cmds, m.sendMessage(content))
15531554 m.dialog.CloseFrontDialog()
1555+ case dialog.ActionAttachSkill:
1556+ m.dialog.CloseFrontDialog()
1557+ cmds = append(cmds, m.attachSkill(msg.ID, msg.Name))
15541558 case dialog.ActionRunMCPPrompt:
15551559 if len(msg.Arguments) > 0 && msg.Args == nil {
15561560 m.dialog.CloseFrontDialog()
@@ -3136,12 +3140,37 @@ func (m *UI) refreshStyles() {
31363140 t.Attachments.Deleting,
31373141 t.Attachments.Image,
31383142 t.Attachments.Text,
3143+ t.Attachments.Skill,
31393144 )
31403145 m.todoSpinner.Style = t.Pills.TodoSpinner
31413146 m.status.help.Styles = t.Help
31423147 m.chat.InvalidateRenderCaches()
31433148}
31443149
3150+// attachSkill reads a skill's content by ID and returns it as a markdown
3151+// attachment to be added to the attachment toolbar. The user can then
3152+// compose a message and send it with the skill attached.
3153+// The name parameter is used as a fallback when the server does not
3154+// return one.
3155+func (m *UI) attachSkill(skillID, name string) tea.Cmd {
3156+ return func() tea.Msg {
3157+ content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
3158+ if err != nil {
3159+ return util.NewErrorMsg(err)
3160+ }
3161+ fileName := result.Name
3162+ if fileName == "" {
3163+ fileName = name
3164+ }
3165+ return message.Attachment{
3166+ FilePath: fileName,
3167+ FileName: fileName,
3168+ MimeType: "text/markdown",
3169+ Content: content,
3170+ }
3171+ }
3172+}
3173+
31453174// sendMessage sends a message with the given content and attachments.
31463175func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
31473176 if !m.com.Workspace.AgentIsReady() {
internal/ui/styles/quickstyle.go+1
@@ -923,6 +923,7 @@ func quickStyle(o quickStyleOpts) Styles {
923923 attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
924924 s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
925925 s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
926+ s.Attachments.Skill = attachmentIconStyle.SetString(SkillIcon)
926927 s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
927928 s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
928929
internal/ui/styles/styles.go+2
@@ -43,6 +43,7 @@ const (
4343
4444 ImageIcon string = "■"
4545 TextIcon string = "≡"
46+ SkillIcon string = "▲"
4647
4748 ScrollbarThumb string = "┃"
4849 ScrollbarTrack string = "│"
@@ -502,6 +503,7 @@ type Styles struct {
502503 Normal lipgloss.Style
503504 Image lipgloss.Style
504505 Text lipgloss.Style
506+ Skill lipgloss.Style
505507 Deleting lipgloss.Style
506508 }
507509
internal/workspace/app_workspace.go+11
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/crush/internal/oauth"
1919 "github.com/charmbracelet/crush/internal/permission"
2020 "github.com/charmbracelet/crush/internal/session"
21+ "github.com/charmbracelet/crush/internal/skills"
2122)
2223
2324// AppWorkspace implements the Workspace interface by delegating
@@ -304,6 +305,16 @@ func (w *AppWorkspace) InitializePrompt() (string, error) {
304305 return agent.InitializePrompt(w.store)
305306}
306307
308+func (w *AppWorkspace) ListSkills(_ context.Context) ([]skills.CatalogEntry, error) {
309+ mgr := w.app.Skills
310+ return skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir()), nil
311+}
312+
313+func (w *AppWorkspace) ReadSkill(_ context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
314+ mgr := w.app.Skills
315+ return skills.ReadContent(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID)
316+}
317+
307318// -- MCP operations --
308319
309320func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
internal/workspace/client_workspace.go+31
@@ -483,6 +483,37 @@ func (w *ClientWorkspace) InitializePrompt() (string, error) {
483483 return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
484484}
485485
486+func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
487+ entries, err := w.client.ListSkills(ctx, w.workspaceID())
488+ if err != nil {
489+ return nil, err
490+ }
491+ result := make([]skills.CatalogEntry, len(entries))
492+ for i, entry := range entries {
493+ result[i] = skills.CatalogEntry{
494+ ID: entry.ID,
495+ Name: entry.Name,
496+ Description: entry.Description,
497+ Label: entry.Label,
498+ Source: skills.SourceType(entry.Source),
499+ }
500+ }
501+ return result, nil
502+}
503+
504+func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
505+ resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
506+ if err != nil {
507+ return nil, skills.SkillReadResult{}, err
508+ }
509+ return resp.Content, skills.SkillReadResult{
510+ Name: resp.Result.Name,
511+ Description: resp.Result.Description,
512+ Source: skills.SourceType(resp.Result.Source),
513+ Builtin: resp.Result.Builtin,
514+ }, nil
515+}
516+
486517// -- MCP operations --
487518
488519func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
internal/workspace/workspace.go+3
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/crush/internal/oauth"
1919 "github.com/charmbracelet/crush/internal/permission"
2020 "github.com/charmbracelet/crush/internal/session"
21+ "github.com/charmbracelet/crush/internal/skills"
2122)
2223
2324// LSPClientInfo holds information about an LSP client's state. This is
@@ -127,6 +128,8 @@ type Workspace interface {
127128 ProjectNeedsInitialization() (bool, error)
128129 MarkProjectInitialized() error
129130 InitializePrompt() (string, error)
131+ ListSkills(ctx context.Context) ([]skills.CatalogEntry, error)
132+ ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error)
130133
131134 // MCP operations (server-side in client mode)
132135 MCPGetStates() map[string]mcptools.ClientInfo