package cmd import ( "context" "encoding/json" "errors" "fmt" "io" "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" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/charmtone" "github.com/charmbracelet/x/term" "github.com/spf13/cobra" ) var sessionCmd = &cobra.Command{ Use: "session", Aliases: []string{"sessions", "s"}, Short: "Manage sessions", Long: "Manage Crush sessions. Agents can use --json for machine-readable output.", } var ( sessionListJSON bool sessionShowJSON bool sessionLastJSON bool sessionDeleteJSON bool sessionRenameJSON bool ) var sessionListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List all sessions", Long: "List all sessions. Use --json for machine-readable output.", RunE: runSessionList, } var sessionShowCmd = &cobra.Command{ Use: "show ", Short: "Show session details", Long: "Show session details. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.", Args: cobra.ExactArgs(1), RunE: runSessionShow, } var sessionLastCmd = &cobra.Command{ Use: "last", Short: "Show most recent session", Long: "Show the last updated session. Use --json for machine-readable output.", RunE: runSessionLast, } var sessionDeleteCmd = &cobra.Command{ Use: "delete ", Aliases: []string{"rm"}, Short: "Delete a session", Long: "Delete a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.", Args: cobra.ExactArgs(1), RunE: runSessionDelete, } var sessionRenameCmd = &cobra.Command{ Use: "rename ", Short: "Rename a session", Long: "Rename a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.", Args: cobra.MinimumNArgs(2), RunE: runSessionRename, } func init() { sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "output in JSON format") sessionShowCmd.Flags().BoolVar(&sessionShowJSON, "json", false, "output in JSON format") sessionLastCmd.Flags().BoolVar(&sessionLastJSON, "json", false, "output in JSON format") sessionDeleteCmd.Flags().BoolVar(&sessionDeleteJSON, "json", false, "output in JSON format") sessionRenameCmd.Flags().BoolVar(&sessionRenameJSON, "json", false, "output in JSON format") sessionCmd.AddCommand(sessionListCmd) sessionCmd.AddCommand(sessionShowCmd) sessionCmd.AddCommand(sessionLastCmd) sessionCmd.AddCommand(sessionDeleteCmd) sessionCmd.AddCommand(sessionRenameCmd) } type sessionServices struct { sessions session.Service messages message.Service } func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) { dataDir, _ := cmd.Flags().GetString("data-dir") ctx := cmd.Context() cfg, err := config.Init("", dataDir, false) if err != nil { return nil, nil, nil, fmt.Errorf("failed to initialize config: %w", err) } if dataDir == "" { dataDir = cfg.Config().Options.DataDirectory } if shouldEnableMetrics(cfg.Config()) { event.Init() } conn, err := db.Connect(ctx, dataDir) if err != nil { return nil, nil, nil, fmt.Errorf("failed to connect to database: %w", err) } queries := db.New(conn) svc := &sessionServices{ sessions: session.NewService(queries, conn), messages: message.NewService(queries), } return ctx, svc, func() { conn.Close() }, nil } func runSessionList(cmd *cobra.Command, _ []string) error { event.SetNonInteractive(true) ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err } defer cleanup() event.SessionListed(sessionListJSON) list, err := svc.sessions.List(ctx) if err != nil { return fmt.Errorf("failed to list sessions: %w", err) } if sessionListJSON { out := cmd.OutOrStdout() output := make([]sessionJSON, len(list)) for i, s := range list { output[i] = sessionJSON{ ID: session.HashID(s.ID), UUID: s.ID, Title: s.Title, Created: time.Unix(s.CreatedAt, 0).Format(time.RFC3339), Modified: time.Unix(s.UpdatedAt, 0).Format(time.RFC3339), } } enc := json.NewEncoder(out) enc.SetEscapeHTML(false) return enc.Encode(output) } w, cleanup, usingPager := sessionWriter(ctx, len(list)) defer cleanup() hashStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu) dateStyle := lipgloss.NewStyle().Foreground(charmtone.Damson) width := sessionOutputWidth if tw, _, err := term.GetSize(os.Stdout.Fd()); err == nil && tw > 0 { width = tw } // 7 (hash) + 1 (space) + 25 (RFC3339 date) + 1 (space) = 34 chars prefix. titleWidth := max(width-34, 10) var writeErr error for _, s := range list { hash := session.HashID(s.ID)[:7] date := time.Unix(s.CreatedAt, 0).Format(time.RFC3339) title := strings.ReplaceAll(s.Title, "\n", " ") title = ansi.Truncate(title, titleWidth, "…") _, writeErr = fmt.Fprintln(w, hashStyle.Render(hash), dateStyle.Render(date), title) if writeErr != nil { break } } if writeErr != nil && usingPager && isBrokenPipe(writeErr) { return nil } return writeErr } type sessionJSON struct { ID string `json:"id"` UUID string `json:"uuid"` Title string `json:"title"` Created string `json:"created"` Modified string `json:"modified"` } type sessionMutationResult struct { ID string `json:"id"` UUID string `json:"uuid"` Title string `json:"title"` Deleted bool `json:"deleted,omitempty"` Renamed bool `json:"renamed,omitempty"` } // resolveSessionID resolves a session ID that can be a UUID, full hash, or hash prefix. // Returns an error if the prefix is ambiguous (matches multiple sessions). func resolveSessionID(ctx context.Context, svc session.Service, id string) (session.Session, error) { // Try direct UUID lookup first if s, err := svc.Get(ctx, id); err == nil { return s, nil } // List all sessions and check for hash matches sessions, err := svc.List(ctx) if err != nil { return session.Session{}, err } var matches []session.Session for _, s := range sessions { hash := session.HashID(s.ID) if hash == id || strings.HasPrefix(hash, id) { matches = append(matches, s) } } if len(matches) == 0 { return session.Session{}, fmt.Errorf("session not found: %s", id) } if len(matches) == 1 { return matches[0], nil } // Ambiguous - show matches like Git does var sb strings.Builder fmt.Fprintf(&sb, "session ID '%s' is ambiguous. Matches:\n\n", id) for _, m := range matches { hash := session.HashID(m.ID) created := time.Unix(m.CreatedAt, 0).Format("2006-01-02") // Keep title on one line by replacing newlines with spaces, and truncate. title := strings.ReplaceAll(m.Title, "\n", " ") title = ansi.Truncate(title, 50, "…") fmt.Fprintf(&sb, " %s... %q (created %s)\n", hash[:12], title, created) } sb.WriteString("\nUse more characters or the full hash") return session.Session{}, errors.New(sb.String()) } func runSessionShow(cmd *cobra.Command, args []string) error { event.SetNonInteractive(true) ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err } defer cleanup() event.SessionShown(sessionShowJSON) sess, err := resolveSessionID(ctx, svc.sessions, args[0]) if err != nil { return err } msgs, err := svc.messages.List(ctx, sess.ID) if err != nil { return fmt.Errorf("failed to list messages: %w", err) } msgPtrs := messagePtrs(msgs) if sessionShowJSON { return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs) } return outputSessionHuman(ctx, sess, msgPtrs) } func runSessionDelete(cmd *cobra.Command, args []string) error { event.SetNonInteractive(true) ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err } defer cleanup() event.SessionDeletedCommand(sessionDeleteJSON) sess, err := resolveSessionID(ctx, svc.sessions, args[0]) if err != nil { return err } if err := svc.sessions.Delete(ctx, sess.ID); err != nil { return fmt.Errorf("failed to delete session: %w", err) } out := cmd.OutOrStdout() if sessionDeleteJSON { enc := json.NewEncoder(out) enc.SetEscapeHTML(false) return enc.Encode(sessionMutationResult{ ID: session.HashID(sess.ID), UUID: sess.ID, Title: sess.Title, Deleted: true, }) } fmt.Fprintf(out, "Deleted session %s\n", session.HashID(sess.ID)[:12]) return nil } func runSessionRename(cmd *cobra.Command, args []string) error { event.SetNonInteractive(true) ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err } defer cleanup() event.SessionRenamed(sessionRenameJSON) sess, err := resolveSessionID(ctx, svc.sessions, args[0]) if err != nil { return err } newTitle := strings.Join(args[1:], " ") if err := svc.sessions.Rename(ctx, sess.ID, newTitle); err != nil { return fmt.Errorf("failed to rename session: %w", err) } out := cmd.OutOrStdout() if sessionRenameJSON { enc := json.NewEncoder(out) enc.SetEscapeHTML(false) return enc.Encode(sessionMutationResult{ ID: session.HashID(sess.ID), UUID: sess.ID, Title: newTitle, Renamed: true, }) } fmt.Fprintf(out, "Renamed session %s to %q\n", session.HashID(sess.ID)[:12], newTitle) return nil } func runSessionLast(cmd *cobra.Command, _ []string) error { event.SetNonInteractive(true) ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err } defer cleanup() event.SessionLastShown(sessionLastJSON) list, err := svc.sessions.List(ctx) if err != nil { return fmt.Errorf("failed to list sessions: %w", err) } if len(list) == 0 { return fmt.Errorf("no sessions found") } sess := list[0] msgs, err := svc.messages.List(ctx, sess.ID) if err != nil { return fmt.Errorf("failed to list messages: %w", err) } msgPtrs := messagePtrs(msgs) if sessionLastJSON { return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs) } return outputSessionHuman(ctx, sess, msgPtrs) } const ( sessionOutputWidth = 80 sessionMaxContentWidth = 120 ) func messagePtrs(msgs []message.Message) []*message.Message { ptrs := make([]*message.Message, len(msgs)) for i := range msgs { ptrs[i] = &msgs[i] } return ptrs } func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Message) error { skills := extractSkillsFromMessages(msgs) output := sessionShowOutput{ Meta: sessionShowMeta{ ID: session.HashID(sess.ID), UUID: sess.ID, Title: sess.Title, Created: time.Unix(sess.CreatedAt, 0).Format(time.RFC3339), Modified: time.Unix(sess.UpdatedAt, 0).Format(time.RFC3339), Cost: sess.Cost, PromptTokens: sess.PromptTokens, CompletionTokens: sess.CompletionTokens, TotalTokens: sess.PromptTokens + sess.CompletionTokens, Skills: skills, }, Messages: make([]sessionShowMessage, len(msgs)), } for i, msg := range msgs { output.Messages[i] = sessionShowMessage{ ID: msg.ID, Role: string(msg.Role), Created: time.Unix(msg.CreatedAt, 0).Format(time.RFC3339), Model: msg.Model, Provider: msg.Provider, Parts: convertParts(msg.Parts), } } enc := json.NewEncoder(w) enc.SetEscapeHTML(false) return enc.Encode(output) } func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error { sty := styles.DefaultStyles() toolResults := chat.BuildToolResultMap(msgs) width := sessionOutputWidth if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 { width = w } contentWidth := min(width, sessionMaxContentWidth) keyStyle := lipgloss.NewStyle().Foreground(charmtone.Damson) valStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu) 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 fmt.Fprintln(&buf, keyStyle.Render("ID: ")+valStyle.Render(hash)) 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 for _, msg := range msgs { items := chat.ExtractMessageItems(&sty, msg, toolResults) for _, item := range items { if !first { fmt.Fprintln(&buf) } first = false fmt.Fprintln(&buf, item.Render(contentWidth)) } } fmt.Fprintln(&buf) contentHeight := strings.Count(buf.String(), "\n") w, cleanup, usingPager := sessionWriter(ctx, contentHeight) defer cleanup() _, err := io.WriteString(w, buf.String()) // Ignore broken pipe errors when using a pager. This happens when the user // exits the pager early (e.g., pressing 'q' in less), which closes the pipe // and causes subsequent writes to fail. These errors are expected user behavior. if err != nil && usingPager && isBrokenPipe(err) { return nil } return err } func isBrokenPipe(err error) bool { if err == nil { return false } // Check for syscall.EPIPE (broken pipe) if errors.Is(err, syscall.EPIPE) { return true } // Also check for "broken pipe" in the error message return strings.Contains(err.Error(), "broken pipe") } // sessionWriter returns a writer, cleanup function, and a bool indicating if a pager is used. // When the content fits within the terminal (or stdout is not a TTY), it returns // a colorprofile.Writer wrapping stdout. When content exceeds terminal height, // it starts a pager process (respecting $PAGER, defaulting to "less -R"). func sessionWriter(ctx context.Context, contentHeight int) (io.Writer, func(), bool) { // Use NewWriter which automatically detects TTY and strips ANSI when redirected if runtime.GOOS == "windows" || !term.IsTerminal(os.Stdout.Fd()) { return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false } _, termHeight, err := term.GetSize(os.Stdout.Fd()) if err != nil || contentHeight <= termHeight { return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false } // Detect color profile from stderr since stdout is piped to the pager. profile := colorprofile.Detect(os.Stderr, os.Environ()) pager := os.Getenv("PAGER") if pager == "" { pager = "less -R" } parts := strings.Fields(pager) cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) //nolint:gosec cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr pipe, err := cmd.StdinPipe() if err != nil { return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false } if err := cmd.Start(); err != nil { return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false } return &colorprofile.Writer{ Forward: pipe, Profile: profile, }, func() { pipe.Close() _ = cmd.Wait() }, true } 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"` Skills []sessionShowSkill `json:"skills,omitempty"` } type sessionShowSkill struct { Name string `json:"name"` Description string `json:"description"` LoadedAt string `json:"loaded_at"` } type sessionShowMessage struct { ID string `json:"id"` Role string `json:"role"` Created string `json:"created"` Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` Parts []sessionShowPart `json:"parts"` } type sessionShowPart struct { Type string `json:"type"` // Text content Text string `json:"text,omitempty"` // Reasoning Thinking string `json:"thinking,omitempty"` StartedAt int64 `json:"started_at,omitempty"` FinishedAt int64 `json:"finished_at,omitempty"` // Tool call ToolCallID string `json:"tool_call_id,omitempty"` Name string `json:"name,omitempty"` Input string `json:"input,omitempty"` // Tool result Content string `json:"content,omitempty"` IsError bool `json:"is_error,omitempty"` MIMEType string `json:"mime_type,omitempty"` // Binary Size int64 `json:"size,omitempty"` // Image URL URL string `json:"url,omitempty"` Detail string `json:"detail,omitempty"` // Finish Reason string `json:"reason,omitempty"` 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 { switch p := part.(type) { case message.TextContent: result = append(result, sessionShowPart{ Type: "text", Text: p.Text, }) case message.ReasoningContent: result = append(result, sessionShowPart{ Type: "reasoning", Thinking: p.Thinking, StartedAt: p.StartedAt, FinishedAt: p.FinishedAt, }) case message.ToolCall: result = append(result, sessionShowPart{ Type: "tool_call", ToolCallID: p.ID, Name: p.Name, Input: p.Input, }) case message.ToolResult: result = append(result, sessionShowPart{ Type: "tool_result", ToolCallID: p.ToolCallID, Name: p.Name, Content: p.Content, IsError: p.IsError, MIMEType: p.MIMEType, }) case message.BinaryContent: result = append(result, sessionShowPart{ Type: "binary", MIMEType: p.MIMEType, Size: int64(len(p.Data)), }) case message.ImageURLContent: result = append(result, sessionShowPart{ Type: "image_url", URL: p.URL, Detail: p.Detail, }) case message.Finish: result = append(result, sessionShowPart{ Type: "finish", Reason: string(p.Reason), Time: p.Time, }) default: result = append(result, sessionShowPart{ Type: "unknown", }) } } return result } type sessionShowOutput struct { Meta sessionShowMeta `json:"meta"` Messages []sessionShowMessage `json:"messages"` }