on main help sign in
feat: CLI-based session access and management (#2373)
commitcfcbd69eb24e2d992a74d835384ce9b0426fb4ce authorChristian Rocha <christian@rocha.is> dateThu 12 Mar 2026, 10:09 (7 months ago) parentcc1b558
12 files changed+728−43
go.mod+1−1 go.sum+2−2 internal/agent/agent.go+4−4 internal/cmd/root.go+2−1 internal/cmd/session.go+641 internal/db/db.go+10 internal/db/models.go+1−1 internal/db/querier.go+1 internal/db/read_files.sql.go+21−32 internal/db/sessions.sql.go+19−1 internal/db/sql/sessions.sql+8−1 internal/session/session.go+18
go.mod+1−1
@@ -6,6 +6,7 @@ require (
66 charm.land/bubbles/v2 v2.0.0
77 charm.land/bubbletea/v2 v2.0.2
88 charm.land/catwalk v0.28.4
9+ charm.land/fang/v2 v2.0.1
910 charm.land/fantasy v0.12.1
1011 charm.land/glamour/v2 v2.0.0
1112 charm.land/lipgloss/v2 v2.0.1
@@ -21,7 +22,6 @@ require (
2122 github.com/bmatcuk/doublestar/v4 v4.10.0
2223 github.com/charlievieth/fastwalk v1.0.14
2324 github.com/charmbracelet/colorprofile v0.4.3
24− github.com/charmbracelet/fang v1.0.0
2525 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
2626 github.com/charmbracelet/x/ansi v0.11.6
2727 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=
44charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
55charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw=
66charm.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=
79charm.land/fantasy v0.12.1 h1:awszoi5O9FIjMEkfyCMiLJfVRNLckp/zQkFrA6IxQqc=
810charm.land/fantasy v0.12.1/go.mod h1:QeRVUeG1XNTWBszRAbhUtPyX1VWs6zjkCxwfcwnICdc=
911charm.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:
100102github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
101103github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
102104github.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=
105105github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
106106github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
107107github.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
842842 // Welp, the large model didn't work either. Use the default
843843 // session name and return.
844844 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)
846846 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)
848848 }
849849 return
850850 }
@@ -854,9 +854,9 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
854854 // Actually, we didn't get a response so we can't. Use the default
855855 // session name and return.
856856 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)
858858 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)
860860 }
861861 return
862862 }
internal/cmd/root.go+2−1
@@ -13,6 +13,7 @@ import (
1313 "strings"
1414
1515 tea "charm.land/bubbletea/v2"
16+ "charm.land/fang/v2"
1617 "charm.land/lipgloss/v2"
1718 "github.com/charmbracelet/colorprofile"
1819 "github.com/charmbracelet/crush/internal/app"
@@ -23,7 +24,6 @@ import (
2324 "github.com/charmbracelet/crush/internal/ui/common"
2425 ui "github.com/charmbracelet/crush/internal/ui/model"
2526 "github.com/charmbracelet/crush/internal/version"
26− "github.com/charmbracelet/fang"
2727 uv "github.com/charmbracelet/ultraviolet"
2828 "github.com/charmbracelet/x/ansi"
2929 "github.com/charmbracelet/x/exp/charmtone"
@@ -47,6 +47,7 @@ func init() {
4747 schemaCmd,
4848 loginCmd,
4949 statsCmd,
50+ sessionCmd,
5051 )
5152}
5253
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) {
120120 if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
121121 return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
122122 }
123+ if q.renameSessionStmt, err = db.PrepareContext(ctx, renameSession); err != nil {
124+ return nil, fmt.Errorf("error preparing query RenameSession: %w", err)
125+ }
123126 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
124127 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
125128 }
@@ -294,6 +297,11 @@ func (q *Queries) Close() error {
294297 err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
295298 }
296299 }
300+ if q.renameSessionStmt != nil {
301+ if cerr := q.renameSessionStmt.Close(); cerr != nil {
302+ err = fmt.Errorf("error closing renameSessionStmt: %w", cerr)
303+ }
304+ }
297305 if q.updateMessageStmt != nil {
298306 if cerr := q.updateMessageStmt.Close(); cerr != nil {
299307 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -380,6 +388,7 @@ type Queries struct {
380388 listSessionsStmt *sql.Stmt
381389 listUserMessagesBySessionStmt *sql.Stmt
382390 recordFileReadStmt *sql.Stmt
391+ renameSessionStmt *sql.Stmt
383392 updateMessageStmt *sql.Stmt
384393 updateSessionStmt *sql.Stmt
385394 updateSessionTitleAndUsageStmt *sql.Stmt
@@ -421,6 +430,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
421430 listSessionsStmt: q.listSessionsStmt,
422431 listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
423432 recordFileReadStmt: q.recordFileReadStmt,
433+ renameSessionStmt: q.renameSessionStmt,
424434 updateMessageStmt: q.updateMessageStmt,
425435 updateSessionStmt: q.updateSessionStmt,
426436 updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
internal/db/models.go+1−1
@@ -34,7 +34,7 @@ type Message struct {
3434type ReadFile struct {
3535 SessionID string `json:"session_id"`
3636 Path string `json:"path"`
37− ReadAt int64 `json:"read_at"` // Unix timestamp when file was last read
37+ ReadAt int64 `json:"read_at"`
3838}
3939
4040type Session struct {
internal/db/querier.go+1
@@ -41,6 +41,7 @@ type Querier interface {
4141 ListSessions(ctx context.Context) ([]Session, error)
4242 ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
4343 RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
44+ RenameSession(ctx context.Context, arg RenameSessionParams) error
4445 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
4546 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
4647 UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
internal/db/read_files.sql.go+21−32
@@ -22,32 +22,10 @@ type GetFileReadParams struct {
2222func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) {
2323 row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path)
2424 var i ReadFile
25− err := row.Scan(
26− &i.SessionID,
27− &i.Path,
28− &i.ReadAt,
29− )
25+ err := row.Scan(&i.SessionID, &i.Path, &i.ReadAt)
3026 return i, err
3127}
3228
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−
5129const listSessionReadFiles = `-- name: ListSessionReadFiles :many
5230SELECT session_id, path, read_at FROM read_files
5331WHERE session_id = ?
@@ -63,11 +41,7 @@ func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([
6341 items := []ReadFile{}
6442 for rows.Next() {
6543 var i ReadFile
66− if err := rows.Scan(
67− &i.SessionID,
68− &i.Path,
69− &i.ReadAt,
70− ); err != nil {
44+ if err := rows.Scan(&i.SessionID, &i.Path, &i.ReadAt); err != nil {
7145 return nil, err
7246 }
7347 items = append(items, i)
@@ -81,10 +55,25 @@ func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([
8155 return items, nil
8256}
8357
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+
8476func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
85− _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
86− arg.SessionID,
87− arg.Path,
88− )
77+ _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead, arg.SessionID, arg.Path)
8978 return err
9079}
internal/db/sessions.sql.go+19−1
@@ -150,6 +150,23 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
150150 return items, nil
151151}
152152
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+
153170const updateSession = `-- name: UpdateSession :one
154171UPDATE sessions
155172SET
@@ -206,7 +223,8 @@ SET
206223 title = ?,
207224 prompt_tokens = prompt_tokens + ?,
208225 completion_tokens = completion_tokens + ?,
209− cost = cost + ?
226+ cost = cost + ?,
227+ updated_at = strftime('%s', 'now')
210228WHERE id = ?
211229`
212230
internal/db/sql/sessions.sql+8−1
@@ -52,10 +52,17 @@ SET
5252 title = ?,
5353 prompt_tokens = prompt_tokens + ?,
5454 completion_tokens = completion_tokens + ?,
55− cost = cost + ?
55+ cost = cost + ?,
56+ updated_at = strftime('%s', 'now')
5657WHERE id = ?;
5758
5859
60+-- name: RenameSession :exec
61+UPDATE sessions
62+SET
63+ title = ?
64+WHERE id = ?;
65+
5966-- name: DeleteSession :exec
6067DELETE FROM sessions
6168WHERE id = ?;
internal/session/session.go+18
@@ -12,6 +12,7 @@ import (
1212 "github.com/charmbracelet/crush/internal/event"
1313 "github.com/charmbracelet/crush/internal/pubsub"
1414 "github.com/google/uuid"
15+ "github.com/zeebo/xxh3"
1516)
1617
1718type TodoStatus string
@@ -22,6 +23,13 @@ const (
2223 TodoStatusCompleted TodoStatus = "completed"
2324)
2425
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+
2533type Todo struct {
2634 Content string `json:"content"`
2735 Status TodoStatus `json:"status"`
@@ -61,6 +69,7 @@ type Service interface {
6169 List(ctx context.Context) ([]Session, error)
6270 Save(ctx context.Context, session Session) (Session, error)
6371 UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error
72+ Rename(ctx context.Context, id string, title string) error
6473 Delete(ctx context.Context, id string) error
6574
6675 // Agent tool session management
@@ -198,6 +207,15 @@ func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title stri
198207 })
199208}
200209
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+
201219func (s *service) List(ctx context.Context) ([]Session, error) {
202220 dbSessions, err := s.q.ListSessions(ctx)
203221 if err != nil {