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}