From 55721653ceb67f04abd53c278ade01d05043bf7d Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Wed, 20 May 2026 12:42:19 -0400 Subject: [PATCH] feat(skills): add descriptions to skill picker and use attachements Co-authored-by: Amolith Assisted-by: Crush:deepseek-v4-pro --- 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(-) create mode 100644 internal/skills/catalog.go diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 8d2ebb017bda61914f6002f68a5a62adeb3bdabe..5a593ee30b014848e982e6075a5ae64c1d17eab7 100644 --- a/internal/backend/backend.go +++ b/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, } } diff --git a/internal/backend/config.go b/internal/backend/config.go index c7e01ff3bd08d3e96edcf875d6198d168fbeb1a5..553b0c2e18225a1ccff3460dfe1a7e8a32610aa4 100644 --- a/internal/backend/config.go +++ b/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 { diff --git a/internal/client/config.go b/internal/client/config.go index e882464969eab7bfdbc428c0281fb12e38ab7347..36ceff21d6b4a1b50d230f6e2fbd6aad81ee7355 100644 --- a/internal/client/config.go +++ b/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"` diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d94f2b0be08317f8adb8a3f4f3ad703fbc92add6..2e4eb1d22b3c724a81b1cbc31feaf4c2b3eb85b4 100644 --- a/internal/cmd/root.go +++ b/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, } } diff --git a/internal/message/attachment.go b/internal/message/attachment.go index c3c04aaea237e9ad060a8687c123a82643edba24..0ac83707212e11133925306ff93a4a8504a7ddec 100644 --- a/internal/message/attachment.go +++ b/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 { diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 22a503b4879806d8b13d109e079604055ebba78b..03afa6b1c7083ea7f55f92faaa6d4f4709311ef0 100644 --- a/internal/proto/proto.go +++ b/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"` diff --git a/internal/server/config.go b/internal/server/config.go index cd96c3603fc94a41aa0d3ae4607e54fb487531ba..58f4ff4c60f4ac03c0e326b7071a4cb651724a21 100644 --- a/internal/server/config.go +++ b/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 diff --git a/internal/server/server.go b/internal/server/server.go index 75ef626d952af7183bcad5681dce7b0fdd85975c..87b7009f4a80894e18a849a215072cea464592c5 100644 --- a/internal/server/server.go +++ b/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) diff --git a/internal/skills/catalog.go b/internal/skills/catalog.go new file mode 100644 index 0000000000000000000000000000000000000000..c1af90ca8bf11e9be1f5993338362b19f4749d22 --- /dev/null +++ b/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) +} diff --git a/internal/skills/manager.go b/internal/skills/manager.go index 8128665c0422fbabccb76382f099999aad8885d2..33e808a221eef3fe3824332542d4d6e3e72f507c 100644 --- a/internal/skills/manager.go +++ b/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 +} diff --git a/internal/ui/attachments/attachments.go b/internal/ui/attachments/attachments.go index d56ea7ac43706ecf36b35fd9ec912d660370eaf1..cb1867cbc2adb128786b1a2cb5a5df20415c7a8a 100644 --- a/internal/ui/attachments/attachments.go +++ b/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 } diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 1e83ec6f6797a866f3587ad6f349bb4c760b1eb3..4fb54b8a9ccfac96529eca7b616501ecbf9d6262 100644 --- a/internal/ui/chat/messages.go +++ b/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: diff --git a/internal/ui/chat/prefix_cache_test.go b/internal/ui/chat/prefix_cache_test.go index 443f5a68dab73d8e518b37d867fc9aa8c758ca25..2ecfc83f9a5f1c635d130ca1da7ca9559af5c988 100644 --- a/internal/ui/chat/prefix_cache_test.go +++ b/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) diff --git a/internal/ui/chat/version_bump_test.go b/internal/ui/chat/version_bump_test.go index 47ce7c5ed8ee615fb57d73df15260b9cddab0482..a65155f72f25c1e2dad9c2182335517e54ada724 100644 --- a/internal/ui/chat/version_bump_test.go +++ b/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", diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 4ec9a59316a703ab49ba571cbf29446d6af3829d..09a3e5b5a0eb267727e67cdf06199e26ef63337c 100644 --- a/internal/ui/dialog/actions.go +++ b/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 diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 185185e5ea3ffc15744d75e605bf8b17b94a8081..ad0eede97c1a17f3cd986da2520e03a8b52d76ee 100644 --- a/internal/ui/dialog/commands.go +++ b/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 { diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 8f656fb388e49eaf9d4b419142c936b4305b1945..6df6b6950041539a70724ac9f3bc2fe786f722ae 100644 --- a/internal/ui/dialog/commands_item.go +++ b/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 } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index df5cd26a7a91fdfb3fa674647aa434d59d20cb97..0b103b266e607a367d105cf9404132d9ffdac524 100644 --- a/internal/ui/model/ui.go +++ b/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() { diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index b631dceff8041af6844586a1cf4585bd140c8b41..4600ea9fe8692d8c7175336379e8080270c46a42 100644 --- a/internal/ui/styles/quickstyle.go +++ b/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) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 20bb5d2424e774858045cdbf1bae836a518cde9e..f0a08537352eac2ee821265db5b76457bc4402d2 100644 --- a/internal/ui/styles/styles.go +++ b/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 } diff --git a/internal/workspace/app_workspace.go b/internal/workspace/app_workspace.go index d4e5ed790e3a1cf7a4bcc299e4e4e63bedfbacd5..17d5903793d3a42b38014d122be0c8d11216d803 100644 --- a/internal/workspace/app_workspace.go +++ b/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 { diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go index 6b3fc362ea923b2092c22ba65de88a92b8675ba2..a959cb3842b891000195f7b51dcd0e2a7e0b240e 100644 --- a/internal/workspace/client_workspace.go +++ b/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 { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 02c54c616f3251140bbee441451c3a4cb14845bd..0434ba21512ea9b732d6b94edb3015a1cd26a1e6 100644 --- a/internal/workspace/workspace.go +++ b/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