session.go 20 KB

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