| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714 |
- package cmd
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "os/exec"
- "runtime"
- "sort"
- "strings"
- "syscall"
- "time"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/colorprofile"
- "github.com/charmbracelet/crush/internal/agent/tools"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/db"
- "github.com/charmbracelet/crush/internal/event"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/ui/chat"
- "github.com/charmbracelet/crush/internal/ui/styles"
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/exp/charmtone"
- "github.com/charmbracelet/x/term"
- "github.com/spf13/cobra"
- )
- var sessionCmd = &cobra.Command{
- Use: "session",
- Aliases: []string{"sessions", "s"},
- Short: "Manage sessions",
- Long: "Manage Crush sessions. Agents can use --json for machine-readable output.",
- }
- var (
- sessionListJSON bool
- sessionShowJSON bool
- sessionLastJSON bool
- sessionDeleteJSON bool
- sessionRenameJSON bool
- )
- var sessionListCmd = &cobra.Command{
- Use: "list",
- Aliases: []string{"ls"},
- Short: "List all sessions",
- Long: "List all sessions. Use --json for machine-readable output.",
- RunE: runSessionList,
- }
- var sessionShowCmd = &cobra.Command{
- Use: "show <id>",
- Short: "Show session details",
- Long: "Show session details. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
- Args: cobra.ExactArgs(1),
- RunE: runSessionShow,
- }
- var sessionLastCmd = &cobra.Command{
- Use: "last",
- Short: "Show most recent session",
- Long: "Show the last updated session. Use --json for machine-readable output.",
- RunE: runSessionLast,
- }
- var sessionDeleteCmd = &cobra.Command{
- Use: "delete <id>",
- Aliases: []string{"rm"},
- Short: "Delete a session",
- Long: "Delete a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
- Args: cobra.ExactArgs(1),
- RunE: runSessionDelete,
- }
- var sessionRenameCmd = &cobra.Command{
- Use: "rename <id> <title>",
- Short: "Rename a session",
- Long: "Rename a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
- Args: cobra.MinimumNArgs(2),
- RunE: runSessionRename,
- }
- func init() {
- sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "output in JSON format")
- sessionShowCmd.Flags().BoolVar(&sessionShowJSON, "json", false, "output in JSON format")
- sessionLastCmd.Flags().BoolVar(&sessionLastJSON, "json", false, "output in JSON format")
- sessionDeleteCmd.Flags().BoolVar(&sessionDeleteJSON, "json", false, "output in JSON format")
- sessionRenameCmd.Flags().BoolVar(&sessionRenameJSON, "json", false, "output in JSON format")
- sessionCmd.AddCommand(sessionListCmd)
- sessionCmd.AddCommand(sessionShowCmd)
- sessionCmd.AddCommand(sessionLastCmd)
- sessionCmd.AddCommand(sessionDeleteCmd)
- sessionCmd.AddCommand(sessionRenameCmd)
- }
- type sessionServices struct {
- sessions session.Service
- messages message.Service
- }
- func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) {
- dataDir, _ := cmd.Flags().GetString("data-dir")
- ctx := cmd.Context()
- if dataDir == "" {
- cfg, err := config.Init("", "", false)
- if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to initialize config: %w", err)
- }
- dataDir = cfg.Config().Options.DataDirectory
- }
- conn, err := db.Connect(ctx, dataDir)
- if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to connect to database: %w", err)
- }
- queries := db.New(conn)
- svc := &sessionServices{
- sessions: session.NewService(queries, conn),
- messages: message.NewService(queries),
- }
- return ctx, svc, func() { conn.Close() }, nil
- }
- func runSessionList(cmd *cobra.Command, _ []string) error {
- event.SetNonInteractive(true)
- event.SessionListed(sessionListJSON)
- ctx, svc, cleanup, err := sessionSetup(cmd)
- if err != nil {
- return err
- }
- defer cleanup()
- list, err := svc.sessions.List(ctx)
- if err != nil {
- return fmt.Errorf("failed to list sessions: %w", err)
- }
- if sessionListJSON {
- out := cmd.OutOrStdout()
- output := make([]sessionJSON, len(list))
- for i, s := range list {
- output[i] = sessionJSON{
- ID: session.HashID(s.ID),
- UUID: s.ID,
- Title: s.Title,
- Created: time.Unix(s.CreatedAt, 0).Format(time.RFC3339),
- Modified: time.Unix(s.UpdatedAt, 0).Format(time.RFC3339),
- }
- }
- enc := json.NewEncoder(out)
- enc.SetEscapeHTML(false)
- return enc.Encode(output)
- }
- w, cleanup, usingPager := sessionWriter(ctx, len(list))
- defer cleanup()
- hashStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
- dateStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
- width := sessionOutputWidth
- if tw, _, err := term.GetSize(os.Stdout.Fd()); err == nil && tw > 0 {
- width = tw
- }
- // 7 (hash) + 1 (space) + 25 (RFC3339 date) + 1 (space) = 34 chars prefix.
- titleWidth := max(width-34, 10)
- var writeErr error
- for _, s := range list {
- hash := session.HashID(s.ID)[:7]
- date := time.Unix(s.CreatedAt, 0).Format(time.RFC3339)
- title := strings.ReplaceAll(s.Title, "\n", " ")
- title = ansi.Truncate(title, titleWidth, "…")
- _, writeErr = fmt.Fprintln(w, hashStyle.Render(hash), dateStyle.Render(date), title)
- if writeErr != nil {
- break
- }
- }
- if writeErr != nil && usingPager && isBrokenPipe(writeErr) {
- return nil
- }
- return writeErr
- }
- type sessionJSON struct {
- ID string `json:"id"`
- UUID string `json:"uuid"`
- Title string `json:"title"`
- Created string `json:"created"`
- Modified string `json:"modified"`
- }
- type sessionMutationResult struct {
- ID string `json:"id"`
- UUID string `json:"uuid"`
- Title string `json:"title"`
- Deleted bool `json:"deleted,omitempty"`
- Renamed bool `json:"renamed,omitempty"`
- }
- // resolveSessionID resolves a session ID that can be a UUID, full hash, or hash prefix.
- // Returns an error if the prefix is ambiguous (matches multiple sessions).
- func resolveSessionID(ctx context.Context, svc session.Service, id string) (session.Session, error) {
- // Try direct UUID lookup first
- if s, err := svc.Get(ctx, id); err == nil {
- return s, nil
- }
- // List all sessions and check for hash matches
- sessions, err := svc.List(ctx)
- if err != nil {
- return session.Session{}, err
- }
- var matches []session.Session
- for _, s := range sessions {
- hash := session.HashID(s.ID)
- if hash == id || strings.HasPrefix(hash, id) {
- matches = append(matches, s)
- }
- }
- if len(matches) == 0 {
- return session.Session{}, fmt.Errorf("session not found: %s", id)
- }
- if len(matches) == 1 {
- return matches[0], nil
- }
- // Ambiguous - show matches like Git does
- var sb strings.Builder
- fmt.Fprintf(&sb, "session ID '%s' is ambiguous. Matches:\n\n", id)
- for _, m := range matches {
- hash := session.HashID(m.ID)
- created := time.Unix(m.CreatedAt, 0).Format("2006-01-02")
- // Keep title on one line by replacing newlines with spaces, and truncate.
- title := strings.ReplaceAll(m.Title, "\n", " ")
- title = ansi.Truncate(title, 50, "…")
- fmt.Fprintf(&sb, " %s... %q (created %s)\n", hash[:12], title, created)
- }
- sb.WriteString("\nUse more characters or the full hash")
- return session.Session{}, errors.New(sb.String())
- }
- func runSessionShow(cmd *cobra.Command, args []string) error {
- event.SetNonInteractive(true)
- event.SessionShown(sessionShowJSON)
- ctx, svc, cleanup, err := sessionSetup(cmd)
- if err != nil {
- return err
- }
- defer cleanup()
- sess, err := resolveSessionID(ctx, svc.sessions, args[0])
- if err != nil {
- return err
- }
- msgs, err := svc.messages.List(ctx, sess.ID)
- if err != nil {
- return fmt.Errorf("failed to list messages: %w", err)
- }
- msgPtrs := messagePtrs(msgs)
- if sessionShowJSON {
- return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
- }
- return outputSessionHuman(ctx, sess, msgPtrs)
- }
- func runSessionDelete(cmd *cobra.Command, args []string) error {
- event.SetNonInteractive(true)
- event.SessionDeletedCommand(sessionDeleteJSON)
- ctx, svc, cleanup, err := sessionSetup(cmd)
- if err != nil {
- return err
- }
- defer cleanup()
- sess, err := resolveSessionID(ctx, svc.sessions, args[0])
- if err != nil {
- return err
- }
- if err := svc.sessions.Delete(ctx, sess.ID); err != nil {
- return fmt.Errorf("failed to delete session: %w", err)
- }
- out := cmd.OutOrStdout()
- if sessionDeleteJSON {
- enc := json.NewEncoder(out)
- enc.SetEscapeHTML(false)
- return enc.Encode(sessionMutationResult{
- ID: session.HashID(sess.ID),
- UUID: sess.ID,
- Title: sess.Title,
- Deleted: true,
- })
- }
- fmt.Fprintf(out, "Deleted session %s\n", session.HashID(sess.ID)[:12])
- return nil
- }
- func runSessionRename(cmd *cobra.Command, args []string) error {
- event.SetNonInteractive(true)
- event.SessionRenamed(sessionRenameJSON)
- ctx, svc, cleanup, err := sessionSetup(cmd)
- if err != nil {
- return err
- }
- defer cleanup()
- sess, err := resolveSessionID(ctx, svc.sessions, args[0])
- if err != nil {
- return err
- }
- newTitle := strings.Join(args[1:], " ")
- if err := svc.sessions.Rename(ctx, sess.ID, newTitle); err != nil {
- return fmt.Errorf("failed to rename session: %w", err)
- }
- out := cmd.OutOrStdout()
- if sessionRenameJSON {
- enc := json.NewEncoder(out)
- enc.SetEscapeHTML(false)
- return enc.Encode(sessionMutationResult{
- ID: session.HashID(sess.ID),
- UUID: sess.ID,
- Title: newTitle,
- Renamed: true,
- })
- }
- fmt.Fprintf(out, "Renamed session %s to %q\n", session.HashID(sess.ID)[:12], newTitle)
- return nil
- }
- func runSessionLast(cmd *cobra.Command, _ []string) error {
- event.SetNonInteractive(true)
- event.SessionLastShown(sessionLastJSON)
- ctx, svc, cleanup, err := sessionSetup(cmd)
- if err != nil {
- return err
- }
- defer cleanup()
- list, err := svc.sessions.List(ctx)
- if err != nil {
- return fmt.Errorf("failed to list sessions: %w", err)
- }
- if len(list) == 0 {
- return fmt.Errorf("no sessions found")
- }
- sess := list[0]
- msgs, err := svc.messages.List(ctx, sess.ID)
- if err != nil {
- return fmt.Errorf("failed to list messages: %w", err)
- }
- msgPtrs := messagePtrs(msgs)
- if sessionLastJSON {
- return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
- }
- return outputSessionHuman(ctx, sess, msgPtrs)
- }
- const (
- sessionOutputWidth = 80
- sessionMaxContentWidth = 120
- )
- func messagePtrs(msgs []message.Message) []*message.Message {
- ptrs := make([]*message.Message, len(msgs))
- for i := range msgs {
- ptrs[i] = &msgs[i]
- }
- return ptrs
- }
- func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Message) error {
- skills := extractSkillsFromMessages(msgs)
- output := sessionShowOutput{
- Meta: sessionShowMeta{
- ID: session.HashID(sess.ID),
- UUID: sess.ID,
- Title: sess.Title,
- Created: time.Unix(sess.CreatedAt, 0).Format(time.RFC3339),
- Modified: time.Unix(sess.UpdatedAt, 0).Format(time.RFC3339),
- Cost: sess.Cost,
- PromptTokens: sess.PromptTokens,
- CompletionTokens: sess.CompletionTokens,
- TotalTokens: sess.PromptTokens + sess.CompletionTokens,
- Skills: skills,
- },
- Messages: make([]sessionShowMessage, len(msgs)),
- }
- for i, msg := range msgs {
- output.Messages[i] = sessionShowMessage{
- ID: msg.ID,
- Role: string(msg.Role),
- Created: time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
- Model: msg.Model,
- Provider: msg.Provider,
- Parts: convertParts(msg.Parts),
- }
- }
- enc := json.NewEncoder(w)
- enc.SetEscapeHTML(false)
- return enc.Encode(output)
- }
- func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error {
- sty := styles.DefaultStyles()
- toolResults := chat.BuildToolResultMap(msgs)
- width := sessionOutputWidth
- if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 {
- width = w
- }
- contentWidth := min(width, sessionMaxContentWidth)
- keyStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
- valStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
- hash := session.HashID(sess.ID)[:12]
- created := time.Unix(sess.CreatedAt, 0).Format("Mon Jan 2 15:04:05 2006 -0700")
- skills := extractSkillsFromMessages(msgs)
- // Render to buffer to determine actual height
- var buf strings.Builder
- fmt.Fprintln(&buf, keyStyle.Render("ID: ")+valStyle.Render(hash))
- fmt.Fprintln(&buf, keyStyle.Render("UUID: ")+valStyle.Render(sess.ID))
- fmt.Fprintln(&buf, keyStyle.Render("Title: ")+valStyle.Render(sess.Title))
- fmt.Fprintln(&buf, keyStyle.Render("Date: ")+valStyle.Render(created))
- if len(skills) > 0 {
- skillNames := make([]string, len(skills))
- for i, s := range skills {
- timestamp := time.Unix(sess.CreatedAt, 0).Format("15:04:05 -0700")
- if s.LoadedAt != "" {
- if t, err := time.Parse(time.RFC3339, s.LoadedAt); err == nil {
- timestamp = t.Format("15:04:05 -0700")
- }
- }
- skillNames[i] = fmt.Sprintf("%s (%s)", s.Name, timestamp)
- }
- fmt.Fprintln(&buf, keyStyle.Render("Skills: ")+valStyle.Render(strings.Join(skillNames, ", ")))
- }
- fmt.Fprintln(&buf)
- first := true
- for _, msg := range msgs {
- items := chat.ExtractMessageItems(&sty, msg, toolResults)
- for _, item := range items {
- if !first {
- fmt.Fprintln(&buf)
- }
- first = false
- fmt.Fprintln(&buf, item.Render(contentWidth))
- }
- }
- fmt.Fprintln(&buf)
- contentHeight := strings.Count(buf.String(), "\n")
- w, cleanup, usingPager := sessionWriter(ctx, contentHeight)
- defer cleanup()
- _, err := io.WriteString(w, buf.String())
- // Ignore broken pipe errors when using a pager. This happens when the user
- // exits the pager early (e.g., pressing 'q' in less), which closes the pipe
- // and causes subsequent writes to fail. These errors are expected user behavior.
- if err != nil && usingPager && isBrokenPipe(err) {
- return nil
- }
- return err
- }
- func isBrokenPipe(err error) bool {
- if err == nil {
- return false
- }
- // Check for syscall.EPIPE (broken pipe)
- if errors.Is(err, syscall.EPIPE) {
- return true
- }
- // Also check for "broken pipe" in the error message
- return strings.Contains(err.Error(), "broken pipe")
- }
- // sessionWriter returns a writer, cleanup function, and a bool indicating if a pager is used.
- // When the content fits within the terminal (or stdout is not a TTY), it returns
- // a colorprofile.Writer wrapping stdout. When content exceeds terminal height,
- // it starts a pager process (respecting $PAGER, defaulting to "less -R").
- func sessionWriter(ctx context.Context, contentHeight int) (io.Writer, func(), bool) {
- // Use NewWriter which automatically detects TTY and strips ANSI when redirected
- if runtime.GOOS == "windows" || !term.IsTerminal(os.Stdout.Fd()) {
- return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
- }
- _, termHeight, err := term.GetSize(os.Stdout.Fd())
- if err != nil || contentHeight <= termHeight {
- return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
- }
- // Detect color profile from stderr since stdout is piped to the pager.
- profile := colorprofile.Detect(os.Stderr, os.Environ())
- pager := os.Getenv("PAGER")
- if pager == "" {
- pager = "less -R"
- }
- parts := strings.Fields(pager)
- cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) //nolint:gosec
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- pipe, err := cmd.StdinPipe()
- if err != nil {
- return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
- }
- if err := cmd.Start(); err != nil {
- return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
- }
- return &colorprofile.Writer{
- Forward: pipe,
- Profile: profile,
- }, func() {
- pipe.Close()
- _ = cmd.Wait()
- }, true
- }
- type sessionShowMeta struct {
- ID string `json:"id"`
- UUID string `json:"uuid"`
- Title string `json:"title"`
- Created string `json:"created"`
- Modified string `json:"modified"`
- Cost float64 `json:"cost"`
- PromptTokens int64 `json:"prompt_tokens"`
- CompletionTokens int64 `json:"completion_tokens"`
- TotalTokens int64 `json:"total_tokens"`
- Skills []sessionShowSkill `json:"skills,omitempty"`
- }
- type sessionShowSkill struct {
- Name string `json:"name"`
- Description string `json:"description"`
- LoadedAt string `json:"loaded_at"`
- }
- type sessionShowMessage struct {
- ID string `json:"id"`
- Role string `json:"role"`
- Created string `json:"created"`
- Model string `json:"model,omitempty"`
- Provider string `json:"provider,omitempty"`
- Parts []sessionShowPart `json:"parts"`
- }
- type sessionShowPart struct {
- Type string `json:"type"`
- // Text content
- Text string `json:"text,omitempty"`
- // Reasoning
- Thinking string `json:"thinking,omitempty"`
- StartedAt int64 `json:"started_at,omitempty"`
- FinishedAt int64 `json:"finished_at,omitempty"`
- // Tool call
- ToolCallID string `json:"tool_call_id,omitempty"`
- Name string `json:"name,omitempty"`
- Input string `json:"input,omitempty"`
- // Tool result
- Content string `json:"content,omitempty"`
- IsError bool `json:"is_error,omitempty"`
- MIMEType string `json:"mime_type,omitempty"`
- // Binary
- Size int64 `json:"size,omitempty"`
- // Image URL
- URL string `json:"url,omitempty"`
- Detail string `json:"detail,omitempty"`
- // Finish
- Reason string `json:"reason,omitempty"`
- Time int64 `json:"time,omitempty"`
- }
- func extractSkillsFromMessages(msgs []*message.Message) []sessionShowSkill {
- var skills []sessionShowSkill
- seen := make(map[string]bool)
- for _, msg := range msgs {
- for _, part := range msg.Parts {
- if tr, ok := part.(message.ToolResult); ok && tr.Metadata != "" {
- var meta tools.ViewResponseMetadata
- if err := json.Unmarshal([]byte(tr.Metadata), &meta); err == nil {
- if meta.ResourceType == tools.ViewResourceSkill && meta.ResourceName != "" {
- if !seen[meta.ResourceName] {
- seen[meta.ResourceName] = true
- skills = append(skills, sessionShowSkill{
- Name: meta.ResourceName,
- Description: meta.ResourceDescription,
- LoadedAt: time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
- })
- }
- }
- }
- }
- }
- }
- sort.Slice(skills, func(i, j int) bool {
- if skills[i].LoadedAt == skills[j].LoadedAt {
- return skills[i].Name < skills[j].Name
- }
- return skills[i].LoadedAt < skills[j].LoadedAt
- })
- return skills
- }
- func convertParts(parts []message.ContentPart) []sessionShowPart {
- result := make([]sessionShowPart, 0, len(parts))
- for _, part := range parts {
- switch p := part.(type) {
- case message.TextContent:
- result = append(result, sessionShowPart{
- Type: "text",
- Text: p.Text,
- })
- case message.ReasoningContent:
- result = append(result, sessionShowPart{
- Type: "reasoning",
- Thinking: p.Thinking,
- StartedAt: p.StartedAt,
- FinishedAt: p.FinishedAt,
- })
- case message.ToolCall:
- result = append(result, sessionShowPart{
- Type: "tool_call",
- ToolCallID: p.ID,
- Name: p.Name,
- Input: p.Input,
- })
- case message.ToolResult:
- result = append(result, sessionShowPart{
- Type: "tool_result",
- ToolCallID: p.ToolCallID,
- Name: p.Name,
- Content: p.Content,
- IsError: p.IsError,
- MIMEType: p.MIMEType,
- })
- case message.BinaryContent:
- result = append(result, sessionShowPart{
- Type: "binary",
- MIMEType: p.MIMEType,
- Size: int64(len(p.Data)),
- })
- case message.ImageURLContent:
- result = append(result, sessionShowPart{
- Type: "image_url",
- URL: p.URL,
- Detail: p.Detail,
- })
- case message.Finish:
- result = append(result, sessionShowPart{
- Type: "finish",
- Reason: string(p.Reason),
- Time: p.Time,
- })
- default:
- result = append(result, sessionShowPart{
- Type: "unknown",
- })
- }
- }
- return result
- }
- type sessionShowOutput struct {
- Meta sessionShowMeta `json:"meta"`
- Messages []sessionShowMessage `json:"messages"`
- }
|