message.go 18 KB

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