@@ -0,0 +1,641 @@
16+ "charm.land/lipgloss/v2"
17+ "github.com/charmbracelet/colorprofile"
18+ "github.com/charmbracelet/crush/internal/config"
19+ "github.com/charmbracelet/crush/internal/db"
20+ "github.com/charmbracelet/crush/internal/message"
21+ "github.com/charmbracelet/crush/internal/session"
22+ "github.com/charmbracelet/crush/internal/ui/chat"
23+ "github.com/charmbracelet/crush/internal/ui/styles"
24+ "github.com/charmbracelet/x/ansi"
25+ "github.com/charmbracelet/x/exp/charmtone"
26+ "github.com/charmbracelet/x/term"
27+ "github.com/spf13/cobra"
30+var sessionCmd = &cobra.Command{
32+ Aliases: []string{"sessions"},
33+ Short: "Manage sessions",
34+ Long: "Manage Crush sessions. Agents can use --json for machine-readable output.",
41+ sessionDeleteJSON bool
42+ sessionRenameJSON bool
45+var sessionListCmd = &cobra.Command{
47+ Aliases: []string{"ls"},
48+ Short: "List all sessions",
49+ Long: "List all sessions. Use --json for machine-readable output.",
50+ RunE: runSessionList,
53+var sessionShowCmd = &cobra.Command{
55+ Short: "Show session details",
56+ Long: "Show session details. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
57+ Args: cobra.ExactArgs(1),
58+ RunE: runSessionShow,
61+var sessionLastCmd = &cobra.Command{
63+ Short: "Show most recent session",
64+ Long: "Show the last updated session. Use --json for machine-readable output.",
65+ RunE: runSessionLast,
68+var sessionDeleteCmd = &cobra.Command{
70+ Aliases: []string{"rm"},
71+ Short: "Delete a session",
72+ Long: "Delete a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
73+ Args: cobra.ExactArgs(1),
74+ RunE: runSessionDelete,
77+var sessionRenameCmd = &cobra.Command{
78+ Use: "rename <id> <title>",
79+ Short: "Rename a session",
80+ Long: "Rename a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
81+ Args: cobra.MinimumNArgs(2),
82+ RunE: runSessionRename,
86+ sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "output in JSON format")
87+ sessionShowCmd.Flags().BoolVar(&sessionShowJSON, "json", false, "output in JSON format")
88+ sessionLastCmd.Flags().BoolVar(&sessionLastJSON, "json", false, "output in JSON format")
89+ sessionDeleteCmd.Flags().BoolVar(&sessionDeleteJSON, "json", false, "output in JSON format")
90+ sessionRenameCmd.Flags().BoolVar(&sessionRenameJSON, "json", false, "output in JSON format")
91+ sessionCmd.AddCommand(sessionListCmd)
92+ sessionCmd.AddCommand(sessionShowCmd)
93+ sessionCmd.AddCommand(sessionLastCmd)
94+ sessionCmd.AddCommand(sessionDeleteCmd)
95+ sessionCmd.AddCommand(sessionRenameCmd)
98+type sessionServices struct {
99+ sessions session.Service
100+ messages message.Service
103+func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) {
104+ dataDir, _ := cmd.Flags().GetString("data-dir")
105+ ctx := cmd.Context()
108+ cfg, err := config.Init("", "", false)
110+ return nil, nil, nil, fmt.Errorf("failed to initialize config: %w", err)
112+ dataDir = cfg.Config().Options.DataDirectory
115+ conn, err := db.Connect(ctx, dataDir)
117+ return nil, nil, nil, fmt.Errorf("failed to connect to database: %w", err)
120+ queries := db.New(conn)
121+ svc := &sessionServices{
122+ sessions: session.NewService(queries, conn),
123+ messages: message.NewService(queries),
125+ return ctx, svc, func() { conn.Close() }, nil
128+func runSessionList(cmd *cobra.Command, _ []string) error {
129+ ctx, svc, cleanup, err := sessionSetup(cmd)
135+ list, err := svc.sessions.List(ctx)
137+ return fmt.Errorf("failed to list sessions: %w", err)
140+ if sessionListJSON {
141+ out := cmd.OutOrStdout()
142+ output := make([]sessionJSON, len(list))
143+ for i, s := range list {
144+ output[i] = sessionJSON{
145+ ID: session.HashID(s.ID),
148+ Created: time.Unix(s.CreatedAt, 0).Format(time.RFC3339),
149+ Modified: time.Unix(s.UpdatedAt, 0).Format(time.RFC3339),
152+ enc := json.NewEncoder(out)
153+ enc.SetEscapeHTML(false)
154+ return enc.Encode(output)
157+ w, cleanup, usingPager := sessionWriter(ctx, len(list))
160+ hashStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
161+ dateStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
163+ width := sessionOutputWidth
164+ if tw, _, err := term.GetSize(os.Stdout.Fd()); err == nil && tw > 0 {
167+ // 7 (hash) + 1 (space) + 25 (RFC3339 date) + 1 (space) = 34 chars prefix.
168+ titleWidth := width - 34
169+ if titleWidth < 10 {
174+ for _, s := range list {
175+ hash := session.HashID(s.ID)[:7]
176+ date := time.Unix(s.CreatedAt, 0).Format(time.RFC3339)
177+ title := strings.ReplaceAll(s.Title, "\n", " ")
178+ title = ansi.Truncate(title, titleWidth, "…")
179+ _, writeErr = fmt.Fprintln(w, hashStyle.Render(hash), dateStyle.Render(date), title)
180+ if writeErr != nil {
184+ if writeErr != nil && usingPager && isBrokenPipe(writeErr) {
190+type sessionJSON struct {
191+ ID string `json:"id"`
192+ UUID string `json:"uuid"`
193+ Title string `json:"title"`
194+ Created string `json:"created"`
195+ Modified string `json:"modified"`
198+type sessionMutationResult struct {
199+ ID string `json:"id"`
200+ UUID string `json:"uuid"`
201+ Title string `json:"title"`
202+ Deleted bool `json:"deleted,omitempty"`
203+ Renamed bool `json:"renamed,omitempty"`
206+// resolveSessionID resolves a session ID that can be a UUID, full hash, or hash prefix.
207+// Returns an error if the prefix is ambiguous (matches multiple sessions).
208+func resolveSessionID(ctx context.Context, svc session.Service, id string) (session.Session, error) {
209+ // Try direct UUID lookup first
210+ if s, err := svc.Get(ctx, id); err == nil {
214+ // List all sessions and check for hash matches
215+ sessions, err := svc.List(ctx)
217+ return session.Session{}, err
220+ var matches []session.Session
221+ for _, s := range sessions {
222+ hash := session.HashID(s.ID)
223+ if hash == id || strings.HasPrefix(hash, id) {
224+ matches = append(matches, s)
228+ if len(matches) == 0 {
229+ return session.Session{}, fmt.Errorf("session not found: %s", id)
232+ if len(matches) == 1 {
233+ return matches[0], nil
236+ // Ambiguous - show matches like Git does
237+ var sb strings.Builder
238+ fmt.Fprintf(&sb, "session ID '%s' is ambiguous. Matches:\n\n", id)
239+ for _, m := range matches {
240+ hash := session.HashID(m.ID)
241+ created := time.Unix(m.CreatedAt, 0).Format("2006-01-02")
242+ // Keep title on one line by replacing newlines with spaces, and truncate.
243+ title := strings.ReplaceAll(m.Title, "\n", " ")
244+ title = ansi.Truncate(title, 50, "…")
245+ fmt.Fprintf(&sb, " %s... %q (created %s)\n", hash[:12], title, created)
247+ sb.WriteString("\nUse more characters or the full hash")
248+ return session.Session{}, errors.New(sb.String())
251+func runSessionShow(cmd *cobra.Command, args []string) error {
252+ ctx, svc, cleanup, err := sessionSetup(cmd)
258+ sess, err := resolveSessionID(ctx, svc.sessions, args[0])
263+ msgs, err := svc.messages.List(ctx, sess.ID)
265+ return fmt.Errorf("failed to list messages: %w", err)
268+ msgPtrs := messagePtrs(msgs)
269+ if sessionShowJSON {
270+ return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
272+ return outputSessionHuman(ctx, sess, msgPtrs)
275+func runSessionDelete(cmd *cobra.Command, args []string) error {
276+ ctx, svc, cleanup, err := sessionSetup(cmd)
282+ sess, err := resolveSessionID(ctx, svc.sessions, args[0])
287+ if err := svc.sessions.Delete(ctx, sess.ID); err != nil {
288+ return fmt.Errorf("failed to delete session: %w", err)
291+ out := cmd.OutOrStdout()
292+ if sessionDeleteJSON {
293+ enc := json.NewEncoder(out)
294+ enc.SetEscapeHTML(false)
295+ return enc.Encode(sessionMutationResult{
296+ ID: session.HashID(sess.ID),
303+ fmt.Fprintf(out, "Deleted session %s\n", session.HashID(sess.ID)[:12])
307+func runSessionRename(cmd *cobra.Command, args []string) error {
308+ ctx, svc, cleanup, err := sessionSetup(cmd)
314+ sess, err := resolveSessionID(ctx, svc.sessions, args[0])
319+ newTitle := strings.Join(args[1:], " ")
320+ if err := svc.sessions.Rename(ctx, sess.ID, newTitle); err != nil {
321+ return fmt.Errorf("failed to rename session: %w", err)
324+ out := cmd.OutOrStdout()
325+ if sessionRenameJSON {
326+ enc := json.NewEncoder(out)
327+ enc.SetEscapeHTML(false)
328+ return enc.Encode(sessionMutationResult{
329+ ID: session.HashID(sess.ID),
336+ fmt.Fprintf(out, "Renamed session %s to %q\n", session.HashID(sess.ID)[:12], newTitle)
340+func runSessionLast(cmd *cobra.Command, _ []string) error {
341+ ctx, svc, cleanup, err := sessionSetup(cmd)
347+ list, err := svc.sessions.List(ctx)
349+ return fmt.Errorf("failed to list sessions: %w", err)
353+ return fmt.Errorf("no sessions found")
358+ msgs, err := svc.messages.List(ctx, sess.ID)
360+ return fmt.Errorf("failed to list messages: %w", err)
363+ msgPtrs := messagePtrs(msgs)
364+ if sessionLastJSON {
365+ return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
367+ return outputSessionHuman(ctx, sess, msgPtrs)
371+ sessionOutputWidth = 80
372+ sessionMaxContentWidth = 120
375+func messagePtrs(msgs []message.Message) []*message.Message {
376+ ptrs := make([]*message.Message, len(msgs))
377+ for i := range msgs {
383+func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Message) error {
384+ output := sessionShowOutput{
385+ Meta: sessionShowMeta{
386+ ID: session.HashID(sess.ID),
389+ Created: time.Unix(sess.CreatedAt, 0).Format(time.RFC3339),
390+ Modified: time.Unix(sess.UpdatedAt, 0).Format(time.RFC3339),
392+ PromptTokens: sess.PromptTokens,
393+ CompletionTokens: sess.CompletionTokens,
394+ TotalTokens: sess.PromptTokens + sess.CompletionTokens,
396+ Messages: make([]sessionShowMessage, len(msgs)),
399+ for i, msg := range msgs {
400+ output.Messages[i] = sessionShowMessage{
402+ Role: string(msg.Role),
403+ Created: time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
405+ Provider: msg.Provider,
406+ Parts: convertParts(msg.Parts),
410+ enc := json.NewEncoder(w)
411+ enc.SetEscapeHTML(false)
412+ return enc.Encode(output)
415+func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error {
416+ sty := styles.DefaultStyles()
417+ toolResults := chat.BuildToolResultMap(msgs)
419+ width := sessionOutputWidth
420+ if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 {
423+ contentWidth := min(width, sessionMaxContentWidth)
425+ keyStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
426+ valStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
428+ hash := session.HashID(sess.ID)[:12]
429+ created := time.Unix(sess.CreatedAt, 0).Format("Mon Jan 2 15:04:05 2006 -0700")
431+ // Render to buffer to determine actual height
432+ var buf strings.Builder
434+ fmt.Fprintln(&buf, keyStyle.Render("ID: ")+valStyle.Render(hash))
435+ fmt.Fprintln(&buf, keyStyle.Render("UUID: ")+valStyle.Render(sess.ID))
436+ fmt.Fprintln(&buf, keyStyle.Render("Title: ")+valStyle.Render(sess.Title))
437+ fmt.Fprintln(&buf, keyStyle.Render("Date: ")+valStyle.Render(created))
441+ for _, msg := range msgs {
442+ items := chat.ExtractMessageItems(&sty, msg, toolResults)
443+ for _, item := range items {
448+ fmt.Fprintln(&buf, item.Render(contentWidth))
453+ contentHeight := strings.Count(buf.String(), "\n")
454+ w, cleanup, usingPager := sessionWriter(ctx, contentHeight)
457+ _, err := io.WriteString(w, buf.String())
458+ // Ignore broken pipe errors when using a pager. This happens when the user
459+ // exits the pager early (e.g., pressing 'q' in less), which closes the pipe
460+ // and causes subsequent writes to fail. These errors are expected user behavior.
461+ if err != nil && usingPager && isBrokenPipe(err) {
467+func isBrokenPipe(err error) bool {
471+ // Check for syscall.EPIPE (broken pipe)
472+ if errors.Is(err, syscall.EPIPE) {
475+ // Also check for "broken pipe" in the error message
476+ return strings.Contains(err.Error(), "broken pipe")
479+// sessionWriter returns a writer, cleanup function, and a bool indicating if a pager is used.
480+// When the content fits within the terminal (or stdout is not a TTY), it returns
481+// a colorprofile.Writer wrapping stdout. When content exceeds terminal height,
482+// it starts a pager process (respecting $PAGER, defaulting to "less -R").
483+func sessionWriter(ctx context.Context, contentHeight int) (io.Writer, func(), bool) {
484+ // Use NewWriter which automatically detects TTY and strips ANSI when redirected
485+ if runtime.GOOS == "windows" || !term.IsTerminal(os.Stdout.Fd()) {
486+ return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
489+ _, termHeight, err := term.GetSize(os.Stdout.Fd())
490+ if err != nil || contentHeight <= termHeight {
491+ return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
494+ // Detect color profile from stderr since stdout is piped to the pager.
495+ profile := colorprofile.Detect(os.Stderr, os.Environ())
497+ pager := os.Getenv("PAGER")
502+ parts := strings.Fields(pager)
503+ cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) //nolint:gosec
504+ cmd.Stdout = os.Stdout
505+ cmd.Stderr = os.Stderr
507+ pipe, err := cmd.StdinPipe()
509+ return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
512+ if err := cmd.Start(); err != nil {
513+ return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
516+ return &colorprofile.Writer{
525+type sessionShowMeta struct {
526+ ID string `json:"id"`
527+ UUID string `json:"uuid"`
528+ Title string `json:"title"`
529+ Created string `json:"created"`
530+ Modified string `json:"modified"`
531+ Cost float64 `json:"cost"`
532+ PromptTokens int64 `json:"prompt_tokens"`
533+ CompletionTokens int64 `json:"completion_tokens"`
534+ TotalTokens int64 `json:"total_tokens"`
537+type sessionShowMessage struct {
538+ ID string `json:"id"`
539+ Role string `json:"role"`
540+ Created string `json:"created"`
541+ Model string `json:"model,omitempty"`
542+ Provider string `json:"provider,omitempty"`
543+ Parts []sessionShowPart `json:"parts"`
546+type sessionShowPart struct {
547+ Type string `json:"type"`
550+ Text string `json:"text,omitempty"`
553+ Thinking string `json:"thinking,omitempty"`
554+ StartedAt int64 `json:"started_at,omitempty"`
555+ FinishedAt int64 `json:"finished_at,omitempty"`
558+ ToolCallID string `json:"tool_call_id,omitempty"`
559+ Name string `json:"name,omitempty"`
560+ Input string `json:"input,omitempty"`
563+ Content string `json:"content,omitempty"`
564+ IsError bool `json:"is_error,omitempty"`
565+ MIMEType string `json:"mime_type,omitempty"`
568+ Size int64 `json:"size,omitempty"`
571+ URL string `json:"url,omitempty"`
572+ Detail string `json:"detail,omitempty"`
575+ Reason string `json:"reason,omitempty"`
576+ Time int64 `json:"time,omitempty"`
579+func convertParts(parts []message.ContentPart) []sessionShowPart {
580+ result := make([]sessionShowPart, 0, len(parts))
581+ for _, part := range parts {
582+ switch p := part.(type) {
583+ case message.TextContent:
584+ result = append(result, sessionShowPart{
588+ case message.ReasoningContent:
589+ result = append(result, sessionShowPart{
591+ Thinking: p.Thinking,
592+ StartedAt: p.StartedAt,
593+ FinishedAt: p.FinishedAt,
595+ case message.ToolCall:
596+ result = append(result, sessionShowPart{
602+ case message.ToolResult:
603+ result = append(result, sessionShowPart{
604+ Type: "tool_result",
605+ ToolCallID: p.ToolCallID,
609+ MIMEType: p.MIMEType,
611+ case message.BinaryContent:
612+ result = append(result, sessionShowPart{
614+ MIMEType: p.MIMEType,
615+ Size: int64(len(p.Data)),
617+ case message.ImageURLContent:
618+ result = append(result, sessionShowPart{
623+ case message.Finish:
624+ result = append(result, sessionShowPart{
626+ Reason: string(p.Reason),
630+ result = append(result, sessionShowPart{
638+type sessionShowOutput struct {
639+ Meta sessionShowMeta `json:"meta"`
640+ Messages []sessionShowMessage `json:"messages"`