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}