feat(skills): add descriptions to skill picker and use attachements

Kieran Klukas and Amolith created

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

Change summary

internal/backend/backend.go            |   9 +
internal/backend/config.go             |  45 ++++++++
internal/client/config.go              |  36 ++++++
internal/cmd/root.go                   |  10 +
internal/message/attachment.go         |   5 
internal/proto/proto.go                |  29 +++++
internal/server/config.go              |  51 +++++++++
internal/server/server.go              |   2 
internal/skills/catalog.go             | 152 ++++++++++++++++++++++++++++
internal/skills/manager.go             |  71 ++++++++++--
internal/ui/attachments/attachments.go |  11 +
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         |  19 ++
internal/ui/dialog/commands_item.go    |  55 +++++++--
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 
23 files changed, 543 insertions(+), 39 deletions(-)

Detailed changes

internal/backend/backend.go 🔗

@@ -112,8 +112,12 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
 	// hosts multiple workspaces concurrently, so the manager is
 	// constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
 	// cross-talk between workspaces.
-	allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
-	skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
+	discoveryCfg := skillsDiscoveryConfig(cfg)
+	allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
+	skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
+		skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
+		skills.WithWorkingDir(discoveryCfg.WorkingDir),
+	)
 
 	appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
 	if err != nil {
@@ -173,6 +177,7 @@ func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
 	return skills.DiscoveryConfig{
 		SkillsPaths:    paths,
 		DisabledSkills: disabled,
+		WorkingDir:     cfg.WorkingDir(),
 		Resolver:       resolver,
 	}
 }

internal/backend/config.go 🔗

@@ -10,6 +10,8 @@ import (
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/skills"
 )
 
 // MCPResourceContents holds the contents of an MCP resource returned
@@ -116,6 +118,49 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
 	return agent.InitializePrompt(ws.Cfg)
 }
 
+// ReadSkill reads a skill's content by ID.
+func (b *Backend) ReadSkill(ctx context.Context, workspaceID, skillID string) ([]byte, proto.SkillReadResult, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, proto.SkillReadResult{}, err
+	}
+
+	mgr := ws.Skills
+	content, result, err := skills.ReadContent(
+		mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID,
+	)
+	if err != nil {
+		return nil, proto.SkillReadResult{}, err
+	}
+	return content, proto.SkillReadResult{
+		Name:        result.Name,
+		Description: result.Description,
+		Source:      string(result.Source),
+		Builtin:     result.Builtin,
+	}, nil
+}
+
+// ListSkills returns the effective visible skills for a workspace.
+func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+	mgr := ws.Skills
+	entries := skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir())
+	result := make([]proto.SkillInfo, len(entries))
+	for i, entry := range entries {
+		result[i] = proto.SkillInfo{
+			ID:          entry.ID,
+			Name:        entry.Name,
+			Description: entry.Description,
+			Label:       entry.Label,
+			Source:      string(entry.Source),
+		}
+	}
+	return result, nil
+}
+
 // EnableDockerMCP validates Docker MCP availability, stages the
 // configuration, starts the MCP client, and persists the config.
 func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {

internal/client/config.go 🔗

@@ -216,6 +216,42 @@ func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, er
 	return result.Prompt, nil
 }
 
+// ListSkills retrieves the visible skills for a workspace.
+func (c *Client) ListSkills(ctx context.Context, id string) ([]proto.SkillInfo, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/skills", id), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list skills: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to list skills: status code %d", rsp.StatusCode)
+	}
+	var skills []proto.SkillInfo
+	if err := json.NewDecoder(rsp.Body).Decode(&skills); err != nil {
+		return nil, fmt.Errorf("failed to decode skills: %w", err)
+	}
+	return skills, nil
+}
+
+// ReadSkill reads a skill's content by ID from the server.
+func (c *Client) ReadSkill(ctx context.Context, id, skillID string) (*proto.ReadSkillResponse, error) {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/skills/read", id), nil, jsonBody(proto.ReadSkillRequest{
+		SkillID: skillID,
+	}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return nil, fmt.Errorf("failed to read skill: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to read skill: status code %d", rsp.StatusCode)
+	}
+	var result proto.ReadSkillResponse
+	if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+		return nil, fmt.Errorf("failed to decode skill response: %w", err)
+	}
+	return &result, nil
+}
+
 // MCPResourceContents holds the contents of an MCP resource.
 type MCPResourceContents struct {
 	URI      string `json:"uri"`

internal/cmd/root.go 🔗

@@ -291,8 +291,13 @@ func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error
 	// workspace per process, so WithGlobalMirror keeps the package
 	// globals (which the TUI reads via skills.GetLatestStates) in sync
 	// with the manager.
-	allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
-	skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
+	discoveryCfg := localSkillsDiscoveryConfig(store)
+	allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
+	skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
+		skills.WithGlobalMirror(),
+		skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
+		skills.WithWorkingDir(discoveryCfg.WorkingDir),
+	)
 
 	appInstance, err := app.New(ctx, conn, store, skillsMgr)
 	if err != nil {
@@ -326,6 +331,7 @@ func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfi
 	return skills.DiscoveryConfig{
 		SkillsPaths:    paths,
 		DisabledSkills: disabled,
+		WorkingDir:     store.WorkingDir(),
 		Resolver:       resolver,
 	}
 }

internal/message/attachment.go 🔗

@@ -12,8 +12,9 @@ type Attachment struct {
 	Content  []byte
 }
 
-func (a Attachment) IsText() bool  { return strings.HasPrefix(a.MimeType, "text/") }
-func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
+func (a Attachment) IsText() bool     { return strings.HasPrefix(a.MimeType, "text/") }
+func (a Attachment) IsImage() bool    { return strings.HasPrefix(a.MimeType, "image/") }
+func (a Attachment) IsMarkdown() bool { return a.MimeType == "text/markdown" }
 
 // ContainsTextAttachment returns true if any of the attachments is a text attachment.
 func ContainsTextAttachment(attachments []Attachment) bool {

internal/proto/proto.go 🔗

@@ -32,6 +32,35 @@ type Error struct {
 	Message string `json:"message"`
 }
 
+// SkillInfo describes a visible skill exposed to a frontend.
+type SkillInfo struct {
+	ID          string `json:"id"`
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Label       string `json:"label"`
+	Source      string `json:"source"`
+}
+
+// ReadSkillRequest is the request body for reading a skill's content.
+type ReadSkillRequest struct {
+	SkillID string `json:"skill_id"`
+}
+
+// ReadSkillResponse is the response for reading a skill's content.
+type ReadSkillResponse struct {
+	Content []byte          `json:"content"`
+	Result  SkillReadResult `json:"result"`
+}
+
+// SkillReadResult holds metadata about a skill returned alongside its
+// content.
+type SkillReadResult struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Source      string `json:"source"`
+	Builtin     bool   `json:"builtin"`
+}
+
 // AgentInfo represents information about the agent.
 type AgentInfo struct {
 	IsBusy   bool                 `json:"is_busy"`

internal/server/config.go 🔗

@@ -266,6 +266,57 @@ func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter
 	jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
 }
 
+// handleGetWorkspaceSkills returns the effective visible skills for a workspace.
+//
+//	@Summary		List visible skills
+//	@Tags			skills
+//	@Produce		json
+//	@Param			id	path		string				true	"Workspace ID"
+//	@Success		200	{array}		proto.SkillInfo
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/skills [get]
+func (c *controllerV1) handleGetWorkspaceSkills(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	skills, err := c.backend.ListSkills(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, skills)
+}
+
+// handlePostWorkspaceSkillRead reads a skill's content by ID.
+//
+//	@Summary		Read skill content
+//	@Tags			skills
+//	@Accept			json
+//	@Produce		json
+//	@Param			id		path		string						true	"Workspace ID"
+//	@Param			request	body		proto.ReadSkillRequest		true	"Read skill request"
+//	@Success		200		{object}	proto.ReadSkillResponse
+//	@Failure		400		{object}	proto.Error
+//	@Failure		404		{object}	proto.Error
+//	@Failure		500		{object}	proto.Error
+//	@Router			/workspaces/{id}/skills/read [post]
+func (c *controllerV1) handlePostWorkspaceSkillRead(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.ReadSkillRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	content, result, err := c.backend.ReadSkill(r.Context(), id, req.SkillID)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, proto.ReadSkillResponse{Content: content, Result: result})
+}
+
 // handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
 //
 //	@Summary		Enable Docker MCP

internal/server/server.go 🔗

@@ -157,6 +157,8 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
 	mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
 	mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
 	mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
+	mux.HandleFunc("GET /v1/workspaces/{id}/skills", c.handleGetWorkspaceSkills)
+	mux.HandleFunc("POST /v1/workspaces/{id}/skills/read", c.handlePostWorkspaceSkillRead)
 	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
 	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
 	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)

internal/skills/catalog.go 🔗

@@ -0,0 +1,152 @@
+package skills
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// SourceType describes where a visible skill comes from.
+type SourceType string
+
+const (
+	SourceSystem  SourceType = "system"
+	SourceUser    SourceType = "user"
+	SourceProject SourceType = "project"
+)
+
+// CatalogEntry describes an effective visible skill for frontend display.
+type CatalogEntry struct {
+	ID          string     `json:"id"`
+	Name        string     `json:"name"`
+	Description string     `json:"description"`
+	Label       string     `json:"label"`
+	Source      SourceType `json:"source"`
+}
+
+// SkillReadResult holds metadata about a skill returned alongside its
+// content.
+type SkillReadResult struct {
+	Name        string     `json:"name"`
+	Description string     `json:"description"`
+	Source      SourceType `json:"source"`
+	Builtin     bool       `json:"builtin"`
+}
+
+// ErrSkillNotFound is returned when a skill ID is not part of the
+// effective visible skill set.
+var ErrSkillNotFound = errors.New("skill not found")
+
+// Catalog builds a slice of CatalogEntry values from pre-discovered
+// skills. The skillPaths and workingDir parameters are used only for
+// labelling (system / user / project); pass nil/empty when labels are
+// not needed.
+func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogEntry {
+	entries := make([]CatalogEntry, 0, len(active))
+	for _, skill := range active {
+		label, source := skillLabel(skillPaths, workingDir, skill)
+		entries = append(entries, CatalogEntry{
+			ID:          skill.SkillFilePath,
+			Name:        skill.Name,
+			Description: skill.Description,
+			Label:       label,
+			Source:      source,
+		})
+	}
+	return entries
+}
+
+// FindEffective returns the named skill from the given active skill
+// set.
+func FindEffective(active []*Skill, skillID string) (*Skill, error) {
+	for _, skill := range active {
+		if skill.SkillFilePath == skillID {
+			return skill, nil
+		}
+	}
+	return nil, fmt.Errorf("%w: %s", ErrSkillNotFound, skillID)
+}
+
+// ReadContent reads the contents of a visible skill by ID and returns
+// the raw bytes along with metadata about the skill.
+func ReadContent(active []*Skill, skillPaths []string, workingDir string, skillID string) ([]byte, SkillReadResult, error) {
+	skill, err := FindEffective(active, skillID)
+	if err != nil {
+		return nil, SkillReadResult{}, err
+	}
+
+	_, source := skillLabel(skillPaths, workingDir, skill)
+	result := SkillReadResult{
+		Name:        skill.Name,
+		Description: skill.Description,
+		Source:      source,
+		Builtin:     skill.Builtin,
+	}
+
+	if skill.Builtin {
+		embeddedPath := "builtin/" + strings.TrimPrefix(skill.SkillFilePath, BuiltinPrefix)
+		content, err := BuiltinFS().ReadFile(embeddedPath)
+		if err != nil {
+			return nil, SkillReadResult{}, fmt.Errorf("read builtin skill %q: %w", skillID, err)
+		}
+		return content, result, nil
+	}
+
+	content, err := os.ReadFile(skill.SkillFilePath)
+	if err != nil {
+		return nil, SkillReadResult{}, fmt.Errorf("read skill %q: %w", skillID, err)
+	}
+	return content, result, nil
+}
+
+func skillLabel(skillPaths []string, workingDir string, skill *Skill) (string, SourceType) {
+	if skill.Builtin {
+		return string(SourceSystem) + ":" + skill.Name, SourceSystem
+	}
+
+	cleanFile := filepath.Clean(skill.SkillFilePath)
+	for _, base := range skillPaths {
+		cleanBase := filepath.Clean(base)
+		rel, err := filepath.Rel(cleanBase, cleanFile)
+		if err != nil || escapesParent(rel) {
+			continue
+		}
+
+		source := SourceUser
+		prefix := string(SourceUser) + ":"
+		if isProjectSkillPath(cleanBase, workingDir) {
+			source = SourceProject
+			prefix = string(SourceProject) + ":"
+		}
+		return prefix + filepath.Base(filepath.Dir(cleanFile)), source
+	}
+
+	return string(SourceUser) + ":" + filepath.Base(filepath.Dir(cleanFile)), SourceUser
+}
+
+func escapesParent(rel string) bool {
+	return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+func isProjectSkillPath(basePath, workingDir string) bool {
+	if workingDir == "" {
+		return false
+	}
+	absBase, err := filepath.Abs(basePath)
+	if err != nil {
+		return false
+	}
+	absWD, err := filepath.Abs(workingDir)
+	if err != nil {
+		return false
+	}
+	cleanBase := filepath.Clean(absBase)
+	cleanWD := filepath.Clean(absWD)
+	rel, err := filepath.Rel(cleanWD, cleanBase)
+	if err != nil {
+		return false
+	}
+	return !escapesParent(rel)
+}

internal/skills/manager.go 🔗

@@ -27,6 +27,12 @@ type Manager struct {
 	activeSkills []*Skill
 	states       []*SkillState
 
+	// resolvedPaths are the expanded SkillsPaths used during discovery.
+	// Stored so Catalog/ReadContent can label skills without
+	// re-resolving.
+	resolvedPaths []string
+	workingDir    string
+
 	broker       *pubsub.Broker[Event]
 	globalMirror bool
 }
@@ -44,6 +50,23 @@ func WithGlobalMirror() ManagerOption {
 	}
 }
 
+// WithResolvedPaths stores the expanded skills directory paths that
+// were used during discovery. Catalog and ReadContent use these for
+// source labelling.
+func WithResolvedPaths(paths []string) ManagerOption {
+	return func(m *Manager) {
+		m.resolvedPaths = paths
+	}
+}
+
+// WithWorkingDir stores the workspace working directory. Catalog and
+// ReadContent use it to distinguish project skills from user skills.
+func WithWorkingDir(dir string) ManagerOption {
+	return func(m *Manager) {
+		m.workingDir = dir
+	}
+}
+
 // NewManager constructs a workspace-scoped Manager with the given
 // pre-computed discovery results. The slices are stored as-is; callers
 // should not mutate them afterwards.
@@ -78,6 +101,18 @@ func (m *Manager) ActiveSkills() []*Skill {
 	return m.activeSkills
 }
 
+// ResolvedPaths returns the expanded skills directory paths stored at
+// construction time.
+func (m *Manager) ResolvedPaths() []string {
+	return m.resolvedPaths
+}
+
+// WorkingDir returns the workspace working directory stored at
+// construction time.
+func (m *Manager) WorkingDir() string {
+	return m.workingDir
+}
+
 // States returns a clone of the latest discovery state snapshot.
 func (m *Manager) States() []*SkillState {
 	m.mu.RLock()
@@ -140,18 +175,8 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
 	discovered := append([]*Skill(nil), builtin...)
 
 	var userStates []*SkillState
-	var userPaths []string
-	if len(cfg.SkillsPaths) > 0 {
-		userPaths = make([]string, 0, len(cfg.SkillsPaths))
-		for _, pth := range cfg.SkillsPaths {
-			expanded := home.Long(pth)
-			if strings.HasPrefix(expanded, "$") && cfg.Resolver != nil {
-				if resolved, err := cfg.Resolver(expanded); err == nil {
-					expanded = resolved
-				}
-			}
-			userPaths = append(userPaths, expanded)
-		}
+	userPaths := cfg.ResolvePaths()
+	if len(userPaths) > 0 {
 		var userSkills []*Skill
 		userSkills, userStates = DiscoverWithStates(userPaths)
 		discovered = append(discovered, userSkills...)
@@ -175,6 +200,28 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
 type DiscoveryConfig struct {
 	SkillsPaths    []string
 	DisabledSkills []string
+	WorkingDir     string
 	// Resolver expands $VAR-style references in paths. May be nil.
 	Resolver func(string) (string, error)
 }
+
+// ResolvePaths expands home-directory and $VAR references in
+// SkillsPaths. This is the canonical path-resolution logic used by
+// DiscoverFromConfig; callers that need the resolved list (e.g. for
+// Catalog labels) can call this directly.
+func (c DiscoveryConfig) ResolvePaths() []string {
+	if len(c.SkillsPaths) == 0 {
+		return nil
+	}
+	out := make([]string, 0, len(c.SkillsPaths))
+	for _, pth := range c.SkillsPaths {
+		expanded := home.Long(pth)
+		if strings.HasPrefix(expanded, "$") && c.Resolver != nil {
+			if resolved, err := c.Resolver(expanded); err == nil {
+				expanded = resolved
+			}
+		}
+		out = append(out, expanded)
+	}
+	return out
+}

internal/ui/attachments/attachments.go 🔗

@@ -82,25 +82,27 @@ func (m *Attachments) Render(width int) string {
 // styles in place.
 func (m *Attachments) Renderer() *Renderer { return m.renderer }
 
-func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) *Renderer {
 	return &Renderer{
 		normalStyle:   normalStyle,
 		textStyle:     textStyle,
 		imageStyle:    imageStyle,
+		skillStyle:    skillStyle,
 		deletingStyle: deletingStyle,
 	}
 }
 
 // SetStyles updates the renderer styles in place.
-func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
+func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) {
 	r.normalStyle = normalStyle
 	r.textStyle = textStyle
 	r.imageStyle = imageStyle
+	r.skillStyle = skillStyle
 	r.deletingStyle = deletingStyle
 }
 
 type Renderer struct {
-	normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
+	normalStyle, textStyle, imageStyle, skillStyle, deletingStyle lipgloss.Style
 }
 
 func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
@@ -143,5 +145,8 @@ func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
 	if a.IsImage() {
 		return r.imageStyle
 	}
+	if a.IsMarkdown() {
+		return r.skillStyle
+	}
 	return r.textStyle
 }

internal/ui/chat/messages.go 🔗

@@ -372,6 +372,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
 			sty.Attachments.Deleting,
 			sty.Attachments.Image,
 			sty.Attachments.Text,
+			sty.Attachments.Skill,
 		)
 		return []MessageItem{NewUserMessageItem(sty, msg, r)}
 	case message.Assistant:

internal/ui/chat/prefix_cache_test.go 🔗

@@ -116,6 +116,7 @@ func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
 		sty.Attachments.Deleting,
 		sty.Attachments.Image,
 		sty.Attachments.Text,
+		sty.Attachments.Skill,
 	)
 	item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
 

internal/ui/chat/version_bump_test.go 🔗

@@ -83,6 +83,7 @@ func TestUserMessageItem_MutatorsBumpVersion(t *testing.T) {
 		sty.Attachments.Deleting,
 		sty.Attachments.Image,
 		sty.Attachments.Text,
+		sty.Attachments.Skill,
 	)
 	msg := &message.Message{
 		ID:   "u-mut",
@@ -253,6 +254,7 @@ func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
 		sty.Attachments.Deleting,
 		sty.Attachments.Image,
 		sty.Attachments.Text,
+		sty.Attachments.Skill,
 	)
 	msg := &message.Message{
 		ID:    "u-fin",

internal/ui/dialog/actions.go 🔗

@@ -74,6 +74,12 @@ type (
 		Args      map[string]string // Actual argument values
 		Skill     *skills.Skill     // Set when this is a skill command
 	}
+	// ActionAttachSkill is sent when a skill is selected from the commands
+	// dialog to be attached to the conversation as a markdown attachment.
+	ActionAttachSkill struct {
+		ID   string
+		Name string
+	}
 	// ActionRunMCPPrompt is a message to run a custom command.
 	ActionRunMCPPrompt struct {
 		Title       string

internal/ui/dialog/commands.go 🔗

@@ -390,12 +390,21 @@ func (c *Commands) setCommandItems(commandType CommandType) {
 		}
 	case UserCommands:
 		for _, cmd := range c.customCommands {
-			action := ActionRunCustomCommand{
-				Content:   cmd.Content,
-				Arguments: cmd.Arguments,
-				Skill:     cmd.Skill,
+			var action Action
+			if cmd.Skill != nil {
+				action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
+			} else {
+				action = ActionRunCustomCommand{
+					Content:   cmd.Content,
+					Arguments: cmd.Arguments,
+					Skill:     cmd.Skill,
+				}
+			}
+			item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
+			if cmd.Skill != nil {
+				item = item.WithDescription(cmd.Skill.Description)
 			}
-			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
+			commandItems = append(commandItems, item)
 		}
 	case MCPPrompts:
 		for _, cmd := range c.mcpPrompts {

internal/ui/dialog/commands_item.go 🔗

@@ -3,23 +3,26 @@ package dialog
 import (
 	"strings"
 
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
 	"github.com/sahilm/fuzzy"
 )
 
 // CommandItem wraps a uicmd.Command to implement the ListItem interface.
 type CommandItem struct {
 	*list.Versioned
-	id       string
-	title    string
-	shortcut string
-	action   Action
-	aliases  []string
-	t        *styles.Styles
-	m        fuzzy.Match
-	cache    map[int]string
-	focused  bool
+	id          string
+	title       string
+	shortcut    string
+	description string
+	action      Action
+	aliases     []string
+	t           *styles.Styles
+	m           fuzzy.Match
+	cache       map[int]string
+	focused     bool
 }
 
 var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
@@ -48,12 +51,23 @@ func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
 	return c
 }
 
+// WithDescription returns the CommandItem with a description displayed below
+// the title.
+func (c *CommandItem) WithDescription(desc string) *CommandItem {
+	c.description = desc
+	return c
+}
+
 // Filter implements ListItem.
 func (c *CommandItem) Filter() string {
-	if len(c.aliases) == 0 {
-		return c.title
+	base := c.title
+	if len(c.aliases) > 0 {
+		base = c.title + " " + strings.Join(c.aliases, " ")
 	}
-	return c.title + " " + strings.Join(c.aliases, " ")
+	if c.description != "" {
+		base = base + " " + c.description
+	}
+	return base
 }
 
 // ID implements ListItem.
@@ -103,5 +117,20 @@ func (c *CommandItem) Render(width int) string {
 		InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
 		InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
 	}
-	return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+	rendered := renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+	if c.description != "" {
+		descStyle := c.t.Dialog.SecondaryText
+		if c.focused {
+			descStyle = c.t.Dialog.SelectedItem
+		}
+		contentWidth := max(0, width-descStyle.GetHorizontalFrameSize()+1)
+		description := ansi.Truncate(strings.TrimSpace(c.description), contentWidth, "...")
+		descVisWidth := lipgloss.Width(description)
+		gap := strings.Repeat(" ", max(0, contentWidth-descVisWidth))
+		if description == "" {
+			description = " "
+		}
+		rendered = lipgloss.JoinVertical(lipgloss.Left, rendered, descStyle.Render(description+gap))
+	}
+	return rendered
 }

internal/ui/model/ui.go 🔗

@@ -317,6 +317,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
 			com.Styles.Attachments.Deleting,
 			com.Styles.Attachments.Image,
 			com.Styles.Attachments.Text,
+			com.Styles.Attachments.Skill,
 		),
 		attachments.Keymap{
 			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
@@ -1551,6 +1552,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		}
 		cmds = append(cmds, m.sendMessage(content))
 		m.dialog.CloseFrontDialog()
+	case dialog.ActionAttachSkill:
+		m.dialog.CloseFrontDialog()
+		cmds = append(cmds, m.attachSkill(msg.ID, msg.Name))
 	case dialog.ActionRunMCPPrompt:
 		if len(msg.Arguments) > 0 && msg.Args == nil {
 			m.dialog.CloseFrontDialog()
@@ -3136,12 +3140,37 @@ func (m *UI) refreshStyles() {
 		t.Attachments.Deleting,
 		t.Attachments.Image,
 		t.Attachments.Text,
+		t.Attachments.Skill,
 	)
 	m.todoSpinner.Style = t.Pills.TodoSpinner
 	m.status.help.Styles = t.Help
 	m.chat.InvalidateRenderCaches()
 }
 
+// attachSkill reads a skill's content by ID and returns it as a markdown
+// attachment to be added to the attachment toolbar. The user can then
+// compose a message and send it with the skill attached.
+// The name parameter is used as a fallback when the server does not
+// return one.
+func (m *UI) attachSkill(skillID, name string) tea.Cmd {
+	return func() tea.Msg {
+		content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
+		if err != nil {
+			return util.NewErrorMsg(err)
+		}
+		fileName := result.Name
+		if fileName == "" {
+			fileName = name
+		}
+		return message.Attachment{
+			FilePath: fileName,
+			FileName: fileName,
+			MimeType: "text/markdown",
+			Content:  content,
+		}
+	}
+}
+
 // sendMessage sends a message with the given content and attachments.
 func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
 	if !m.com.Workspace.AgentIsReady() {

internal/ui/styles/quickstyle.go 🔗

@@ -923,6 +923,7 @@ func quickStyle(o quickStyleOpts) Styles {
 	attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
 	s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
 	s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
+	s.Attachments.Skill = attachmentIconStyle.SetString(SkillIcon)
 	s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
 	s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
 

internal/ui/styles/styles.go 🔗

@@ -43,6 +43,7 @@ const (
 
 	ImageIcon string = "■"
 	TextIcon  string = "≡"
+	SkillIcon string = "▲"
 
 	ScrollbarThumb string = "┃"
 	ScrollbarTrack string = "│"
@@ -502,6 +503,7 @@ type Styles struct {
 		Normal   lipgloss.Style
 		Image    lipgloss.Style
 		Text     lipgloss.Style
+		Skill    lipgloss.Style
 		Deleting lipgloss.Style
 	}
 

internal/workspace/app_workspace.go 🔗

@@ -18,6 +18,7 @@ import (
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/skills"
 )
 
 // AppWorkspace implements the Workspace interface by delegating
@@ -304,6 +305,16 @@ func (w *AppWorkspace) InitializePrompt() (string, error) {
 	return agent.InitializePrompt(w.store)
 }
 
+func (w *AppWorkspace) ListSkills(_ context.Context) ([]skills.CatalogEntry, error) {
+	mgr := w.app.Skills
+	return skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir()), nil
+}
+
+func (w *AppWorkspace) ReadSkill(_ context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
+	mgr := w.app.Skills
+	return skills.ReadContent(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID)
+}
+
 // -- MCP operations --
 
 func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {

internal/workspace/client_workspace.go 🔗

@@ -483,6 +483,37 @@ func (w *ClientWorkspace) InitializePrompt() (string, error) {
 	return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
 }
 
+func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
+	entries, err := w.client.ListSkills(ctx, w.workspaceID())
+	if err != nil {
+		return nil, err
+	}
+	result := make([]skills.CatalogEntry, len(entries))
+	for i, entry := range entries {
+		result[i] = skills.CatalogEntry{
+			ID:          entry.ID,
+			Name:        entry.Name,
+			Description: entry.Description,
+			Label:       entry.Label,
+			Source:      skills.SourceType(entry.Source),
+		}
+	}
+	return result, nil
+}
+
+func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
+	resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
+	if err != nil {
+		return nil, skills.SkillReadResult{}, err
+	}
+	return resp.Content, skills.SkillReadResult{
+		Name:        resp.Result.Name,
+		Description: resp.Result.Description,
+		Source:      skills.SourceType(resp.Result.Source),
+		Builtin:     resp.Result.Builtin,
+	}, nil
+}
+
 // -- MCP operations --
 
 func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {

internal/workspace/workspace.go 🔗

@@ -18,6 +18,7 @@ import (
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/skills"
 )
 
 // LSPClientInfo holds information about an LSP client's state. This is
@@ -127,6 +128,8 @@ type Workspace interface {
 	ProjectNeedsInitialization() (bool, error)
 	MarkProjectInitialized() error
 	InitializePrompt() (string, error)
+	ListSkills(ctx context.Context) ([]skills.CatalogEntry, error)
+	ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error)
 
 	// MCP operations (server-side in client mode)
 	MCPGetStates() map[string]mcptools.ClientInfo