message.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. package chat
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "path/filepath"
  7. "strings"
  8. "time"
  9. "github.com/charmbracelet/lipgloss"
  10. "github.com/charmbracelet/x/ansi"
  11. "github.com/opencode-ai/opencode/internal/config"
  12. "github.com/opencode-ai/opencode/internal/diff"
  13. "github.com/opencode-ai/opencode/internal/llm/agent"
  14. "github.com/opencode-ai/opencode/internal/llm/models"
  15. "github.com/opencode-ai/opencode/internal/llm/tools"
  16. "github.com/opencode-ai/opencode/internal/message"
  17. "github.com/opencode-ai/opencode/internal/tui/styles"
  18. "github.com/opencode-ai/opencode/internal/tui/theme"
  19. )
  20. type uiMessageType int
  21. const (
  22. userMessageType uiMessageType = iota
  23. assistantMessageType
  24. toolMessageType
  25. maxResultHeight = 10
  26. )
  27. type uiMessage struct {
  28. ID string
  29. messageType uiMessageType
  30. position int
  31. height int
  32. content string
  33. }
  34. func toMarkdown(content string, focused bool, width int) string {
  35. r := styles.GetMarkdownRenderer(width)
  36. rendered, _ := r.Render(content)
  37. return rendered
  38. }
  39. func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
  40. t := theme.CurrentTheme()
  41. style := styles.BaseStyle().
  42. Width(width - 1).
  43. BorderLeft(true).
  44. Foreground(t.TextMuted()).
  45. BorderForeground(t.Primary()).
  46. BorderStyle(lipgloss.ThickBorder())
  47. if isUser {
  48. style = style.BorderForeground(t.Secondary())
  49. }
  50. // Apply markdown formatting and handle background color
  51. parts := []string{
  52. styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()),
  53. }
  54. // Remove newline at the end
  55. parts[0] = strings.TrimSuffix(parts[0], "\n")
  56. if len(info) > 0 {
  57. parts = append(parts, info...)
  58. }
  59. rendered := style.Render(
  60. lipgloss.JoinVertical(
  61. lipgloss.Left,
  62. parts...,
  63. ),
  64. )
  65. return rendered
  66. }
  67. func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
  68. var styledAttachments []string
  69. t := theme.CurrentTheme()
  70. attachmentStyles := styles.BaseStyle().
  71. MarginLeft(1).
  72. Background(t.TextMuted()).
  73. Foreground(t.Text())
  74. for _, attachment := range msg.BinaryContent() {
  75. file := filepath.Base(attachment.Path)
  76. var filename string
  77. if len(file) > 10 {
  78. filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
  79. } else {
  80. filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
  81. }
  82. styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
  83. }
  84. content := ""
  85. if len(styledAttachments) > 0 {
  86. attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
  87. content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent)
  88. } else {
  89. content = renderMessage(msg.Content().String(), true, isFocused, width)
  90. }
  91. userMsg := uiMessage{
  92. ID: msg.ID,
  93. messageType: userMessageType,
  94. position: position,
  95. height: lipgloss.Height(content),
  96. content: content,
  97. }
  98. return userMsg
  99. }
  100. // Returns multiple uiMessages because of the tool calls
  101. func renderAssistantMessage(
  102. msg message.Message,
  103. msgIndex int,
  104. allMessages []message.Message, // we need this to get tool results and the user message
  105. messagesService message.Service, // We need this to get the task tool messages
  106. focusedUIMessageId string,
  107. width int,
  108. position int,
  109. ) []uiMessage {
  110. messages := []uiMessage{}
  111. content := msg.Content().String()
  112. thinking := msg.IsThinking()
  113. thinkingContent := msg.ReasoningContent().Thinking
  114. finished := msg.IsFinished()
  115. finishData := msg.FinishPart()
  116. info := []string{}
  117. t := theme.CurrentTheme()
  118. baseStyle := styles.BaseStyle()
  119. // Add finish info if available
  120. if finished {
  121. switch finishData.Reason {
  122. case message.FinishReasonEndTurn:
  123. took := formatTimestampDiff(msg.CreatedAt, finishData.Time)
  124. info = append(info, baseStyle.
  125. Width(width-1).
  126. Foreground(t.TextMuted()).
  127. Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took)),
  128. )
  129. case message.FinishReasonCanceled:
  130. info = append(info, baseStyle.
  131. Width(width-1).
  132. Foreground(t.TextMuted()).
  133. Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled")),
  134. )
  135. case message.FinishReasonError:
  136. info = append(info, baseStyle.
  137. Width(width-1).
  138. Foreground(t.TextMuted()).
  139. Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error")),
  140. )
  141. case message.FinishReasonPermissionDenied:
  142. info = append(info, baseStyle.
  143. Width(width-1).
  144. Foreground(t.TextMuted()).
  145. Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied")),
  146. )
  147. }
  148. }
  149. if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
  150. if content == "" {
  151. content = "*Finished without output*"
  152. }
  153. content = renderMessage(content, false, true, width, info...)
  154. messages = append(messages, uiMessage{
  155. ID: msg.ID,
  156. messageType: assistantMessageType,
  157. position: position,
  158. height: lipgloss.Height(content),
  159. content: content,
  160. })
  161. position += messages[0].height
  162. position++ // for the space
  163. } else if thinking && thinkingContent != "" {
  164. // Render the thinking content
  165. content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
  166. }
  167. for i, toolCall := range msg.ToolCalls() {
  168. toolCallContent := renderToolMessage(
  169. toolCall,
  170. allMessages,
  171. messagesService,
  172. focusedUIMessageId,
  173. false,
  174. width,
  175. i+1,
  176. )
  177. messages = append(messages, toolCallContent)
  178. position += toolCallContent.height
  179. position++ // for the space
  180. }
  181. return messages
  182. }
  183. func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
  184. for _, msg := range futureMessages {
  185. for _, result := range msg.ToolResults() {
  186. if result.ToolCallID == toolCallID {
  187. return &result
  188. }
  189. }
  190. }
  191. return nil
  192. }
  193. func toolName(name string) string {
  194. switch name {
  195. case agent.AgentToolName:
  196. return "Task"
  197. case tools.BashToolName:
  198. return "Bash"
  199. case tools.EditToolName:
  200. return "Edit"
  201. case tools.FetchToolName:
  202. return "Fetch"
  203. case tools.GlobToolName:
  204. return "Glob"
  205. case tools.GrepToolName:
  206. return "Grep"
  207. case tools.LSToolName:
  208. return "List"
  209. case tools.SourcegraphToolName:
  210. return "Sourcegraph"
  211. case tools.ViewToolName:
  212. return "View"
  213. case tools.WriteToolName:
  214. return "Write"
  215. case tools.PatchToolName:
  216. return "Patch"
  217. }
  218. return name
  219. }
  220. func getToolAction(name string) string {
  221. switch name {
  222. case agent.AgentToolName:
  223. return "Preparing prompt..."
  224. case tools.BashToolName:
  225. return "Building command..."
  226. case tools.EditToolName:
  227. return "Preparing edit..."
  228. case tools.FetchToolName:
  229. return "Writing fetch..."
  230. case tools.GlobToolName:
  231. return "Finding files..."
  232. case tools.GrepToolName:
  233. return "Searching content..."
  234. case tools.LSToolName:
  235. return "Listing directory..."
  236. case tools.SourcegraphToolName:
  237. return "Searching code..."
  238. case tools.ViewToolName:
  239. return "Reading file..."
  240. case tools.WriteToolName:
  241. return "Preparing write..."
  242. case tools.PatchToolName:
  243. return "Preparing patch..."
  244. }
  245. return "Working..."
  246. }
  247. // renders params, params[0] (params[1]=params[2] ....)
  248. func renderParams(paramsWidth int, params ...string) string {
  249. if len(params) == 0 {
  250. return ""
  251. }
  252. mainParam := params[0]
  253. if len(mainParam) > paramsWidth {
  254. mainParam = mainParam[:paramsWidth-3] + "..."
  255. }
  256. if len(params) == 1 {
  257. return mainParam
  258. }
  259. otherParams := params[1:]
  260. // create pairs of key/value
  261. // if odd number of params, the last one is a key without value
  262. if len(otherParams)%2 != 0 {
  263. otherParams = append(otherParams, "")
  264. }
  265. parts := make([]string, 0, len(otherParams)/2)
  266. for i := 0; i < len(otherParams); i += 2 {
  267. key := otherParams[i]
  268. value := otherParams[i+1]
  269. if value == "" {
  270. continue
  271. }
  272. parts = append(parts, fmt.Sprintf("%s=%s", key, value))
  273. }
  274. partsRendered := strings.Join(parts, ", ")
  275. remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
  276. if remainingWidth < 30 {
  277. // No space for the params, just show the main
  278. return mainParam
  279. }
  280. if len(parts) > 0 {
  281. mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
  282. }
  283. return ansi.Truncate(mainParam, paramsWidth, "...")
  284. }
  285. func removeWorkingDirPrefix(path string) string {
  286. wd := config.WorkingDirectory()
  287. if strings.HasPrefix(path, wd) {
  288. path = strings.TrimPrefix(path, wd)
  289. }
  290. if strings.HasPrefix(path, "/") {
  291. path = strings.TrimPrefix(path, "/")
  292. }
  293. if strings.HasPrefix(path, "./") {
  294. path = strings.TrimPrefix(path, "./")
  295. }
  296. if strings.HasPrefix(path, "../") {
  297. path = strings.TrimPrefix(path, "../")
  298. }
  299. return path
  300. }
  301. func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
  302. params := ""
  303. switch toolCall.Name {
  304. case agent.AgentToolName:
  305. var params agent.AgentParams
  306. json.Unmarshal([]byte(toolCall.Input), &params)
  307. prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
  308. return renderParams(paramWidth, prompt)
  309. case tools.BashToolName:
  310. var params tools.BashParams
  311. json.Unmarshal([]byte(toolCall.Input), &params)
  312. command := strings.ReplaceAll(params.Command, "\n", " ")
  313. return renderParams(paramWidth, command)
  314. case tools.EditToolName:
  315. var params tools.EditParams
  316. json.Unmarshal([]byte(toolCall.Input), &params)
  317. filePath := removeWorkingDirPrefix(params.FilePath)
  318. return renderParams(paramWidth, filePath)
  319. case tools.FetchToolName:
  320. var params tools.FetchParams
  321. json.Unmarshal([]byte(toolCall.Input), &params)
  322. url := params.URL
  323. toolParams := []string{
  324. url,
  325. }
  326. if params.Format != "" {
  327. toolParams = append(toolParams, "format", params.Format)
  328. }
  329. if params.Timeout != 0 {
  330. toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
  331. }
  332. return renderParams(paramWidth, toolParams...)
  333. case tools.GlobToolName:
  334. var params tools.GlobParams
  335. json.Unmarshal([]byte(toolCall.Input), &params)
  336. pattern := params.Pattern
  337. toolParams := []string{
  338. pattern,
  339. }
  340. if params.Path != "" {
  341. toolParams = append(toolParams, "path", params.Path)
  342. }
  343. return renderParams(paramWidth, toolParams...)
  344. case tools.GrepToolName:
  345. var params tools.GrepParams
  346. json.Unmarshal([]byte(toolCall.Input), &params)
  347. pattern := params.Pattern
  348. toolParams := []string{
  349. pattern,
  350. }
  351. if params.Path != "" {
  352. toolParams = append(toolParams, "path", params.Path)
  353. }
  354. if params.Include != "" {
  355. toolParams = append(toolParams, "include", params.Include)
  356. }
  357. if params.LiteralText {
  358. toolParams = append(toolParams, "literal", "true")
  359. }
  360. return renderParams(paramWidth, toolParams...)
  361. case tools.LSToolName:
  362. var params tools.LSParams
  363. json.Unmarshal([]byte(toolCall.Input), &params)
  364. path := params.Path
  365. if path == "" {
  366. path = "."
  367. }
  368. return renderParams(paramWidth, path)
  369. case tools.SourcegraphToolName:
  370. var params tools.SourcegraphParams
  371. json.Unmarshal([]byte(toolCall.Input), &params)
  372. return renderParams(paramWidth, params.Query)
  373. case tools.ViewToolName:
  374. var params tools.ViewParams
  375. json.Unmarshal([]byte(toolCall.Input), &params)
  376. filePath := removeWorkingDirPrefix(params.FilePath)
  377. toolParams := []string{
  378. filePath,
  379. }
  380. if params.Limit != 0 {
  381. toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
  382. }
  383. if params.Offset != 0 {
  384. toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
  385. }
  386. return renderParams(paramWidth, toolParams...)
  387. case tools.WriteToolName:
  388. var params tools.WriteParams
  389. json.Unmarshal([]byte(toolCall.Input), &params)
  390. filePath := removeWorkingDirPrefix(params.FilePath)
  391. return renderParams(paramWidth, filePath)
  392. default:
  393. input := strings.ReplaceAll(toolCall.Input, "\n", " ")
  394. params = renderParams(paramWidth, input)
  395. }
  396. return params
  397. }
  398. func truncateHeight(content string, height int) string {
  399. lines := strings.Split(content, "\n")
  400. if len(lines) > height {
  401. return strings.Join(lines[:height], "\n")
  402. }
  403. return content
  404. }
  405. func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
  406. t := theme.CurrentTheme()
  407. baseStyle := styles.BaseStyle()
  408. if response.IsError {
  409. errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
  410. errContent = ansi.Truncate(errContent, width-1, "...")
  411. return baseStyle.
  412. Width(width).
  413. Foreground(t.Error()).
  414. Render(errContent)
  415. }
  416. resultContent := truncateHeight(response.Content, maxResultHeight)
  417. switch toolCall.Name {
  418. case agent.AgentToolName:
  419. return styles.ForceReplaceBackgroundWithLipgloss(
  420. toMarkdown(response.Content, false, width),
  421. t.Background(),
  422. )
  423. case tools.BashToolName:
  424. resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
  425. return styles.ForceReplaceBackgroundWithLipgloss(
  426. toMarkdown(resultContent, true, width),
  427. t.Background(),
  428. )
  429. case tools.EditToolName:
  430. metadata := tools.EditResponseMetadata{}
  431. json.Unmarshal([]byte(response.Metadata), &metadata)
  432. formattedDiff, _ := diff.FormatDiff(metadata.Diff, diff.WithTotalWidth(width))
  433. return formattedDiff
  434. case tools.FetchToolName:
  435. var params tools.FetchParams
  436. json.Unmarshal([]byte(toolCall.Input), &params)
  437. mdFormat := "markdown"
  438. switch params.Format {
  439. case "text":
  440. mdFormat = "text"
  441. case "html":
  442. mdFormat = "html"
  443. }
  444. resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
  445. return styles.ForceReplaceBackgroundWithLipgloss(
  446. toMarkdown(resultContent, true, width),
  447. t.Background(),
  448. )
  449. case tools.GlobToolName:
  450. return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
  451. case tools.GrepToolName:
  452. return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
  453. case tools.LSToolName:
  454. return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
  455. case tools.SourcegraphToolName:
  456. return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
  457. case tools.ViewToolName:
  458. metadata := tools.ViewResponseMetadata{}
  459. json.Unmarshal([]byte(response.Metadata), &metadata)
  460. ext := filepath.Ext(metadata.FilePath)
  461. if ext == "" {
  462. ext = ""
  463. } else {
  464. ext = strings.ToLower(ext[1:])
  465. }
  466. resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
  467. return styles.ForceReplaceBackgroundWithLipgloss(
  468. toMarkdown(resultContent, true, width),
  469. t.Background(),
  470. )
  471. case tools.WriteToolName:
  472. params := tools.WriteParams{}
  473. json.Unmarshal([]byte(toolCall.Input), &params)
  474. metadata := tools.WriteResponseMetadata{}
  475. json.Unmarshal([]byte(response.Metadata), &metadata)
  476. ext := filepath.Ext(params.FilePath)
  477. if ext == "" {
  478. ext = ""
  479. } else {
  480. ext = strings.ToLower(ext[1:])
  481. }
  482. resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
  483. return styles.ForceReplaceBackgroundWithLipgloss(
  484. toMarkdown(resultContent, true, width),
  485. t.Background(),
  486. )
  487. default:
  488. resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
  489. return styles.ForceReplaceBackgroundWithLipgloss(
  490. toMarkdown(resultContent, true, width),
  491. t.Background(),
  492. )
  493. }
  494. }
  495. func renderToolMessage(
  496. toolCall message.ToolCall,
  497. allMessages []message.Message,
  498. messagesService message.Service,
  499. focusedUIMessageId string,
  500. nested bool,
  501. width int,
  502. position int,
  503. ) uiMessage {
  504. if nested {
  505. width = width - 3
  506. }
  507. t := theme.CurrentTheme()
  508. baseStyle := styles.BaseStyle()
  509. style := baseStyle.
  510. Width(width - 1).
  511. BorderLeft(true).
  512. BorderStyle(lipgloss.ThickBorder()).
  513. PaddingLeft(1).
  514. BorderForeground(t.TextMuted())
  515. response := findToolResponse(toolCall.ID, allMessages)
  516. toolNameText := baseStyle.Foreground(t.TextMuted()).
  517. Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
  518. if !toolCall.Finished {
  519. // Get a brief description of what the tool is doing
  520. toolAction := getToolAction(toolCall.Name)
  521. progressText := baseStyle.
  522. Width(width - 2 - lipgloss.Width(toolNameText)).
  523. Foreground(t.TextMuted()).
  524. Render(fmt.Sprintf("%s", toolAction))
  525. content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
  526. toolMsg := uiMessage{
  527. messageType: toolMessageType,
  528. position: position,
  529. height: lipgloss.Height(content),
  530. content: content,
  531. }
  532. return toolMsg
  533. }
  534. params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall)
  535. responseContent := ""
  536. if response != nil {
  537. responseContent = renderToolResponse(toolCall, *response, width-2)
  538. responseContent = strings.TrimSuffix(responseContent, "\n")
  539. } else {
  540. responseContent = baseStyle.
  541. Italic(true).
  542. Width(width - 2).
  543. Foreground(t.TextMuted()).
  544. Render("Waiting for response...")
  545. }
  546. parts := []string{}
  547. if !nested {
  548. formattedParams := baseStyle.
  549. Width(width - 2 - lipgloss.Width(toolNameText)).
  550. Foreground(t.TextMuted()).
  551. Render(params)
  552. parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
  553. } else {
  554. prefix := baseStyle.
  555. Foreground(t.TextMuted()).
  556. Render(" └ ")
  557. formattedParams := baseStyle.
  558. Width(width - 2 - lipgloss.Width(toolNameText)).
  559. Foreground(t.TextMuted()).
  560. Render(params)
  561. parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
  562. }
  563. if toolCall.Name == agent.AgentToolName {
  564. taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
  565. toolCalls := []message.ToolCall{}
  566. for _, v := range taskMessages {
  567. toolCalls = append(toolCalls, v.ToolCalls()...)
  568. }
  569. for _, call := range toolCalls {
  570. rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
  571. parts = append(parts, rendered.content)
  572. }
  573. }
  574. if responseContent != "" && !nested {
  575. parts = append(parts, responseContent)
  576. }
  577. content := style.Render(
  578. lipgloss.JoinVertical(
  579. lipgloss.Left,
  580. parts...,
  581. ),
  582. )
  583. if nested {
  584. content = lipgloss.JoinVertical(
  585. lipgloss.Left,
  586. parts...,
  587. )
  588. }
  589. toolMsg := uiMessage{
  590. messageType: toolMessageType,
  591. position: position,
  592. height: lipgloss.Height(content),
  593. content: content,
  594. }
  595. return toolMsg
  596. }
  597. // Helper function to format the time difference between two Unix timestamps
  598. func formatTimestampDiff(start, end int64) string {
  599. diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
  600. if diffSeconds < 1 {
  601. return fmt.Sprintf("%dms", int(diffSeconds*1000))
  602. }
  603. if diffSeconds < 60 {
  604. return fmt.Sprintf("%.1fs", diffSeconds)
  605. }
  606. return fmt.Sprintf("%.1fm", diffSeconds/60)
  607. }