feat(cli/session): show skill metadata (#2541)

Christian Rocha created

Change summary

internal/cmd/session.go | 78 ++++++++++++++++++++++++++++++++++++++----
1 file changed, 69 insertions(+), 9 deletions(-)

Detailed changes

internal/cmd/session.go 🔗

@@ -9,12 +9,14 @@ import (
 	"os"
 	"os/exec"
 	"runtime"
+	"sort"
 	"strings"
 	"syscall"
 	"time"
 
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/colorprofile"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
@@ -397,6 +399,7 @@ func messagePtrs(msgs []message.Message) []*message.Message {
 }
 
 func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Message) error {
+	skills := extractSkillsFromMessages(msgs)
 	output := sessionShowOutput{
 		Meta: sessionShowMeta{
 			ID:               session.HashID(sess.ID),
@@ -408,6 +411,7 @@ func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Messag
 			PromptTokens:     sess.PromptTokens,
 			CompletionTokens: sess.CompletionTokens,
 			TotalTokens:      sess.PromptTokens + sess.CompletionTokens,
+			Skills:           skills,
 		},
 		Messages: make([]sessionShowMessage, len(msgs)),
 	}
@@ -444,6 +448,8 @@ func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*messa
 	hash := session.HashID(sess.ID)[:12]
 	created := time.Unix(sess.CreatedAt, 0).Format("Mon Jan 2 15:04:05 2006 -0700")
 
+	skills := extractSkillsFromMessages(msgs)
+
 	// Render to buffer to determine actual height
 	var buf strings.Builder
 
@@ -451,6 +457,19 @@ func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*messa
 	fmt.Fprintln(&buf, keyStyle.Render("UUID:  ")+valStyle.Render(sess.ID))
 	fmt.Fprintln(&buf, keyStyle.Render("Title: ")+valStyle.Render(sess.Title))
 	fmt.Fprintln(&buf, keyStyle.Render("Date:  ")+valStyle.Render(created))
+	if len(skills) > 0 {
+		skillNames := make([]string, len(skills))
+		for i, s := range skills {
+			timestamp := time.Unix(sess.CreatedAt, 0).Format("15:04:05 -0700")
+			if s.LoadedAt != "" {
+				if t, err := time.Parse(time.RFC3339, s.LoadedAt); err == nil {
+					timestamp = t.Format("15:04:05 -0700")
+				}
+			}
+			skillNames[i] = fmt.Sprintf("%s (%s)", s.Name, timestamp)
+		}
+		fmt.Fprintln(&buf, keyStyle.Render("Skills: ")+valStyle.Render(strings.Join(skillNames, ", ")))
+	}
 	fmt.Fprintln(&buf)
 
 	first := true
@@ -539,15 +558,22 @@ func sessionWriter(ctx context.Context, contentHeight int) (io.Writer, func(), b
 }
 
 type sessionShowMeta struct {
-	ID               string  `json:"id"`
-	UUID             string  `json:"uuid"`
-	Title            string  `json:"title"`
-	Created          string  `json:"created"`
-	Modified         string  `json:"modified"`
-	Cost             float64 `json:"cost"`
-	PromptTokens     int64   `json:"prompt_tokens"`
-	CompletionTokens int64   `json:"completion_tokens"`
-	TotalTokens      int64   `json:"total_tokens"`
+	ID               string             `json:"id"`
+	UUID             string             `json:"uuid"`
+	Title            string             `json:"title"`
+	Created          string             `json:"created"`
+	Modified         string             `json:"modified"`
+	Cost             float64            `json:"cost"`
+	PromptTokens     int64              `json:"prompt_tokens"`
+	CompletionTokens int64              `json:"completion_tokens"`
+	TotalTokens      int64              `json:"total_tokens"`
+	Skills           []sessionShowSkill `json:"skills,omitempty"`
+}
+
+type sessionShowSkill struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	LoadedAt    string `json:"loaded_at"`
 }
 
 type sessionShowMessage struct {
@@ -592,6 +618,40 @@ type sessionShowPart struct {
 	Time   int64  `json:"time,omitempty"`
 }
 
+func extractSkillsFromMessages(msgs []*message.Message) []sessionShowSkill {
+	var skills []sessionShowSkill
+	seen := make(map[string]bool)
+
+	for _, msg := range msgs {
+		for _, part := range msg.Parts {
+			if tr, ok := part.(message.ToolResult); ok && tr.Metadata != "" {
+				var meta tools.ViewResponseMetadata
+				if err := json.Unmarshal([]byte(tr.Metadata), &meta); err == nil {
+					if meta.ResourceType == tools.ViewResourceSkill && meta.ResourceName != "" {
+						if !seen[meta.ResourceName] {
+							seen[meta.ResourceName] = true
+							skills = append(skills, sessionShowSkill{
+								Name:        meta.ResourceName,
+								Description: meta.ResourceDescription,
+								LoadedAt:    time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
+							})
+						}
+					}
+				}
+			}
+		}
+	}
+
+	sort.Slice(skills, func(i, j int) bool {
+		if skills[i].LoadedAt == skills[j].LoadedAt {
+			return skills[i].Name < skills[j].Name
+		}
+		return skills[i].LoadedAt < skills[j].LoadedAt
+	})
+
+	return skills
+}
+
 func convertParts(parts []message.ContentPart) []sessionShowPart {
 	result := make([]sessionShowPart, 0, len(parts))
 	for _, part := range parts {