on main help sign in
feat: CLI-based session access and management (#2373)
cfcbd69Christian Rocha
go.mod+1−1
@@ -6,6 +6,7 @@ require (
6 charm.land/bubbles/v2 v2.0.0
6 charm.land/bubbles/v2 v2.0.0
7 charm.land/bubbletea/v2 v2.0.2
7 charm.land/bubbletea/v2 v2.0.2
8 charm.land/catwalk v0.28.4
8 charm.land/catwalk v0.28.4
9+ charm.land/fang/v2 v2.0.1
9 charm.land/fantasy v0.12.1
10 charm.land/fantasy v0.12.1
10 charm.land/glamour/v2 v2.0.0
11 charm.land/glamour/v2 v2.0.0
11 charm.land/lipgloss/v2 v2.0.1
12 charm.land/lipgloss/v2 v2.0.1
@@ -21,7 +22,6 @@ require (
21 github.com/bmatcuk/doublestar/v4 v4.10.0
22 github.com/bmatcuk/doublestar/v4 v4.10.0
22 github.com/charlievieth/fastwalk v1.0.14
23 github.com/charlievieth/fastwalk v1.0.14
23 github.com/charmbracelet/colorprofile v0.4.3
24 github.com/charmbracelet/colorprofile v0.4.3
24− github.com/charmbracelet/fang v1.0.0
25 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
25 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
26 github.com/charmbracelet/x/ansi v0.11.6
26 github.com/charmbracelet/x/ansi v0.11.6
27 github.com/charmbracelet/x/editor v0.2.0
27 github.com/charmbracelet/x/editor v0.2.0
go.sum+2−2
@@ -4,6 +4,8 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
4charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
4charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
5charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw=
5charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw=
6charm.land/catwalk v0.28.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg=
6charm.land/catwalk v0.28.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg=
7+charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
8+charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
7charm.land/fantasy v0.12.1 h1:awszoi5O9FIjMEkfyCMiLJfVRNLckp/zQkFrA6IxQqc=
9charm.land/fantasy v0.12.1 h1:awszoi5O9FIjMEkfyCMiLJfVRNLckp/zQkFrA6IxQqc=
8charm.land/fantasy v0.12.1/go.mod h1:QeRVUeG1XNTWBszRAbhUtPyX1VWs6zjkCxwfcwnICdc=
10charm.land/fantasy v0.12.1/go.mod h1:QeRVUeG1XNTWBszRAbhUtPyX1VWs6zjkCxwfcwnICdc=
9charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
11charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
@@ -100,8 +102,6 @@ github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:
100github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
102github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
101github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
103github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
102github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
104github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
103−github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
104−github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
105github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
105github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
106github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
106github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
107github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
107github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
internal/agent/agent.go+4−4
@@ -842,9 +842,9 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
842 // Welp, the large model didn't work either. Use the default
842 // Welp, the large model didn't work either. Use the default
843 // session name and return.
843 // session name and return.
844 slog.Error("Error generating title with large model", "err", err)
844 slog.Error("Error generating title with large model", "err", err)
845− saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, DefaultSessionName, 0, 0, 0)
845+ saveErr := a.sessions.Rename(ctx, sessionID, DefaultSessionName)
846 if saveErr != nil {
846 if saveErr != nil {
847− slog.Error("Failed to save session title and usage", "error", saveErr)
847+ slog.Error("Failed to save session title", "error", saveErr)
848 }
848 }
849 return
849 return
850 }
850 }
@@ -854,9 +854,9 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
854 // Actually, we didn't get a response so we can't. Use the default
854 // Actually, we didn't get a response so we can't. Use the default
855 // session name and return.
855 // session name and return.
856 slog.Error("Response is nil; can't generate title")
856 slog.Error("Response is nil; can't generate title")
857− saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, DefaultSessionName, 0, 0, 0)
857+ saveErr := a.sessions.Rename(ctx, sessionID, DefaultSessionName)
858 if saveErr != nil {
858 if saveErr != nil {
859− slog.Error("Failed to save session title and usage", "error", saveErr)
859+ slog.Error("Failed to save session title", "error", saveErr)
860 }
860 }
861 return
861 return
862 }
862 }
internal/cmd/root.go+2−1
@@ -13,6 +13,7 @@ import (
13 "strings"
13 "strings"
14
14
15 tea "charm.land/bubbletea/v2"
15 tea "charm.land/bubbletea/v2"
16+ "charm.land/fang/v2"
16 "charm.land/lipgloss/v2"
17 "charm.land/lipgloss/v2"
17 "github.com/charmbracelet/colorprofile"
18 "github.com/charmbracelet/colorprofile"
18 "github.com/charmbracelet/crush/internal/app"
19 "github.com/charmbracelet/crush/internal/app"
@@ -23,7 +24,6 @@ import (
23 "github.com/charmbracelet/crush/internal/ui/common"
24 "github.com/charmbracelet/crush/internal/ui/common"
24 ui "github.com/charmbracelet/crush/internal/ui/model"
25 ui "github.com/charmbracelet/crush/internal/ui/model"
25 "github.com/charmbracelet/crush/internal/version"
26 "github.com/charmbracelet/crush/internal/version"
26− "github.com/charmbracelet/fang"
27 uv "github.com/charmbracelet/ultraviolet"
27 uv "github.com/charmbracelet/ultraviolet"
28 "github.com/charmbracelet/x/ansi"
28 "github.com/charmbracelet/x/ansi"
29 "github.com/charmbracelet/x/exp/charmtone"
29 "github.com/charmbracelet/x/exp/charmtone"
@@ -47,6 +47,7 @@ func init() {
47 schemaCmd,
47 schemaCmd,
48 loginCmd,
48 loginCmd,
49 statsCmd,
49 statsCmd,
50+ sessionCmd,
50 )
51 )
51}
52}
52
53
internal/cmd/session.gonew file+641
@@ -0,0 +1,641 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "runtime"
12+ "strings"
13+ "syscall"
14+ "time"
15+
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"
28+)
29+
30+var sessionCmd = &cobra.Command{
31+ Use: "session",
32+ Aliases: []string{"sessions"},
33+ Short: "Manage sessions",
34+ Long: "Manage Crush sessions. Agents can use --json for machine-readable output.",
35+}
36+
37+var (
38+ sessionListJSON bool
39+ sessionShowJSON bool
40+ sessionLastJSON bool
41+ sessionDeleteJSON bool
42+ sessionRenameJSON bool
43+)
44+
45+var sessionListCmd = &cobra.Command{
46+ Use: "list",
47+ Aliases: []string{"ls"},
48+ Short: "List all sessions",
49+ Long: "List all sessions. Use --json for machine-readable output.",
50+ RunE: runSessionList,
51+}
52+
53+var sessionShowCmd = &cobra.Command{
54+ Use: "show <id>",
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,
59+}
60+
61+var sessionLastCmd = &cobra.Command{
62+ Use: "last",
63+ Short: "Show most recent session",
64+ Long: "Show the last updated session. Use --json for machine-readable output.",
65+ RunE: runSessionLast,
66+}
67+
68+var sessionDeleteCmd = &cobra.Command{
69+ Use: "delete <id>",
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,
75+}
76+
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,
83+}
84+
85+func init() {
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)
96+}
97+
98+type sessionServices struct {
99+ sessions session.Service
100+ messages message.Service
101+}
102+
103+func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) {
104+ dataDir, _ := cmd.Flags().GetString("data-dir")
105+ ctx := cmd.Context()
106+
107+ if dataDir == "" {
108+ cfg, err := config.Init("", "", false)
109+ if err != nil {
110+ return nil, nil, nil, fmt.Errorf("failed to initialize config: %w", err)
111+ }
112+ dataDir = cfg.Config().Options.DataDirectory
113+ }
114+
115+ conn, err := db.Connect(ctx, dataDir)
116+ if err != nil {
117+ return nil, nil, nil, fmt.Errorf("failed to connect to database: %w", err)
118+ }
119+
120+ queries := db.New(conn)
121+ svc := &sessionServices{
122+ sessions: session.NewService(queries, conn),
123+ messages: message.NewService(queries),
124+ }
125+ return ctx, svc, func() { conn.Close() }, nil
126+}
127+
128+func runSessionList(cmd *cobra.Command, _ []string) error {
129+ ctx, svc, cleanup, err := sessionSetup(cmd)
130+ if err != nil {
131+ return err
132+ }
133+ defer cleanup()
134+
135+ list, err := svc.sessions.List(ctx)
136+ if err != nil {
137+ return fmt.Errorf("failed to list sessions: %w", err)
138+ }
139+
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),
146+ UUID: s.ID,
147+ Title: s.Title,
148+ Created: time.Unix(s.CreatedAt, 0).Format(time.RFC3339),
149+ Modified: time.Unix(s.UpdatedAt, 0).Format(time.RFC3339),
150+ }
151+ }
152+ enc := json.NewEncoder(out)
153+ enc.SetEscapeHTML(false)
154+ return enc.Encode(output)
155+ }
156+
157+ w, cleanup, usingPager := sessionWriter(ctx, len(list))
158+ defer cleanup()
159+
160+ hashStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
161+ dateStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
162+
163+ width := sessionOutputWidth
164+ if tw, _, err := term.GetSize(os.Stdout.Fd()); err == nil && tw > 0 {
165+ width = tw
166+ }
167+ // 7 (hash) + 1 (space) + 25 (RFC3339 date) + 1 (space) = 34 chars prefix.
168+ titleWidth := width - 34
169+ if titleWidth < 10 {
170+ titleWidth = 10
171+ }
172+
173+ var writeErr error
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 {
181+ break
182+ }
183+ }
184+ if writeErr != nil && usingPager && isBrokenPipe(writeErr) {
185+ return nil
186+ }
187+ return writeErr
188+}
189+
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"`
196+}
197+
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"`
204+}
205+
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 {
211+ return s, nil
212+ }
213+
214+ // List all sessions and check for hash matches
215+ sessions, err := svc.List(ctx)
216+ if err != nil {
217+ return session.Session{}, err
218+ }
219+
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)
225+ }
226+ }
227+
228+ if len(matches) == 0 {
229+ return session.Session{}, fmt.Errorf("session not found: %s", id)
230+ }
231+
232+ if len(matches) == 1 {
233+ return matches[0], nil
234+ }
235+
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)
246+ }
247+ sb.WriteString("\nUse more characters or the full hash")
248+ return session.Session{}, errors.New(sb.String())
249+}
250+
251+func runSessionShow(cmd *cobra.Command, args []string) error {
252+ ctx, svc, cleanup, err := sessionSetup(cmd)
253+ if err != nil {
254+ return err
255+ }
256+ defer cleanup()
257+
258+ sess, err := resolveSessionID(ctx, svc.sessions, args[0])
259+ if err != nil {
260+ return err
261+ }
262+
263+ msgs, err := svc.messages.List(ctx, sess.ID)
264+ if err != nil {
265+ return fmt.Errorf("failed to list messages: %w", err)
266+ }
267+
268+ msgPtrs := messagePtrs(msgs)
269+ if sessionShowJSON {
270+ return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
271+ }
272+ return outputSessionHuman(ctx, sess, msgPtrs)
273+}
274+
275+func runSessionDelete(cmd *cobra.Command, args []string) error {
276+ ctx, svc, cleanup, err := sessionSetup(cmd)
277+ if err != nil {
278+ return err
279+ }
280+ defer cleanup()
281+
282+ sess, err := resolveSessionID(ctx, svc.sessions, args[0])
283+ if err != nil {
284+ return err
285+ }
286+
287+ if err := svc.sessions.Delete(ctx, sess.ID); err != nil {
288+ return fmt.Errorf("failed to delete session: %w", err)
289+ }
290+
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),
297+ UUID: sess.ID,
298+ Title: sess.Title,
299+ Deleted: true,
300+ })
301+ }
302+
303+ fmt.Fprintf(out, "Deleted session %s\n", session.HashID(sess.ID)[:12])
304+ return nil
305+}
306+
307+func runSessionRename(cmd *cobra.Command, args []string) error {
308+ ctx, svc, cleanup, err := sessionSetup(cmd)
309+ if err != nil {
310+ return err
311+ }
312+ defer cleanup()
313+
314+ sess, err := resolveSessionID(ctx, svc.sessions, args[0])
315+ if err != nil {
316+ return err
317+ }
318+
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)
322+ }
323+
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),
330+ UUID: sess.ID,
331+ Title: newTitle,
332+ Renamed: true,
333+ })
334+ }
335+
336+ fmt.Fprintf(out, "Renamed session %s to %q\n", session.HashID(sess.ID)[:12], newTitle)
337+ return nil
338+}
339+
340+func runSessionLast(cmd *cobra.Command, _ []string) error {
341+ ctx, svc, cleanup, err := sessionSetup(cmd)
342+ if err != nil {
343+ return err
344+ }
345+ defer cleanup()
346+
347+ list, err := svc.sessions.List(ctx)
348+ if err != nil {
349+ return fmt.Errorf("failed to list sessions: %w", err)
350+ }
351+
352+ if len(list) == 0 {
353+ return fmt.Errorf("no sessions found")
354+ }
355+
356+ sess := list[0]
357+
358+ msgs, err := svc.messages.List(ctx, sess.ID)
359+ if err != nil {
360+ return fmt.Errorf("failed to list messages: %w", err)
361+ }
362+
363+ msgPtrs := messagePtrs(msgs)
364+ if sessionLastJSON {
365+ return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
366+ }
367+ return outputSessionHuman(ctx, sess, msgPtrs)
368+}
369+
370+const (
371+ sessionOutputWidth = 80
372+ sessionMaxContentWidth = 120
373+)
374+
375+func messagePtrs(msgs []message.Message) []*message.Message {
376+ ptrs := make([]*message.Message, len(msgs))
377+ for i := range msgs {
378+ ptrs[i] = &msgs[i]
379+ }
380+ return ptrs
381+}
382+
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),
387+ UUID: sess.ID,
388+ Title: sess.Title,
389+ Created: time.Unix(sess.CreatedAt, 0).Format(time.RFC3339),
390+ Modified: time.Unix(sess.UpdatedAt, 0).Format(time.RFC3339),
391+ Cost: sess.Cost,
392+ PromptTokens: sess.PromptTokens,
393+ CompletionTokens: sess.CompletionTokens,
394+ TotalTokens: sess.PromptTokens + sess.CompletionTokens,
395+ },
396+ Messages: make([]sessionShowMessage, len(msgs)),
397+ }
398+
399+ for i, msg := range msgs {
400+ output.Messages[i] = sessionShowMessage{
401+ ID: msg.ID,
402+ Role: string(msg.Role),
403+ Created: time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
404+ Model: msg.Model,
405+ Provider: msg.Provider,
406+ Parts: convertParts(msg.Parts),
407+ }
408+ }
409+
410+ enc := json.NewEncoder(w)
411+ enc.SetEscapeHTML(false)
412+ return enc.Encode(output)
413+}
414+
415+func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error {
416+ sty := styles.DefaultStyles()
417+ toolResults := chat.BuildToolResultMap(msgs)
418+
419+ width := sessionOutputWidth
420+ if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 {
421+ width = w
422+ }
423+ contentWidth := min(width, sessionMaxContentWidth)
424+
425+ keyStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
426+ valStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
427+
428+ hash := session.HashID(sess.ID)[:12]
429+ created := time.Unix(sess.CreatedAt, 0).Format("Mon Jan 2 15:04:05 2006 -0700")
430+
431+ // Render to buffer to determine actual height
432+ var buf strings.Builder
433+
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))
438+ fmt.Fprintln(&buf)
439+
440+ first := true
441+ for _, msg := range msgs {
442+ items := chat.ExtractMessageItems(&sty, msg, toolResults)
443+ for _, item := range items {
444+ if !first {
445+ fmt.Fprintln(&buf)
446+ }
447+ first = false
448+ fmt.Fprintln(&buf, item.Render(contentWidth))
449+ }
450+ }
451+ fmt.Fprintln(&buf)
452+
453+ contentHeight := strings.Count(buf.String(), "\n")
454+ w, cleanup, usingPager := sessionWriter(ctx, contentHeight)
455+ defer cleanup()
456+
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) {
462+ return nil
463+ }
464+ return err
465+}
466+
467+func isBrokenPipe(err error) bool {
468+ if err == nil {
469+ return false
470+ }
471+ // Check for syscall.EPIPE (broken pipe)
472+ if errors.Is(err, syscall.EPIPE) {
473+ return true
474+ }
475+ // Also check for "broken pipe" in the error message
476+ return strings.Contains(err.Error(), "broken pipe")
477+}
478+
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
487+ }
488+
489+ _, termHeight, err := term.GetSize(os.Stdout.Fd())
490+ if err != nil || contentHeight <= termHeight {
491+ return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
492+ }
493+
494+ // Detect color profile from stderr since stdout is piped to the pager.
495+ profile := colorprofile.Detect(os.Stderr, os.Environ())
496+
497+ pager := os.Getenv("PAGER")
498+ if pager == "" {
499+ pager = "less -R"
500+ }
501+
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
506+
507+ pipe, err := cmd.StdinPipe()
508+ if err != nil {
509+ return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
510+ }
511+
512+ if err := cmd.Start(); err != nil {
513+ return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
514+ }
515+
516+ return &colorprofile.Writer{
517+ Forward: pipe,
518+ Profile: profile,
519+ }, func() {
520+ pipe.Close()
521+ _ = cmd.Wait()
522+ }, true
523+}
524+
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"`
535+}
536+
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"`
544+}
545+
546+type sessionShowPart struct {
547+ Type string `json:"type"`
548+
549+ // Text content
550+ Text string `json:"text,omitempty"`
551+
552+ // Reasoning
553+ Thinking string `json:"thinking,omitempty"`
554+ StartedAt int64 `json:"started_at,omitempty"`
555+ FinishedAt int64 `json:"finished_at,omitempty"`
556+
557+ // Tool call
558+ ToolCallID string `json:"tool_call_id,omitempty"`
559+ Name string `json:"name,omitempty"`
560+ Input string `json:"input,omitempty"`
561+
562+ // Tool result
563+ Content string `json:"content,omitempty"`
564+ IsError bool `json:"is_error,omitempty"`
565+ MIMEType string `json:"mime_type,omitempty"`
566+
567+ // Binary
568+ Size int64 `json:"size,omitempty"`
569+
570+ // Image URL
571+ URL string `json:"url,omitempty"`
572+ Detail string `json:"detail,omitempty"`
573+
574+ // Finish
575+ Reason string `json:"reason,omitempty"`
576+ Time int64 `json:"time,omitempty"`
577+}
578+
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{
585+ Type: "text",
586+ Text: p.Text,
587+ })
588+ case message.ReasoningContent:
589+ result = append(result, sessionShowPart{
590+ Type: "reasoning",
591+ Thinking: p.Thinking,
592+ StartedAt: p.StartedAt,
593+ FinishedAt: p.FinishedAt,
594+ })
595+ case message.ToolCall:
596+ result = append(result, sessionShowPart{
597+ Type: "tool_call",
598+ ToolCallID: p.ID,
599+ Name: p.Name,
600+ Input: p.Input,
601+ })
602+ case message.ToolResult:
603+ result = append(result, sessionShowPart{
604+ Type: "tool_result",
605+ ToolCallID: p.ToolCallID,
606+ Name: p.Name,
607+ Content: p.Content,
608+ IsError: p.IsError,
609+ MIMEType: p.MIMEType,
610+ })
611+ case message.BinaryContent:
612+ result = append(result, sessionShowPart{
613+ Type: "binary",
614+ MIMEType: p.MIMEType,
615+ Size: int64(len(p.Data)),
616+ })
617+ case message.ImageURLContent:
618+ result = append(result, sessionShowPart{
619+ Type: "image_url",
620+ URL: p.URL,
621+ Detail: p.Detail,
622+ })
623+ case message.Finish:
624+ result = append(result, sessionShowPart{
625+ Type: "finish",
626+ Reason: string(p.Reason),
627+ Time: p.Time,
628+ })
629+ default:
630+ result = append(result, sessionShowPart{
631+ Type: "unknown",
632+ })
633+ }
634+ }
635+ return result
636+}
637+
638+type sessionShowOutput struct {
639+ Meta sessionShowMeta `json:"meta"`
640+ Messages []sessionShowMessage `json:"messages"`
641+}
internal/db/db.go+10
@@ -120,6 +120,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
120 if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
120 if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
121 return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
121 return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
122 }
122 }
123+ if q.renameSessionStmt, err = db.PrepareContext(ctx, renameSession); err != nil {
124+ return nil, fmt.Errorf("error preparing query RenameSession: %w", err)
125+ }
123 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
126 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
124 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
127 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
125 }
128 }
@@ -294,6 +297,11 @@ func (q *Queries) Close() error {
294 err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
297 err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
295 }
298 }
296 }
299 }
300+ if q.renameSessionStmt != nil {
301+ if cerr := q.renameSessionStmt.Close(); cerr != nil {
302+ err = fmt.Errorf("error closing renameSessionStmt: %w", cerr)
303+ }
304+ }
297 if q.updateMessageStmt != nil {
305 if q.updateMessageStmt != nil {
298 if cerr := q.updateMessageStmt.Close(); cerr != nil {
306 if cerr := q.updateMessageStmt.Close(); cerr != nil {
299 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
307 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -380,6 +388,7 @@ type Queries struct {
380 listSessionsStmt *sql.Stmt
388 listSessionsStmt *sql.Stmt
381 listUserMessagesBySessionStmt *sql.Stmt
389 listUserMessagesBySessionStmt *sql.Stmt
382 recordFileReadStmt *sql.Stmt
390 recordFileReadStmt *sql.Stmt
391+ renameSessionStmt *sql.Stmt
383 updateMessageStmt *sql.Stmt
392 updateMessageStmt *sql.Stmt
384 updateSessionStmt *sql.Stmt
393 updateSessionStmt *sql.Stmt
385 updateSessionTitleAndUsageStmt *sql.Stmt
394 updateSessionTitleAndUsageStmt *sql.Stmt
@@ -421,6 +430,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
421 listSessionsStmt: q.listSessionsStmt,
430 listSessionsStmt: q.listSessionsStmt,
422 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
431 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
423 recordFileReadStmt: q.recordFileReadStmt,
432 recordFileReadStmt: q.recordFileReadStmt,
433+ renameSessionStmt: q.renameSessionStmt,
424 updateMessageStmt: q.updateMessageStmt,
434 updateMessageStmt: q.updateMessageStmt,
425 updateSessionStmt: q.updateSessionStmt,
435 updateSessionStmt: q.updateSessionStmt,
426 updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
436 updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
internal/db/models.go+1−1
@@ -34,7 +34,7 @@ type Message struct {
34type ReadFile struct {
34type ReadFile struct {
35 SessionID string `json:"session_id"`
35 SessionID string `json:"session_id"`
36 Path string `json:"path"`
36 Path string `json:"path"`
37− ReadAt int64 `json:"read_at"` // Unix timestamp when file was last read
37+ ReadAt int64 `json:"read_at"`
38}
38}
39
39
40type Session struct {
40type Session struct {
internal/db/querier.go+1
@@ -41,6 +41,7 @@ type Querier interface {
41 ListSessions(ctx context.Context) ([]Session, error)
41 ListSessions(ctx context.Context) ([]Session, error)
42 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
42 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
43 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
43 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
44+ RenameSession(ctx context.Context, arg RenameSessionParams) error
44 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
45 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
45 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
46 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
46 UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
47 UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
internal/db/read_files.sql.go+21−32
@@ -22,32 +22,10 @@ type GetFileReadParams struct {
22func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) {
22func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) {
23 row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path)
23 row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path)
24 var i ReadFile
24 var i ReadFile
25− err := row.Scan(
25+ err := row.Scan(&i.SessionID, &i.Path, &i.ReadAt)
26− &i.SessionID,
27− &i.Path,
28− &i.ReadAt,
29− )
30 return i, err
26 return i, err
31}
27}
32
28
33−const recordFileRead = `-- name: RecordFileRead :exec
34−INSERT INTO read_files (
35− session_id,
36− path,
37− read_at
38−) VALUES (
39− ?,
40− ?,
41− strftime('%s', 'now')
42−) ON CONFLICT(path, session_id) DO UPDATE SET
43− read_at = excluded.read_at
44−`
45−
46−type RecordFileReadParams struct {
47− SessionID string `json:"session_id"`
48− Path string `json:"path"`
49−}
50−
51const listSessionReadFiles = `-- name: ListSessionReadFiles :many
29const listSessionReadFiles = `-- name: ListSessionReadFiles :many
52SELECT session_id, path, read_at FROM read_files
30SELECT session_id, path, read_at FROM read_files
53WHERE session_id = ?
31WHERE session_id = ?
@@ -63,11 +41,7 @@ func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([
63 items := []ReadFile{}
41 items := []ReadFile{}
64 for rows.Next() {
42 for rows.Next() {
65 var i ReadFile
43 var i ReadFile
66− if err := rows.Scan(
44+ if err := rows.Scan(&i.SessionID, &i.Path, &i.ReadAt); err != nil {
67− &i.SessionID,
68− &i.Path,
69− &i.ReadAt,
70− ); err != nil {
71 return nil, err
45 return nil, err
72 }
46 }
73 items = append(items, i)
47 items = append(items, i)
@@ -81,10 +55,25 @@ func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([
81 return items, nil
55 return items, nil
82}
56}
83
57
58+const recordFileRead = `-- name: RecordFileRead :exec
59+INSERT INTO read_files (
60+ session_id,
61+ path,
62+ read_at
63+) VALUES (
64+ ?,
65+ ?,
66+ strftime('%s', 'now')
67+) ON CONFLICT(path, session_id) DO UPDATE SET
68+ read_at = excluded.read_at
69+`
70+
71+type RecordFileReadParams struct {
72+ SessionID string `json:"session_id"`
73+ Path string `json:"path"`
74+}
75+
84func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
76func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
85− _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
77+ _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead, arg.SessionID, arg.Path)
86− arg.SessionID,
87− arg.Path,
88− )
89 return err
78 return err
90}
79}
internal/db/sessions.sql.go+19−1
@@ -150,6 +150,23 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
150 return items, nil
150 return items, nil
151}
151}
152
152
153+const renameSession = `-- name: RenameSession :exec
154+UPDATE sessions
155+SET
156+ title = ?
157+WHERE id = ?
158+`
159+
160+type RenameSessionParams struct {
161+ Title string `json:"title"`
162+ ID string `json:"id"`
163+}
164+
165+func (q *Queries) RenameSession(ctx context.Context, arg RenameSessionParams) error {
166+ _, err := q.exec(ctx, q.renameSessionStmt, renameSession, arg.Title, arg.ID)
167+ return err
168+}
169+
153const updateSession = `-- name: UpdateSession :one
170const updateSession = `-- name: UpdateSession :one
154UPDATE sessions
171UPDATE sessions
155SET
172SET
@@ -206,7 +223,8 @@ SET
206 title = ?,
223 title = ?,
207 prompt_tokens = prompt_tokens + ?,
224 prompt_tokens = prompt_tokens + ?,
208 completion_tokens = completion_tokens + ?,
225 completion_tokens = completion_tokens + ?,
209− cost = cost + ?
226+ cost = cost + ?,
227+ updated_at = strftime('%s', 'now')
210WHERE id = ?
228WHERE id = ?
211`
229`
212
230
internal/db/sql/sessions.sql+8−1
@@ -52,10 +52,17 @@ SET
52 title = ?,
52 title = ?,
53 prompt_tokens = prompt_tokens + ?,
53 prompt_tokens = prompt_tokens + ?,
54 completion_tokens = completion_tokens + ?,
54 completion_tokens = completion_tokens + ?,
55− cost = cost + ?
55+ cost = cost + ?,
56+ updated_at = strftime('%s', 'now')
56WHERE id = ?;
57WHERE id = ?;
57
58
58
59
60+-- name: RenameSession :exec
61+UPDATE sessions
62+SET
63+ title = ?
64+WHERE id = ?;
65+
59-- name: DeleteSession :exec
66-- name: DeleteSession :exec
60DELETE FROM sessions
67DELETE FROM sessions
61WHERE id = ?;
68WHERE id = ?;
internal/session/session.go+18
@@ -12,6 +12,7 @@ import (
12 "github.com/charmbracelet/crush/internal/event"
12 "github.com/charmbracelet/crush/internal/event"
13 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/pubsub"
14 "github.com/google/uuid"
14 "github.com/google/uuid"
15+ "github.com/zeebo/xxh3"
15)
16)
16
17
17type TodoStatus string
18type TodoStatus string
@@ -22,6 +23,13 @@ const (
22 TodoStatusCompleted TodoStatus = "completed"
23 TodoStatusCompleted TodoStatus = "completed"
23)
24)
24
25
26+// HashID returns the XXH3 hash of a session ID (UUID) as a hex string.
27+func HashID(id string) string {
28+ h := xxh3.New()
29+ h.WriteString(id)
30+ return fmt.Sprintf("%x", h.Sum(nil))
31+}
32+
25type Todo struct {
33type Todo struct {
26 Content string `json:"content"`
34 Content string `json:"content"`
27 Status TodoStatus `json:"status"`
35 Status TodoStatus `json:"status"`
@@ -61,6 +69,7 @@ type Service interface {
61 List(ctx context.Context) ([]Session, error)
69 List(ctx context.Context) ([]Session, error)
62 Save(ctx context.Context, session Session) (Session, error)
70 Save(ctx context.Context, session Session) (Session, error)
63 UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error
71 UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error
72+ Rename(ctx context.Context, id string, title string) error
64 Delete(ctx context.Context, id string) error
73 Delete(ctx context.Context, id string) error
65
74
66 // Agent tool session management
75 // Agent tool session management
@@ -198,6 +207,15 @@ func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title stri
198 })
207 })
199}
208}
200
209
210+// Rename updates only the title of a session without touching updated_at or
211+// usage fields.
212+func (s *service) Rename(ctx context.Context, id string, title string) error {
213+ return s.q.RenameSession(ctx, db.RenameSessionParams{
214+ ID: id,
215+ Title: title,
216+ })
217+}
218+
201func (s *service) List(ctx context.Context) ([]Session, error) {
219func (s *service) List(ctx context.Context) ([]Session, error) {
202 dbSessions, err := s.q.ListSessions(ctx)
220 dbSessions, err := s.q.ListSessions(ctx)
203 if err != nil {
221 if err != nil {