session.go

  1package cmd
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"os"
 10	"os/exec"
 11	"runtime"
 12	"sort"
 13	"strings"
 14	"syscall"
 15	"time"
 16
 17	"charm.land/lipgloss/v2"
 18	"github.com/charmbracelet/colorprofile"
 19	"github.com/charmbracelet/crush/internal/agent/tools"
 20	"github.com/charmbracelet/crush/internal/config"
 21	"github.com/charmbracelet/crush/internal/db"
 22	"github.com/charmbracelet/crush/internal/event"
 23	"github.com/charmbracelet/crush/internal/message"
 24	"github.com/charmbracelet/crush/internal/session"
 25	"github.com/charmbracelet/crush/internal/ui/chat"
 26	"github.com/charmbracelet/crush/internal/ui/styles"
 27	"github.com/charmbracelet/x/ansi"
 28	"github.com/charmbracelet/x/exp/charmtone"
 29	"github.com/charmbracelet/x/term"
 30	"github.com/spf13/cobra"
 31)
 32
 33var sessionCmd = &cobra.Command{
 34	Use:     "session",
 35	Aliases: []string{"sessions", "s"},
 36	Short:   "Manage sessions",
 37	Long:    "Manage Crush sessions. Agents can use --json for machine-readable output.",
 38}
 39
 40var (
 41	sessionListJSON   bool
 42	sessionShowJSON   bool
 43	sessionLastJSON   bool
 44	sessionDeleteJSON bool
 45	sessionRenameJSON bool
 46)
 47
 48var sessionListCmd = &cobra.Command{
 49	Use:     "list",
 50	Aliases: []string{"ls"},
 51	Short:   "List all sessions",
 52	Long:    "List all sessions. Use --json for machine-readable output.",
 53	RunE:    runSessionList,
 54}
 55
 56var sessionShowCmd = &cobra.Command{
 57	Use:   "show <id>",
 58	Short: "Show session details",
 59	Long:  "Show session details. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
 60	Args:  cobra.ExactArgs(1),
 61	RunE:  runSessionShow,
 62}
 63
 64var sessionLastCmd = &cobra.Command{
 65	Use:   "last",
 66	Short: "Show most recent session",
 67	Long:  "Show the last updated session. Use --json for machine-readable output.",
 68	RunE:  runSessionLast,
 69}
 70
 71var sessionDeleteCmd = &cobra.Command{
 72	Use:     "delete <id>",
 73	Aliases: []string{"rm"},
 74	Short:   "Delete a session",
 75	Long:    "Delete a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
 76	Args:    cobra.ExactArgs(1),
 77	RunE:    runSessionDelete,
 78}
 79
 80var sessionRenameCmd = &cobra.Command{
 81	Use:   "rename <id> <title>",
 82	Short: "Rename a session",
 83	Long:  "Rename a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
 84	Args:  cobra.MinimumNArgs(2),
 85	RunE:  runSessionRename,
 86}
 87
 88func init() {
 89	sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "output in JSON format")
 90	sessionShowCmd.Flags().BoolVar(&sessionShowJSON, "json", false, "output in JSON format")
 91	sessionLastCmd.Flags().BoolVar(&sessionLastJSON, "json", false, "output in JSON format")
 92	sessionDeleteCmd.Flags().BoolVar(&sessionDeleteJSON, "json", false, "output in JSON format")
 93	sessionRenameCmd.Flags().BoolVar(&sessionRenameJSON, "json", false, "output in JSON format")
 94	sessionCmd.AddCommand(sessionListCmd)
 95	sessionCmd.AddCommand(sessionShowCmd)
 96	sessionCmd.AddCommand(sessionLastCmd)
 97	sessionCmd.AddCommand(sessionDeleteCmd)
 98	sessionCmd.AddCommand(sessionRenameCmd)
 99}
100
101type sessionServices struct {
102	sessions session.Service
103	messages message.Service
104}
105
106func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) {
107	dataDir, _ := cmd.Flags().GetString("data-dir")
108	ctx := cmd.Context()
109
110	cfg, err := config.Init("", dataDir, false)
111	if err != nil {
112		return nil, nil, nil, fmt.Errorf("failed to initialize config: %w", err)
113	}
114	if dataDir == "" {
115		dataDir = cfg.Config().Options.DataDirectory
116	}
117	if shouldEnableMetrics(cfg.Config()) {
118		event.Init()
119	}
120
121	conn, err := db.Connect(ctx, dataDir)
122	if err != nil {
123		return nil, nil, nil, fmt.Errorf("failed to connect to database: %w", err)
124	}
125
126	queries := db.New(conn)
127	svc := &sessionServices{
128		sessions: session.NewService(queries, conn),
129		messages: message.NewService(queries),
130	}
131	return ctx, svc, func() { conn.Close() }, nil
132}
133
134func runSessionList(cmd *cobra.Command, _ []string) error {
135	event.SetNonInteractive(true)
136
137	ctx, svc, cleanup, err := sessionSetup(cmd)
138	if err != nil {
139		return err
140	}
141	defer cleanup()
142
143	event.SessionListed(sessionListJSON)
144
145	list, err := svc.sessions.List(ctx)
146	if err != nil {
147		return fmt.Errorf("failed to list sessions: %w", err)
148	}
149
150	if sessionListJSON {
151		out := cmd.OutOrStdout()
152		output := make([]sessionJSON, len(list))
153		for i, s := range list {
154			output[i] = sessionJSON{
155				ID:       session.HashID(s.ID),
156				UUID:     s.ID,
157				Title:    s.Title,
158				Created:  time.Unix(s.CreatedAt, 0).Format(time.RFC3339),
159				Modified: time.Unix(s.UpdatedAt, 0).Format(time.RFC3339),
160			}
161		}
162		enc := json.NewEncoder(out)
163		enc.SetEscapeHTML(false)
164		return enc.Encode(output)
165	}
166
167	w, cleanup, usingPager := sessionWriter(ctx, len(list))
168	defer cleanup()
169
170	hashStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
171	dateStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
172
173	width := sessionOutputWidth
174	if tw, _, err := term.GetSize(os.Stdout.Fd()); err == nil && tw > 0 {
175		width = tw
176	}
177	// 7 (hash) + 1 (space) + 25 (RFC3339 date) + 1 (space) = 34 chars prefix.
178	titleWidth := max(width-34, 10)
179
180	var writeErr error
181	for _, s := range list {
182		hash := session.HashID(s.ID)[:7]
183		date := time.Unix(s.CreatedAt, 0).Format(time.RFC3339)
184		title := strings.ReplaceAll(s.Title, "\n", " ")
185		title = ansi.Truncate(title, titleWidth, "…")
186		_, writeErr = fmt.Fprintln(w, hashStyle.Render(hash), dateStyle.Render(date), title)
187		if writeErr != nil {
188			break
189		}
190	}
191	if writeErr != nil && usingPager && isBrokenPipe(writeErr) {
192		return nil
193	}
194	return writeErr
195}
196
197type sessionJSON struct {
198	ID       string `json:"id"`
199	UUID     string `json:"uuid"`
200	Title    string `json:"title"`
201	Created  string `json:"created"`
202	Modified string `json:"modified"`
203}
204
205type sessionMutationResult struct {
206	ID      string `json:"id"`
207	UUID    string `json:"uuid"`
208	Title   string `json:"title"`
209	Deleted bool   `json:"deleted,omitempty"`
210	Renamed bool   `json:"renamed,omitempty"`
211}
212
213// resolveSessionID resolves a session ID that can be a UUID, full hash, or hash prefix.
214// Returns an error if the prefix is ambiguous (matches multiple sessions).
215func resolveSessionID(ctx context.Context, svc session.Service, id string) (session.Session, error) {
216	// Try direct UUID lookup first
217	if s, err := svc.Get(ctx, id); err == nil {
218		return s, nil
219	}
220
221	// List all sessions and check for hash matches
222	sessions, err := svc.List(ctx)
223	if err != nil {
224		return session.Session{}, err
225	}
226
227	var matches []session.Session
228	for _, s := range sessions {
229		hash := session.HashID(s.ID)
230		if hash == id || strings.HasPrefix(hash, id) {
231			matches = append(matches, s)
232		}
233	}
234
235	if len(matches) == 0 {
236		return session.Session{}, fmt.Errorf("session not found: %s", id)
237	}
238
239	if len(matches) == 1 {
240		return matches[0], nil
241	}
242
243	// Ambiguous - show matches like Git does
244	var sb strings.Builder
245	fmt.Fprintf(&sb, "session ID '%s' is ambiguous. Matches:\n\n", id)
246	for _, m := range matches {
247		hash := session.HashID(m.ID)
248		created := time.Unix(m.CreatedAt, 0).Format("2006-01-02")
249		// Keep title on one line by replacing newlines with spaces, and truncate.
250		title := strings.ReplaceAll(m.Title, "\n", " ")
251		title = ansi.Truncate(title, 50, "…")
252		fmt.Fprintf(&sb, "  %s... %q (created %s)\n", hash[:12], title, created)
253	}
254	sb.WriteString("\nUse more characters or the full hash")
255	return session.Session{}, errors.New(sb.String())
256}
257
258func runSessionShow(cmd *cobra.Command, args []string) error {
259	event.SetNonInteractive(true)
260
261	ctx, svc, cleanup, err := sessionSetup(cmd)
262	if err != nil {
263		return err
264	}
265	defer cleanup()
266
267	event.SessionShown(sessionShowJSON)
268
269	sess, err := resolveSessionID(ctx, svc.sessions, args[0])
270	if err != nil {
271		return err
272	}
273
274	msgs, err := svc.messages.List(ctx, sess.ID)
275	if err != nil {
276		return fmt.Errorf("failed to list messages: %w", err)
277	}
278
279	msgPtrs := messagePtrs(msgs)
280	if sessionShowJSON {
281		return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
282	}
283	return outputSessionHuman(ctx, sess, msgPtrs)
284}
285
286func runSessionDelete(cmd *cobra.Command, args []string) error {
287	event.SetNonInteractive(true)
288
289	ctx, svc, cleanup, err := sessionSetup(cmd)
290	if err != nil {
291		return err
292	}
293	defer cleanup()
294
295	event.SessionDeletedCommand(sessionDeleteJSON)
296
297	sess, err := resolveSessionID(ctx, svc.sessions, args[0])
298	if err != nil {
299		return err
300	}
301
302	if err := svc.sessions.Delete(ctx, sess.ID); err != nil {
303		return fmt.Errorf("failed to delete session: %w", err)
304	}
305
306	out := cmd.OutOrStdout()
307	if sessionDeleteJSON {
308		enc := json.NewEncoder(out)
309		enc.SetEscapeHTML(false)
310		return enc.Encode(sessionMutationResult{
311			ID:      session.HashID(sess.ID),
312			UUID:    sess.ID,
313			Title:   sess.Title,
314			Deleted: true,
315		})
316	}
317
318	fmt.Fprintf(out, "Deleted session %s\n", session.HashID(sess.ID)[:12])
319	return nil
320}
321
322func runSessionRename(cmd *cobra.Command, args []string) error {
323	event.SetNonInteractive(true)
324
325	ctx, svc, cleanup, err := sessionSetup(cmd)
326	if err != nil {
327		return err
328	}
329	defer cleanup()
330
331	event.SessionRenamed(sessionRenameJSON)
332
333	sess, err := resolveSessionID(ctx, svc.sessions, args[0])
334	if err != nil {
335		return err
336	}
337
338	newTitle := strings.Join(args[1:], " ")
339	if err := svc.sessions.Rename(ctx, sess.ID, newTitle); err != nil {
340		return fmt.Errorf("failed to rename session: %w", err)
341	}
342
343	out := cmd.OutOrStdout()
344	if sessionRenameJSON {
345		enc := json.NewEncoder(out)
346		enc.SetEscapeHTML(false)
347		return enc.Encode(sessionMutationResult{
348			ID:      session.HashID(sess.ID),
349			UUID:    sess.ID,
350			Title:   newTitle,
351			Renamed: true,
352		})
353	}
354
355	fmt.Fprintf(out, "Renamed session %s to %q\n", session.HashID(sess.ID)[:12], newTitle)
356	return nil
357}
358
359func runSessionLast(cmd *cobra.Command, _ []string) error {
360	event.SetNonInteractive(true)
361
362	ctx, svc, cleanup, err := sessionSetup(cmd)
363	if err != nil {
364		return err
365	}
366	defer cleanup()
367
368	event.SessionLastShown(sessionLastJSON)
369
370	list, err := svc.sessions.List(ctx)
371	if err != nil {
372		return fmt.Errorf("failed to list sessions: %w", err)
373	}
374
375	if len(list) == 0 {
376		return fmt.Errorf("no sessions found")
377	}
378
379	sess := list[0]
380
381	msgs, err := svc.messages.List(ctx, sess.ID)
382	if err != nil {
383		return fmt.Errorf("failed to list messages: %w", err)
384	}
385
386	msgPtrs := messagePtrs(msgs)
387	if sessionLastJSON {
388		return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
389	}
390	return outputSessionHuman(ctx, sess, msgPtrs)
391}
392
393const (
394	sessionOutputWidth     = 80
395	sessionMaxContentWidth = 120
396)
397
398func messagePtrs(msgs []message.Message) []*message.Message {
399	ptrs := make([]*message.Message, len(msgs))
400	for i := range msgs {
401		ptrs[i] = &msgs[i]
402	}
403	return ptrs
404}
405
406func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Message) error {
407	skills := extractSkillsFromMessages(msgs)
408	output := sessionShowOutput{
409		Meta: sessionShowMeta{
410			ID:               session.HashID(sess.ID),
411			UUID:             sess.ID,
412			Title:            sess.Title,
413			Created:          time.Unix(sess.CreatedAt, 0).Format(time.RFC3339),
414			Modified:         time.Unix(sess.UpdatedAt, 0).Format(time.RFC3339),
415			Cost:             sess.Cost,
416			PromptTokens:     sess.PromptTokens,
417			CompletionTokens: sess.CompletionTokens,
418			TotalTokens:      sess.PromptTokens + sess.CompletionTokens,
419			Skills:           skills,
420		},
421		Messages: make([]sessionShowMessage, len(msgs)),
422	}
423
424	for i, msg := range msgs {
425		output.Messages[i] = sessionShowMessage{
426			ID:       msg.ID,
427			Role:     string(msg.Role),
428			Created:  time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
429			Model:    msg.Model,
430			Provider: msg.Provider,
431			Parts:    convertParts(msg.Parts),
432		}
433	}
434
435	enc := json.NewEncoder(w)
436	enc.SetEscapeHTML(false)
437	return enc.Encode(output)
438}
439
440func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error {
441	sty := styles.DefaultStyles()
442	toolResults := chat.BuildToolResultMap(msgs)
443
444	width := sessionOutputWidth
445	if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 {
446		width = w
447	}
448	contentWidth := min(width, sessionMaxContentWidth)
449
450	keyStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
451	valStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
452
453	hash := session.HashID(sess.ID)[:12]
454	created := time.Unix(sess.CreatedAt, 0).Format("Mon Jan 2 15:04:05 2006 -0700")
455
456	skills := extractSkillsFromMessages(msgs)
457
458	// Render to buffer to determine actual height
459	var buf strings.Builder
460
461	fmt.Fprintln(&buf, keyStyle.Render("ID:    ")+valStyle.Render(hash))
462	fmt.Fprintln(&buf, keyStyle.Render("UUID:  ")+valStyle.Render(sess.ID))
463	fmt.Fprintln(&buf, keyStyle.Render("Title: ")+valStyle.Render(sess.Title))
464	fmt.Fprintln(&buf, keyStyle.Render("Date:  ")+valStyle.Render(created))
465	if len(skills) > 0 {
466		skillNames := make([]string, len(skills))
467		for i, s := range skills {
468			timestamp := time.Unix(sess.CreatedAt, 0).Format("15:04:05 -0700")
469			if s.LoadedAt != "" {
470				if t, err := time.Parse(time.RFC3339, s.LoadedAt); err == nil {
471					timestamp = t.Format("15:04:05 -0700")
472				}
473			}
474			skillNames[i] = fmt.Sprintf("%s (%s)", s.Name, timestamp)
475		}
476		fmt.Fprintln(&buf, keyStyle.Render("Skills: ")+valStyle.Render(strings.Join(skillNames, ", ")))
477	}
478	fmt.Fprintln(&buf)
479
480	first := true
481	for _, msg := range msgs {
482		items := chat.ExtractMessageItems(&sty, msg, toolResults)
483		for _, item := range items {
484			if !first {
485				fmt.Fprintln(&buf)
486			}
487			first = false
488			fmt.Fprintln(&buf, item.Render(contentWidth))
489		}
490	}
491	fmt.Fprintln(&buf)
492
493	contentHeight := strings.Count(buf.String(), "\n")
494	w, cleanup, usingPager := sessionWriter(ctx, contentHeight)
495	defer cleanup()
496
497	_, err := io.WriteString(w, buf.String())
498	// Ignore broken pipe errors when using a pager. This happens when the user
499	// exits the pager early (e.g., pressing 'q' in less), which closes the pipe
500	// and causes subsequent writes to fail. These errors are expected user behavior.
501	if err != nil && usingPager && isBrokenPipe(err) {
502		return nil
503	}
504	return err
505}
506
507func isBrokenPipe(err error) bool {
508	if err == nil {
509		return false
510	}
511	// Check for syscall.EPIPE (broken pipe)
512	if errors.Is(err, syscall.EPIPE) {
513		return true
514	}
515	// Also check for "broken pipe" in the error message
516	return strings.Contains(err.Error(), "broken pipe")
517}
518
519// sessionWriter returns a writer, cleanup function, and a bool indicating if a pager is used.
520// When the content fits within the terminal (or stdout is not a TTY), it returns
521// a colorprofile.Writer wrapping stdout. When content exceeds terminal height,
522// it starts a pager process (respecting $PAGER, defaulting to "less -R").
523func sessionWriter(ctx context.Context, contentHeight int) (io.Writer, func(), bool) {
524	// Use NewWriter which automatically detects TTY and strips ANSI when redirected
525	if runtime.GOOS == "windows" || !term.IsTerminal(os.Stdout.Fd()) {
526		return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
527	}
528
529	_, termHeight, err := term.GetSize(os.Stdout.Fd())
530	if err != nil || contentHeight <= termHeight {
531		return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
532	}
533
534	// Detect color profile from stderr since stdout is piped to the pager.
535	profile := colorprofile.Detect(os.Stderr, os.Environ())
536
537	pager := os.Getenv("PAGER")
538	if pager == "" {
539		pager = "less -R"
540	}
541
542	parts := strings.Fields(pager)
543	cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) //nolint:gosec
544	cmd.Stdout = os.Stdout
545	cmd.Stderr = os.Stderr
546
547	pipe, err := cmd.StdinPipe()
548	if err != nil {
549		return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
550	}
551
552	if err := cmd.Start(); err != nil {
553		return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
554	}
555
556	return &colorprofile.Writer{
557			Forward: pipe,
558			Profile: profile,
559		}, func() {
560			pipe.Close()
561			_ = cmd.Wait()
562		}, true
563}
564
565type sessionShowMeta struct {
566	ID               string             `json:"id"`
567	UUID             string             `json:"uuid"`
568	Title            string             `json:"title"`
569	Created          string             `json:"created"`
570	Modified         string             `json:"modified"`
571	Cost             float64            `json:"cost"`
572	PromptTokens     int64              `json:"prompt_tokens"`
573	CompletionTokens int64              `json:"completion_tokens"`
574	TotalTokens      int64              `json:"total_tokens"`
575	Skills           []sessionShowSkill `json:"skills,omitempty"`
576}
577
578type sessionShowSkill struct {
579	Name        string `json:"name"`
580	Description string `json:"description"`
581	LoadedAt    string `json:"loaded_at"`
582}
583
584type sessionShowMessage struct {
585	ID       string            `json:"id"`
586	Role     string            `json:"role"`
587	Created  string            `json:"created"`
588	Model    string            `json:"model,omitempty"`
589	Provider string            `json:"provider,omitempty"`
590	Parts    []sessionShowPart `json:"parts"`
591}
592
593type sessionShowPart struct {
594	Type string `json:"type"`
595
596	// Text content
597	Text string `json:"text,omitempty"`
598
599	// Reasoning
600	Thinking   string `json:"thinking,omitempty"`
601	StartedAt  int64  `json:"started_at,omitempty"`
602	FinishedAt int64  `json:"finished_at,omitempty"`
603
604	// Tool call
605	ToolCallID string `json:"tool_call_id,omitempty"`
606	Name       string `json:"name,omitempty"`
607	Input      string `json:"input,omitempty"`
608
609	// Tool result
610	Content  string `json:"content,omitempty"`
611	IsError  bool   `json:"is_error,omitempty"`
612	MIMEType string `json:"mime_type,omitempty"`
613
614	// Binary
615	Size int64 `json:"size,omitempty"`
616
617	// Image URL
618	URL    string `json:"url,omitempty"`
619	Detail string `json:"detail,omitempty"`
620
621	// Finish
622	Reason string `json:"reason,omitempty"`
623	Time   int64  `json:"time,omitempty"`
624}
625
626func extractSkillsFromMessages(msgs []*message.Message) []sessionShowSkill {
627	var skills []sessionShowSkill
628	seen := make(map[string]bool)
629
630	for _, msg := range msgs {
631		for _, part := range msg.Parts {
632			if tr, ok := part.(message.ToolResult); ok && tr.Metadata != "" {
633				var meta tools.ViewResponseMetadata
634				if err := json.Unmarshal([]byte(tr.Metadata), &meta); err == nil {
635					if meta.ResourceType == tools.ViewResourceSkill && meta.ResourceName != "" {
636						if !seen[meta.ResourceName] {
637							seen[meta.ResourceName] = true
638							skills = append(skills, sessionShowSkill{
639								Name:        meta.ResourceName,
640								Description: meta.ResourceDescription,
641								LoadedAt:    time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
642							})
643						}
644					}
645				}
646			}
647		}
648	}
649
650	sort.Slice(skills, func(i, j int) bool {
651		if skills[i].LoadedAt == skills[j].LoadedAt {
652			return skills[i].Name < skills[j].Name
653		}
654		return skills[i].LoadedAt < skills[j].LoadedAt
655	})
656
657	return skills
658}
659
660func convertParts(parts []message.ContentPart) []sessionShowPart {
661	result := make([]sessionShowPart, 0, len(parts))
662	for _, part := range parts {
663		switch p := part.(type) {
664		case message.TextContent:
665			result = append(result, sessionShowPart{
666				Type: "text",
667				Text: p.Text,
668			})
669		case message.ReasoningContent:
670			result = append(result, sessionShowPart{
671				Type:       "reasoning",
672				Thinking:   p.Thinking,
673				StartedAt:  p.StartedAt,
674				FinishedAt: p.FinishedAt,
675			})
676		case message.ToolCall:
677			result = append(result, sessionShowPart{
678				Type:       "tool_call",
679				ToolCallID: p.ID,
680				Name:       p.Name,
681				Input:      p.Input,
682			})
683		case message.ToolResult:
684			result = append(result, sessionShowPart{
685				Type:       "tool_result",
686				ToolCallID: p.ToolCallID,
687				Name:       p.Name,
688				Content:    p.Content,
689				IsError:    p.IsError,
690				MIMEType:   p.MIMEType,
691			})
692		case message.BinaryContent:
693			result = append(result, sessionShowPart{
694				Type:     "binary",
695				MIMEType: p.MIMEType,
696				Size:     int64(len(p.Data)),
697			})
698		case message.ImageURLContent:
699			result = append(result, sessionShowPart{
700				Type:   "image_url",
701				URL:    p.URL,
702				Detail: p.Detail,
703			})
704		case message.Finish:
705			result = append(result, sessionShowPart{
706				Type:   "finish",
707				Reason: string(p.Reason),
708				Time:   p.Time,
709			})
710		default:
711			result = append(result, sessionShowPart{
712				Type: "unknown",
713			})
714		}
715	}
716	return result
717}
718
719type sessionShowOutput struct {
720	Meta     sessionShowMeta      `json:"meta"`
721	Messages []sessionShowMessage `json:"messages"`
722}