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