session.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. package cmd
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "os"
  9. "os/exec"
  10. "runtime"
  11. "sort"
  12. "strings"
  13. "syscall"
  14. "time"
  15. "charm.land/lipgloss/v2"
  16. "github.com/charmbracelet/colorprofile"
  17. "github.com/charmbracelet/crush/internal/agent/tools"
  18. "github.com/charmbracelet/crush/internal/config"
  19. "github.com/charmbracelet/crush/internal/db"
  20. "github.com/charmbracelet/crush/internal/event"
  21. "github.com/charmbracelet/crush/internal/message"
  22. "github.com/charmbracelet/crush/internal/session"
  23. "github.com/charmbracelet/crush/internal/ui/chat"
  24. "github.com/charmbracelet/crush/internal/ui/styles"
  25. "github.com/charmbracelet/x/ansi"
  26. "github.com/charmbracelet/x/exp/charmtone"
  27. "github.com/charmbracelet/x/term"
  28. "github.com/spf13/cobra"
  29. )
  30. var sessionCmd = &cobra.Command{
  31. Use: "session",
  32. Aliases: []string{"sessions", "s"},
  33. Short: "Manage sessions",
  34. Long: "Manage Crush sessions. Agents can use --json for machine-readable output.",
  35. }
  36. var (
  37. sessionListJSON bool
  38. sessionShowJSON bool
  39. sessionLastJSON bool
  40. sessionDeleteJSON bool
  41. sessionRenameJSON bool
  42. )
  43. var sessionListCmd = &cobra.Command{
  44. Use: "list",
  45. Aliases: []string{"ls"},
  46. Short: "List all sessions",
  47. Long: "List all sessions. Use --json for machine-readable output.",
  48. RunE: runSessionList,
  49. }
  50. var sessionShowCmd = &cobra.Command{
  51. Use: "show <id>",
  52. Short: "Show session details",
  53. Long: "Show session details. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
  54. Args: cobra.ExactArgs(1),
  55. RunE: runSessionShow,
  56. }
  57. var sessionLastCmd = &cobra.Command{
  58. Use: "last",
  59. Short: "Show most recent session",
  60. Long: "Show the last updated session. Use --json for machine-readable output.",
  61. RunE: runSessionLast,
  62. }
  63. var sessionDeleteCmd = &cobra.Command{
  64. Use: "delete <id>",
  65. Aliases: []string{"rm"},
  66. Short: "Delete a session",
  67. Long: "Delete a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
  68. Args: cobra.ExactArgs(1),
  69. RunE: runSessionDelete,
  70. }
  71. var sessionRenameCmd = &cobra.Command{
  72. Use: "rename <id> <title>",
  73. Short: "Rename a session",
  74. Long: "Rename a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
  75. Args: cobra.MinimumNArgs(2),
  76. RunE: runSessionRename,
  77. }
  78. func init() {
  79. sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "output in JSON format")
  80. sessionShowCmd.Flags().BoolVar(&sessionShowJSON, "json", false, "output in JSON format")
  81. sessionLastCmd.Flags().BoolVar(&sessionLastJSON, "json", false, "output in JSON format")
  82. sessionDeleteCmd.Flags().BoolVar(&sessionDeleteJSON, "json", false, "output in JSON format")
  83. sessionRenameCmd.Flags().BoolVar(&sessionRenameJSON, "json", false, "output in JSON format")
  84. sessionCmd.AddCommand(sessionListCmd)
  85. sessionCmd.AddCommand(sessionShowCmd)
  86. sessionCmd.AddCommand(sessionLastCmd)
  87. sessionCmd.AddCommand(sessionDeleteCmd)
  88. sessionCmd.AddCommand(sessionRenameCmd)
  89. }
  90. type sessionServices struct {
  91. sessions session.Service
  92. messages message.Service
  93. }
  94. func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) {
  95. dataDir, _ := cmd.Flags().GetString("data-dir")
  96. ctx := cmd.Context()
  97. if dataDir == "" {
  98. cfg, err := config.Init("", "", false)
  99. if err != nil {
  100. return nil, nil, nil, fmt.Errorf("failed to initialize config: %w", err)
  101. }
  102. dataDir = cfg.Config().Options.DataDirectory
  103. }
  104. conn, err := db.Connect(ctx, dataDir)
  105. if err != nil {
  106. return nil, nil, nil, fmt.Errorf("failed to connect to database: %w", err)
  107. }
  108. queries := db.New(conn)
  109. svc := &sessionServices{
  110. sessions: session.NewService(queries, conn),
  111. messages: message.NewService(queries),
  112. }
  113. return ctx, svc, func() { conn.Close() }, nil
  114. }
  115. func runSessionList(cmd *cobra.Command, _ []string) error {
  116. event.SetNonInteractive(true)
  117. event.SessionListed(sessionListJSON)
  118. ctx, svc, cleanup, err := sessionSetup(cmd)
  119. if err != nil {
  120. return err
  121. }
  122. defer cleanup()
  123. list, err := svc.sessions.List(ctx)
  124. if err != nil {
  125. return fmt.Errorf("failed to list sessions: %w", err)
  126. }
  127. if sessionListJSON {
  128. out := cmd.OutOrStdout()
  129. output := make([]sessionJSON, len(list))
  130. for i, s := range list {
  131. output[i] = sessionJSON{
  132. ID: session.HashID(s.ID),
  133. UUID: s.ID,
  134. Title: s.Title,
  135. Created: time.Unix(s.CreatedAt, 0).Format(time.RFC3339),
  136. Modified: time.Unix(s.UpdatedAt, 0).Format(time.RFC3339),
  137. }
  138. }
  139. enc := json.NewEncoder(out)
  140. enc.SetEscapeHTML(false)
  141. return enc.Encode(output)
  142. }
  143. w, cleanup, usingPager := sessionWriter(ctx, len(list))
  144. defer cleanup()
  145. hashStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
  146. dateStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
  147. width := sessionOutputWidth
  148. if tw, _, err := term.GetSize(os.Stdout.Fd()); err == nil && tw > 0 {
  149. width = tw
  150. }
  151. // 7 (hash) + 1 (space) + 25 (RFC3339 date) + 1 (space) = 34 chars prefix.
  152. titleWidth := max(width-34, 10)
  153. var writeErr error
  154. for _, s := range list {
  155. hash := session.HashID(s.ID)[:7]
  156. date := time.Unix(s.CreatedAt, 0).Format(time.RFC3339)
  157. title := strings.ReplaceAll(s.Title, "\n", " ")
  158. title = ansi.Truncate(title, titleWidth, "…")
  159. _, writeErr = fmt.Fprintln(w, hashStyle.Render(hash), dateStyle.Render(date), title)
  160. if writeErr != nil {
  161. break
  162. }
  163. }
  164. if writeErr != nil && usingPager && isBrokenPipe(writeErr) {
  165. return nil
  166. }
  167. return writeErr
  168. }
  169. type sessionJSON struct {
  170. ID string `json:"id"`
  171. UUID string `json:"uuid"`
  172. Title string `json:"title"`
  173. Created string `json:"created"`
  174. Modified string `json:"modified"`
  175. }
  176. type sessionMutationResult struct {
  177. ID string `json:"id"`
  178. UUID string `json:"uuid"`
  179. Title string `json:"title"`
  180. Deleted bool `json:"deleted,omitempty"`
  181. Renamed bool `json:"renamed,omitempty"`
  182. }
  183. // resolveSessionID resolves a session ID that can be a UUID, full hash, or hash prefix.
  184. // Returns an error if the prefix is ambiguous (matches multiple sessions).
  185. func resolveSessionID(ctx context.Context, svc session.Service, id string) (session.Session, error) {
  186. // Try direct UUID lookup first
  187. if s, err := svc.Get(ctx, id); err == nil {
  188. return s, nil
  189. }
  190. // List all sessions and check for hash matches
  191. sessions, err := svc.List(ctx)
  192. if err != nil {
  193. return session.Session{}, err
  194. }
  195. var matches []session.Session
  196. for _, s := range sessions {
  197. hash := session.HashID(s.ID)
  198. if hash == id || strings.HasPrefix(hash, id) {
  199. matches = append(matches, s)
  200. }
  201. }
  202. if len(matches) == 0 {
  203. return session.Session{}, fmt.Errorf("session not found: %s", id)
  204. }
  205. if len(matches) == 1 {
  206. return matches[0], nil
  207. }
  208. // Ambiguous - show matches like Git does
  209. var sb strings.Builder
  210. fmt.Fprintf(&sb, "session ID '%s' is ambiguous. Matches:\n\n", id)
  211. for _, m := range matches {
  212. hash := session.HashID(m.ID)
  213. created := time.Unix(m.CreatedAt, 0).Format("2006-01-02")
  214. // Keep title on one line by replacing newlines with spaces, and truncate.
  215. title := strings.ReplaceAll(m.Title, "\n", " ")
  216. title = ansi.Truncate(title, 50, "…")
  217. fmt.Fprintf(&sb, " %s... %q (created %s)\n", hash[:12], title, created)
  218. }
  219. sb.WriteString("\nUse more characters or the full hash")
  220. return session.Session{}, errors.New(sb.String())
  221. }
  222. func runSessionShow(cmd *cobra.Command, args []string) error {
  223. event.SetNonInteractive(true)
  224. event.SessionShown(sessionShowJSON)
  225. ctx, svc, cleanup, err := sessionSetup(cmd)
  226. if err != nil {
  227. return err
  228. }
  229. defer cleanup()
  230. sess, err := resolveSessionID(ctx, svc.sessions, args[0])
  231. if err != nil {
  232. return err
  233. }
  234. msgs, err := svc.messages.List(ctx, sess.ID)
  235. if err != nil {
  236. return fmt.Errorf("failed to list messages: %w", err)
  237. }
  238. msgPtrs := messagePtrs(msgs)
  239. if sessionShowJSON {
  240. return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
  241. }
  242. return outputSessionHuman(ctx, sess, msgPtrs)
  243. }
  244. func runSessionDelete(cmd *cobra.Command, args []string) error {
  245. event.SetNonInteractive(true)
  246. event.SessionDeletedCommand(sessionDeleteJSON)
  247. ctx, svc, cleanup, err := sessionSetup(cmd)
  248. if err != nil {
  249. return err
  250. }
  251. defer cleanup()
  252. sess, err := resolveSessionID(ctx, svc.sessions, args[0])
  253. if err != nil {
  254. return err
  255. }
  256. if err := svc.sessions.Delete(ctx, sess.ID); err != nil {
  257. return fmt.Errorf("failed to delete session: %w", err)
  258. }
  259. out := cmd.OutOrStdout()
  260. if sessionDeleteJSON {
  261. enc := json.NewEncoder(out)
  262. enc.SetEscapeHTML(false)
  263. return enc.Encode(sessionMutationResult{
  264. ID: session.HashID(sess.ID),
  265. UUID: sess.ID,
  266. Title: sess.Title,
  267. Deleted: true,
  268. })
  269. }
  270. fmt.Fprintf(out, "Deleted session %s\n", session.HashID(sess.ID)[:12])
  271. return nil
  272. }
  273. func runSessionRename(cmd *cobra.Command, args []string) error {
  274. event.SetNonInteractive(true)
  275. event.SessionRenamed(sessionRenameJSON)
  276. ctx, svc, cleanup, err := sessionSetup(cmd)
  277. if err != nil {
  278. return err
  279. }
  280. defer cleanup()
  281. sess, err := resolveSessionID(ctx, svc.sessions, args[0])
  282. if err != nil {
  283. return err
  284. }
  285. newTitle := strings.Join(args[1:], " ")
  286. if err := svc.sessions.Rename(ctx, sess.ID, newTitle); err != nil {
  287. return fmt.Errorf("failed to rename session: %w", err)
  288. }
  289. out := cmd.OutOrStdout()
  290. if sessionRenameJSON {
  291. enc := json.NewEncoder(out)
  292. enc.SetEscapeHTML(false)
  293. return enc.Encode(sessionMutationResult{
  294. ID: session.HashID(sess.ID),
  295. UUID: sess.ID,
  296. Title: newTitle,
  297. Renamed: true,
  298. })
  299. }
  300. fmt.Fprintf(out, "Renamed session %s to %q\n", session.HashID(sess.ID)[:12], newTitle)
  301. return nil
  302. }
  303. func runSessionLast(cmd *cobra.Command, _ []string) error {
  304. event.SetNonInteractive(true)
  305. event.SessionLastShown(sessionLastJSON)
  306. ctx, svc, cleanup, err := sessionSetup(cmd)
  307. if err != nil {
  308. return err
  309. }
  310. defer cleanup()
  311. list, err := svc.sessions.List(ctx)
  312. if err != nil {
  313. return fmt.Errorf("failed to list sessions: %w", err)
  314. }
  315. if len(list) == 0 {
  316. return fmt.Errorf("no sessions found")
  317. }
  318. sess := list[0]
  319. msgs, err := svc.messages.List(ctx, sess.ID)
  320. if err != nil {
  321. return fmt.Errorf("failed to list messages: %w", err)
  322. }
  323. msgPtrs := messagePtrs(msgs)
  324. if sessionLastJSON {
  325. return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
  326. }
  327. return outputSessionHuman(ctx, sess, msgPtrs)
  328. }
  329. const (
  330. sessionOutputWidth = 80
  331. sessionMaxContentWidth = 120
  332. )
  333. func messagePtrs(msgs []message.Message) []*message.Message {
  334. ptrs := make([]*message.Message, len(msgs))
  335. for i := range msgs {
  336. ptrs[i] = &msgs[i]
  337. }
  338. return ptrs
  339. }
  340. func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Message) error {
  341. skills := extractSkillsFromMessages(msgs)
  342. output := sessionShowOutput{
  343. Meta: sessionShowMeta{
  344. ID: session.HashID(sess.ID),
  345. UUID: sess.ID,
  346. Title: sess.Title,
  347. Created: time.Unix(sess.CreatedAt, 0).Format(time.RFC3339),
  348. Modified: time.Unix(sess.UpdatedAt, 0).Format(time.RFC3339),
  349. Cost: sess.Cost,
  350. PromptTokens: sess.PromptTokens,
  351. CompletionTokens: sess.CompletionTokens,
  352. TotalTokens: sess.PromptTokens + sess.CompletionTokens,
  353. Skills: skills,
  354. },
  355. Messages: make([]sessionShowMessage, len(msgs)),
  356. }
  357. for i, msg := range msgs {
  358. output.Messages[i] = sessionShowMessage{
  359. ID: msg.ID,
  360. Role: string(msg.Role),
  361. Created: time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
  362. Model: msg.Model,
  363. Provider: msg.Provider,
  364. Parts: convertParts(msg.Parts),
  365. }
  366. }
  367. enc := json.NewEncoder(w)
  368. enc.SetEscapeHTML(false)
  369. return enc.Encode(output)
  370. }
  371. func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error {
  372. sty := styles.DefaultStyles()
  373. toolResults := chat.BuildToolResultMap(msgs)
  374. width := sessionOutputWidth
  375. if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 {
  376. width = w
  377. }
  378. contentWidth := min(width, sessionMaxContentWidth)
  379. keyStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
  380. valStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
  381. hash := session.HashID(sess.ID)[:12]
  382. created := time.Unix(sess.CreatedAt, 0).Format("Mon Jan 2 15:04:05 2006 -0700")
  383. skills := extractSkillsFromMessages(msgs)
  384. // Render to buffer to determine actual height
  385. var buf strings.Builder
  386. fmt.Fprintln(&buf, keyStyle.Render("ID: ")+valStyle.Render(hash))
  387. fmt.Fprintln(&buf, keyStyle.Render("UUID: ")+valStyle.Render(sess.ID))
  388. fmt.Fprintln(&buf, keyStyle.Render("Title: ")+valStyle.Render(sess.Title))
  389. fmt.Fprintln(&buf, keyStyle.Render("Date: ")+valStyle.Render(created))
  390. if len(skills) > 0 {
  391. skillNames := make([]string, len(skills))
  392. for i, s := range skills {
  393. timestamp := time.Unix(sess.CreatedAt, 0).Format("15:04:05 -0700")
  394. if s.LoadedAt != "" {
  395. if t, err := time.Parse(time.RFC3339, s.LoadedAt); err == nil {
  396. timestamp = t.Format("15:04:05 -0700")
  397. }
  398. }
  399. skillNames[i] = fmt.Sprintf("%s (%s)", s.Name, timestamp)
  400. }
  401. fmt.Fprintln(&buf, keyStyle.Render("Skills: ")+valStyle.Render(strings.Join(skillNames, ", ")))
  402. }
  403. fmt.Fprintln(&buf)
  404. first := true
  405. for _, msg := range msgs {
  406. items := chat.ExtractMessageItems(&sty, msg, toolResults)
  407. for _, item := range items {
  408. if !first {
  409. fmt.Fprintln(&buf)
  410. }
  411. first = false
  412. fmt.Fprintln(&buf, item.Render(contentWidth))
  413. }
  414. }
  415. fmt.Fprintln(&buf)
  416. contentHeight := strings.Count(buf.String(), "\n")
  417. w, cleanup, usingPager := sessionWriter(ctx, contentHeight)
  418. defer cleanup()
  419. _, err := io.WriteString(w, buf.String())
  420. // Ignore broken pipe errors when using a pager. This happens when the user
  421. // exits the pager early (e.g., pressing 'q' in less), which closes the pipe
  422. // and causes subsequent writes to fail. These errors are expected user behavior.
  423. if err != nil && usingPager && isBrokenPipe(err) {
  424. return nil
  425. }
  426. return err
  427. }
  428. func isBrokenPipe(err error) bool {
  429. if err == nil {
  430. return false
  431. }
  432. // Check for syscall.EPIPE (broken pipe)
  433. if errors.Is(err, syscall.EPIPE) {
  434. return true
  435. }
  436. // Also check for "broken pipe" in the error message
  437. return strings.Contains(err.Error(), "broken pipe")
  438. }
  439. // sessionWriter returns a writer, cleanup function, and a bool indicating if a pager is used.
  440. // When the content fits within the terminal (or stdout is not a TTY), it returns
  441. // a colorprofile.Writer wrapping stdout. When content exceeds terminal height,
  442. // it starts a pager process (respecting $PAGER, defaulting to "less -R").
  443. func sessionWriter(ctx context.Context, contentHeight int) (io.Writer, func(), bool) {
  444. // Use NewWriter which automatically detects TTY and strips ANSI when redirected
  445. if runtime.GOOS == "windows" || !term.IsTerminal(os.Stdout.Fd()) {
  446. return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
  447. }
  448. _, termHeight, err := term.GetSize(os.Stdout.Fd())
  449. if err != nil || contentHeight <= termHeight {
  450. return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
  451. }
  452. // Detect color profile from stderr since stdout is piped to the pager.
  453. profile := colorprofile.Detect(os.Stderr, os.Environ())
  454. pager := os.Getenv("PAGER")
  455. if pager == "" {
  456. pager = "less -R"
  457. }
  458. parts := strings.Fields(pager)
  459. cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) //nolint:gosec
  460. cmd.Stdout = os.Stdout
  461. cmd.Stderr = os.Stderr
  462. pipe, err := cmd.StdinPipe()
  463. if err != nil {
  464. return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
  465. }
  466. if err := cmd.Start(); err != nil {
  467. return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
  468. }
  469. return &colorprofile.Writer{
  470. Forward: pipe,
  471. Profile: profile,
  472. }, func() {
  473. pipe.Close()
  474. _ = cmd.Wait()
  475. }, true
  476. }
  477. type sessionShowMeta struct {
  478. ID string `json:"id"`
  479. UUID string `json:"uuid"`
  480. Title string `json:"title"`
  481. Created string `json:"created"`
  482. Modified string `json:"modified"`
  483. Cost float64 `json:"cost"`
  484. PromptTokens int64 `json:"prompt_tokens"`
  485. CompletionTokens int64 `json:"completion_tokens"`
  486. TotalTokens int64 `json:"total_tokens"`
  487. Skills []sessionShowSkill `json:"skills,omitempty"`
  488. }
  489. type sessionShowSkill struct {
  490. Name string `json:"name"`
  491. Description string `json:"description"`
  492. LoadedAt string `json:"loaded_at"`
  493. }
  494. type sessionShowMessage struct {
  495. ID string `json:"id"`
  496. Role string `json:"role"`
  497. Created string `json:"created"`
  498. Model string `json:"model,omitempty"`
  499. Provider string `json:"provider,omitempty"`
  500. Parts []sessionShowPart `json:"parts"`
  501. }
  502. type sessionShowPart struct {
  503. Type string `json:"type"`
  504. // Text content
  505. Text string `json:"text,omitempty"`
  506. // Reasoning
  507. Thinking string `json:"thinking,omitempty"`
  508. StartedAt int64 `json:"started_at,omitempty"`
  509. FinishedAt int64 `json:"finished_at,omitempty"`
  510. // Tool call
  511. ToolCallID string `json:"tool_call_id,omitempty"`
  512. Name string `json:"name,omitempty"`
  513. Input string `json:"input,omitempty"`
  514. // Tool result
  515. Content string `json:"content,omitempty"`
  516. IsError bool `json:"is_error,omitempty"`
  517. MIMEType string `json:"mime_type,omitempty"`
  518. // Binary
  519. Size int64 `json:"size,omitempty"`
  520. // Image URL
  521. URL string `json:"url,omitempty"`
  522. Detail string `json:"detail,omitempty"`
  523. // Finish
  524. Reason string `json:"reason,omitempty"`
  525. Time int64 `json:"time,omitempty"`
  526. }
  527. func extractSkillsFromMessages(msgs []*message.Message) []sessionShowSkill {
  528. var skills []sessionShowSkill
  529. seen := make(map[string]bool)
  530. for _, msg := range msgs {
  531. for _, part := range msg.Parts {
  532. if tr, ok := part.(message.ToolResult); ok && tr.Metadata != "" {
  533. var meta tools.ViewResponseMetadata
  534. if err := json.Unmarshal([]byte(tr.Metadata), &meta); err == nil {
  535. if meta.ResourceType == tools.ViewResourceSkill && meta.ResourceName != "" {
  536. if !seen[meta.ResourceName] {
  537. seen[meta.ResourceName] = true
  538. skills = append(skills, sessionShowSkill{
  539. Name: meta.ResourceName,
  540. Description: meta.ResourceDescription,
  541. LoadedAt: time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
  542. })
  543. }
  544. }
  545. }
  546. }
  547. }
  548. }
  549. sort.Slice(skills, func(i, j int) bool {
  550. if skills[i].LoadedAt == skills[j].LoadedAt {
  551. return skills[i].Name < skills[j].Name
  552. }
  553. return skills[i].LoadedAt < skills[j].LoadedAt
  554. })
  555. return skills
  556. }
  557. func convertParts(parts []message.ContentPart) []sessionShowPart {
  558. result := make([]sessionShowPart, 0, len(parts))
  559. for _, part := range parts {
  560. switch p := part.(type) {
  561. case message.TextContent:
  562. result = append(result, sessionShowPart{
  563. Type: "text",
  564. Text: p.Text,
  565. })
  566. case message.ReasoningContent:
  567. result = append(result, sessionShowPart{
  568. Type: "reasoning",
  569. Thinking: p.Thinking,
  570. StartedAt: p.StartedAt,
  571. FinishedAt: p.FinishedAt,
  572. })
  573. case message.ToolCall:
  574. result = append(result, sessionShowPart{
  575. Type: "tool_call",
  576. ToolCallID: p.ID,
  577. Name: p.Name,
  578. Input: p.Input,
  579. })
  580. case message.ToolResult:
  581. result = append(result, sessionShowPart{
  582. Type: "tool_result",
  583. ToolCallID: p.ToolCallID,
  584. Name: p.Name,
  585. Content: p.Content,
  586. IsError: p.IsError,
  587. MIMEType: p.MIMEType,
  588. })
  589. case message.BinaryContent:
  590. result = append(result, sessionShowPart{
  591. Type: "binary",
  592. MIMEType: p.MIMEType,
  593. Size: int64(len(p.Data)),
  594. })
  595. case message.ImageURLContent:
  596. result = append(result, sessionShowPart{
  597. Type: "image_url",
  598. URL: p.URL,
  599. Detail: p.Detail,
  600. })
  601. case message.Finish:
  602. result = append(result, sessionShowPart{
  603. Type: "finish",
  604. Reason: string(p.Reason),
  605. Time: p.Time,
  606. })
  607. default:
  608. result = append(result, sessionShowPart{
  609. Type: "unknown",
  610. })
  611. }
  612. }
  613. return result
  614. }
  615. type sessionShowOutput struct {
  616. Meta sessionShowMeta `json:"meta"`
  617. Messages []sessionShowMessage `json:"messages"`
  618. }