message.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  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/kujtimiihoxha/opencode/internal/config"
  14. "github.com/kujtimiihoxha/opencode/internal/diff"
  15. "github.com/kujtimiihoxha/opencode/internal/llm/agent"
  16. "github.com/kujtimiihoxha/opencode/internal/llm/models"
  17. "github.com/kujtimiihoxha/opencode/internal/llm/tools"
  18. "github.com/kujtimiihoxha/opencode/internal/message"
  19. "github.com/kujtimiihoxha/opencode/internal/tui/styles"
  20. )
  21. type uiMessageType int
  22. const (
  23. userMessageType uiMessageType = iota
  24. assistantMessageType
  25. toolMessageType
  26. maxResultHeight = 15
  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. // find the user message that is before this assistant message
  103. var userMsg message.Message
  104. for i := msgIndex - 1; i >= 0; i-- {
  105. msg := allMessages[i]
  106. if msg.Role == message.User {
  107. userMsg = allMessages[i]
  108. break
  109. }
  110. }
  111. messages := []uiMessage{}
  112. content := msg.Content().String()
  113. finished := msg.IsFinished()
  114. finishData := msg.FinishPart()
  115. info := []string{}
  116. // Add finish info if available
  117. if finished {
  118. switch finishData.Reason {
  119. case message.FinishReasonEndTurn:
  120. took := formatTimeDifference(userMsg.CreatedAt, finishData.Time)
  121. info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
  122. fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
  123. ))
  124. case message.FinishReasonCanceled:
  125. info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
  126. fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
  127. ))
  128. case message.FinishReasonError:
  129. info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
  130. fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
  131. ))
  132. case message.FinishReasonPermissionDenied:
  133. info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
  134. fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
  135. ))
  136. }
  137. }
  138. if content != "" {
  139. content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
  140. messages = append(messages, uiMessage{
  141. ID: msg.ID,
  142. messageType: assistantMessageType,
  143. position: position,
  144. height: lipgloss.Height(content),
  145. content: content,
  146. })
  147. position += messages[0].height
  148. position++ // for the space
  149. }
  150. for i, toolCall := range msg.ToolCalls() {
  151. toolCallContent := renderToolMessage(
  152. toolCall,
  153. allMessages,
  154. messagesService,
  155. focusedUIMessageId,
  156. false,
  157. width,
  158. i+1,
  159. )
  160. messages = append(messages, toolCallContent)
  161. position += toolCallContent.height
  162. position++ // for the space
  163. }
  164. return messages
  165. }
  166. func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
  167. for _, msg := range futureMessages {
  168. for _, result := range msg.ToolResults() {
  169. if result.ToolCallID == toolCallID {
  170. return &result
  171. }
  172. }
  173. }
  174. return nil
  175. }
  176. func toolName(name string) string {
  177. switch name {
  178. case agent.AgentToolName:
  179. return "Task"
  180. case tools.BashToolName:
  181. return "Bash"
  182. case tools.EditToolName:
  183. return "Edit"
  184. case tools.FetchToolName:
  185. return "Fetch"
  186. case tools.GlobToolName:
  187. return "Glob"
  188. case tools.GrepToolName:
  189. return "Grep"
  190. case tools.LSToolName:
  191. return "List"
  192. case tools.SourcegraphToolName:
  193. return "Sourcegraph"
  194. case tools.ViewToolName:
  195. return "View"
  196. case tools.WriteToolName:
  197. return "Write"
  198. }
  199. return name
  200. }
  201. // renders params, params[0] (params[1]=params[2] ....)
  202. func renderParams(paramsWidth int, params ...string) string {
  203. if len(params) == 0 {
  204. return ""
  205. }
  206. mainParam := params[0]
  207. if len(mainParam) > paramsWidth {
  208. mainParam = mainParam[:paramsWidth-3] + "..."
  209. }
  210. if len(params) == 1 {
  211. return mainParam
  212. }
  213. otherParams := params[1:]
  214. // create pairs of key/value
  215. // if odd number of params, the last one is a key without value
  216. if len(otherParams)%2 != 0 {
  217. otherParams = append(otherParams, "")
  218. }
  219. parts := make([]string, 0, len(otherParams)/2)
  220. for i := 0; i < len(otherParams); i += 2 {
  221. key := otherParams[i]
  222. value := otherParams[i+1]
  223. if value == "" {
  224. continue
  225. }
  226. parts = append(parts, fmt.Sprintf("%s=%s", key, value))
  227. }
  228. partsRendered := strings.Join(parts, ", ")
  229. remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
  230. if remainingWidth < 30 {
  231. // No space for the params, just show the main
  232. return mainParam
  233. }
  234. if len(parts) > 0 {
  235. mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
  236. }
  237. return ansi.Truncate(mainParam, paramsWidth, "...")
  238. }
  239. func removeWorkingDirPrefix(path string) string {
  240. wd := config.WorkingDirectory()
  241. if strings.HasPrefix(path, wd) {
  242. path = strings.TrimPrefix(path, wd)
  243. }
  244. if strings.HasPrefix(path, "/") {
  245. path = strings.TrimPrefix(path, "/")
  246. }
  247. if strings.HasPrefix(path, "./") {
  248. path = strings.TrimPrefix(path, "./")
  249. }
  250. if strings.HasPrefix(path, "../") {
  251. path = strings.TrimPrefix(path, "../")
  252. }
  253. return path
  254. }
  255. func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
  256. params := ""
  257. switch toolCall.Name {
  258. case agent.AgentToolName:
  259. var params agent.AgentParams
  260. json.Unmarshal([]byte(toolCall.Input), &params)
  261. prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
  262. return renderParams(paramWidth, prompt)
  263. case tools.BashToolName:
  264. var params tools.BashParams
  265. json.Unmarshal([]byte(toolCall.Input), &params)
  266. command := strings.ReplaceAll(params.Command, "\n", " ")
  267. return renderParams(paramWidth, command)
  268. case tools.EditToolName:
  269. var params tools.EditParams
  270. json.Unmarshal([]byte(toolCall.Input), &params)
  271. filePath := removeWorkingDirPrefix(params.FilePath)
  272. return renderParams(paramWidth, filePath)
  273. case tools.FetchToolName:
  274. var params tools.FetchParams
  275. json.Unmarshal([]byte(toolCall.Input), &params)
  276. url := params.URL
  277. toolParams := []string{
  278. url,
  279. }
  280. if params.Format != "" {
  281. toolParams = append(toolParams, "format", params.Format)
  282. }
  283. if params.Timeout != 0 {
  284. toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
  285. }
  286. return renderParams(paramWidth, toolParams...)
  287. case tools.GlobToolName:
  288. var params tools.GlobParams
  289. json.Unmarshal([]byte(toolCall.Input), &params)
  290. pattern := params.Pattern
  291. toolParams := []string{
  292. pattern,
  293. }
  294. if params.Path != "" {
  295. toolParams = append(toolParams, "path", params.Path)
  296. }
  297. return renderParams(paramWidth, toolParams...)
  298. case tools.GrepToolName:
  299. var params tools.GrepParams
  300. json.Unmarshal([]byte(toolCall.Input), &params)
  301. pattern := params.Pattern
  302. toolParams := []string{
  303. pattern,
  304. }
  305. if params.Path != "" {
  306. toolParams = append(toolParams, "path", params.Path)
  307. }
  308. if params.Include != "" {
  309. toolParams = append(toolParams, "include", params.Include)
  310. }
  311. if params.LiteralText {
  312. toolParams = append(toolParams, "literal", "true")
  313. }
  314. return renderParams(paramWidth, toolParams...)
  315. case tools.LSToolName:
  316. var params tools.LSParams
  317. json.Unmarshal([]byte(toolCall.Input), &params)
  318. path := params.Path
  319. if path == "" {
  320. path = "."
  321. }
  322. return renderParams(paramWidth, path)
  323. case tools.SourcegraphToolName:
  324. var params tools.SourcegraphParams
  325. json.Unmarshal([]byte(toolCall.Input), &params)
  326. return renderParams(paramWidth, params.Query)
  327. case tools.ViewToolName:
  328. var params tools.ViewParams
  329. json.Unmarshal([]byte(toolCall.Input), &params)
  330. filePath := removeWorkingDirPrefix(params.FilePath)
  331. toolParams := []string{
  332. filePath,
  333. }
  334. if params.Limit != 0 {
  335. toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
  336. }
  337. if params.Offset != 0 {
  338. toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
  339. }
  340. return renderParams(paramWidth, toolParams...)
  341. case tools.WriteToolName:
  342. var params tools.WriteParams
  343. json.Unmarshal([]byte(toolCall.Input), &params)
  344. filePath := removeWorkingDirPrefix(params.FilePath)
  345. return renderParams(paramWidth, filePath)
  346. default:
  347. input := strings.ReplaceAll(toolCall.Input, "\n", " ")
  348. params = renderParams(paramWidth, input)
  349. }
  350. return params
  351. }
  352. func truncateHeight(content string, height int) string {
  353. lines := strings.Split(content, "\n")
  354. if len(lines) > height {
  355. return strings.Join(lines[:height], "\n")
  356. }
  357. return content
  358. }
  359. func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
  360. if response.IsError {
  361. errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
  362. errContent = ansi.Truncate(errContent, width-1, "...")
  363. return styles.BaseStyle.
  364. Foreground(styles.Error).
  365. Render(errContent)
  366. }
  367. resultContent := truncateHeight(response.Content, maxResultHeight)
  368. switch toolCall.Name {
  369. case agent.AgentToolName:
  370. return styles.ForceReplaceBackgroundWithLipgloss(
  371. toMarkdown(resultContent, false, width),
  372. styles.Background,
  373. )
  374. case tools.BashToolName:
  375. resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
  376. return styles.ForceReplaceBackgroundWithLipgloss(
  377. toMarkdown(resultContent, true, width),
  378. styles.Background,
  379. )
  380. case tools.EditToolName:
  381. metadata := tools.EditResponseMetadata{}
  382. json.Unmarshal([]byte(response.Metadata), &metadata)
  383. truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
  384. formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
  385. return formattedDiff
  386. case tools.FetchToolName:
  387. var params tools.FetchParams
  388. json.Unmarshal([]byte(toolCall.Input), &params)
  389. mdFormat := "markdown"
  390. switch params.Format {
  391. case "text":
  392. mdFormat = "text"
  393. case "html":
  394. mdFormat = "html"
  395. }
  396. resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
  397. return styles.ForceReplaceBackgroundWithLipgloss(
  398. toMarkdown(resultContent, true, width),
  399. styles.Background,
  400. )
  401. case tools.GlobToolName:
  402. return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
  403. case tools.GrepToolName:
  404. return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
  405. case tools.LSToolName:
  406. return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
  407. case tools.SourcegraphToolName:
  408. return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
  409. case tools.ViewToolName:
  410. metadata := tools.ViewResponseMetadata{}
  411. json.Unmarshal([]byte(response.Metadata), &metadata)
  412. ext := filepath.Ext(metadata.FilePath)
  413. if ext == "" {
  414. ext = ""
  415. } else {
  416. ext = strings.ToLower(ext[1:])
  417. }
  418. resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
  419. return styles.ForceReplaceBackgroundWithLipgloss(
  420. toMarkdown(resultContent, true, width),
  421. styles.Background,
  422. )
  423. case tools.WriteToolName:
  424. params := tools.WriteParams{}
  425. json.Unmarshal([]byte(toolCall.Input), &params)
  426. metadata := tools.WriteResponseMetadata{}
  427. json.Unmarshal([]byte(response.Metadata), &metadata)
  428. ext := filepath.Ext(params.FilePath)
  429. if ext == "" {
  430. ext = ""
  431. } else {
  432. ext = strings.ToLower(ext[1:])
  433. }
  434. resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
  435. return styles.ForceReplaceBackgroundWithLipgloss(
  436. toMarkdown(resultContent, true, width),
  437. styles.Background,
  438. )
  439. default:
  440. resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
  441. return styles.ForceReplaceBackgroundWithLipgloss(
  442. toMarkdown(resultContent, true, width),
  443. styles.Background,
  444. )
  445. }
  446. }
  447. func renderToolMessage(
  448. toolCall message.ToolCall,
  449. allMessages []message.Message,
  450. messagesService message.Service,
  451. focusedUIMessageId string,
  452. nested bool,
  453. width int,
  454. position int,
  455. ) uiMessage {
  456. if nested {
  457. width = width - 3
  458. }
  459. response := findToolResponse(toolCall.ID, allMessages)
  460. toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
  461. params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
  462. responseContent := ""
  463. if response != nil {
  464. responseContent = renderToolResponse(toolCall, *response, width-2)
  465. responseContent = strings.TrimSuffix(responseContent, "\n")
  466. } else {
  467. responseContent = styles.BaseStyle.
  468. Italic(true).
  469. Width(width - 2).
  470. Foreground(styles.ForgroundDim).
  471. Render("Waiting for response...")
  472. }
  473. style := styles.BaseStyle.
  474. Width(width - 1).
  475. BorderLeft(true).
  476. BorderStyle(lipgloss.ThickBorder()).
  477. PaddingLeft(1).
  478. BorderForeground(styles.ForgroundDim)
  479. parts := []string{}
  480. if !nested {
  481. params := styles.BaseStyle.
  482. Width(width - 2 - lipgloss.Width(toolName)).
  483. Foreground(styles.ForgroundDim).
  484. Render(params)
  485. parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
  486. } else {
  487. prefix := styles.BaseStyle.
  488. Foreground(styles.ForgroundDim).
  489. Render(" └ ")
  490. params := styles.BaseStyle.
  491. Width(width - 2 - lipgloss.Width(toolName)).
  492. Foreground(styles.ForgroundMid).
  493. Render(params)
  494. parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
  495. }
  496. if toolCall.Name == agent.AgentToolName {
  497. taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
  498. toolCalls := []message.ToolCall{}
  499. for _, v := range taskMessages {
  500. toolCalls = append(toolCalls, v.ToolCalls()...)
  501. }
  502. for _, call := range toolCalls {
  503. rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
  504. parts = append(parts, rendered.content)
  505. }
  506. }
  507. if responseContent != "" && !nested {
  508. parts = append(parts, responseContent)
  509. }
  510. content := style.Render(
  511. lipgloss.JoinVertical(
  512. lipgloss.Left,
  513. parts...,
  514. ),
  515. )
  516. if nested {
  517. content = lipgloss.JoinVertical(
  518. lipgloss.Left,
  519. parts...,
  520. )
  521. }
  522. toolMsg := uiMessage{
  523. messageType: toolMessageType,
  524. position: position,
  525. height: lipgloss.Height(content),
  526. content: content,
  527. }
  528. return toolMsg
  529. }