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