message.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. package chat
  2. import (
  3. "fmt"
  4. "path/filepath"
  5. "strings"
  6. "time"
  7. "github.com/charmbracelet/lipgloss"
  8. "github.com/charmbracelet/x/ansi"
  9. "github.com/sst/opencode/internal/components/diff"
  10. "github.com/sst/opencode/internal/styles"
  11. "github.com/sst/opencode/internal/theme"
  12. "github.com/sst/opencode/pkg/client"
  13. "golang.org/x/text/cases"
  14. "golang.org/x/text/language"
  15. )
  16. const (
  17. maxResultHeight = 10
  18. )
  19. func toMarkdown(content string, width int) string {
  20. r := styles.GetMarkdownRenderer(width)
  21. rendered, _ := r.Render(content)
  22. lines := strings.Split(rendered, "\n")
  23. if len(lines) > 0 {
  24. firstLine := lines[0]
  25. cleaned := ansi.Strip(firstLine)
  26. nospace := strings.ReplaceAll(cleaned, " ", "")
  27. if nospace == "" {
  28. lines = lines[1:]
  29. }
  30. if len(lines) > 0 {
  31. lastLine := lines[len(lines)-1]
  32. cleaned = ansi.Strip(lastLine)
  33. nospace = strings.ReplaceAll(cleaned, " ", "")
  34. if nospace == "" {
  35. lines = lines[:len(lines)-1]
  36. }
  37. }
  38. }
  39. return strings.TrimSuffix(strings.Join(lines, "\n"), "\n")
  40. }
  41. func renderUserMessage(user string, msg client.MessageInfo, width int) string {
  42. t := theme.CurrentTheme()
  43. style := styles.BaseStyle().
  44. PaddingLeft(1).
  45. BorderLeft(true).
  46. Foreground(t.TextMuted()).
  47. BorderForeground(t.Secondary()).
  48. BorderStyle(lipgloss.ThickBorder())
  49. // var styledAttachments []string
  50. // attachmentStyles := baseStyle.
  51. // MarginLeft(1).
  52. // Background(t.TextMuted()).
  53. // Foreground(t.Text())
  54. // for _, attachment := range msg.BinaryContent() {
  55. // file := filepath.Base(attachment.Path)
  56. // var filename string
  57. // if len(file) > 10 {
  58. // filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
  59. // } else {
  60. // filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
  61. // }
  62. // styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
  63. // }
  64. timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
  65. if time.Now().Format("02 Jan 2006") == timestamp[:11] {
  66. timestamp = timestamp[12:]
  67. }
  68. info := styles.BaseStyle().
  69. Foreground(t.TextMuted()).
  70. Render(fmt.Sprintf("%s (%s)", user, timestamp))
  71. content := ""
  72. // if len(styledAttachments) > 0 {
  73. // attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
  74. // content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
  75. // } else {
  76. for _, p := range msg.Parts {
  77. part, err := p.ValueByDiscriminator()
  78. if err != nil {
  79. continue //TODO: handle error?
  80. }
  81. switch part.(type) {
  82. case client.MessagePartText:
  83. textPart := part.(client.MessagePartText)
  84. text := toMarkdown(textPart.Text, width)
  85. content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
  86. }
  87. }
  88. return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
  89. }
  90. func renderAssistantMessage(
  91. msg client.MessageInfo,
  92. width int,
  93. showToolMessages bool,
  94. appInfo client.AppInfo,
  95. ) string {
  96. t := theme.CurrentTheme()
  97. style := styles.BaseStyle().
  98. PaddingLeft(1).
  99. BorderLeft(true).
  100. Foreground(t.TextMuted()).
  101. BorderForeground(t.Primary()).
  102. BorderStyle(lipgloss.ThickBorder())
  103. messages := []string{}
  104. timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
  105. if time.Now().Format("02 Jan 2006") == timestamp[:11] {
  106. timestamp = timestamp[12:]
  107. }
  108. modelName := msg.Metadata.Assistant.ModelID
  109. info := styles.BaseStyle().
  110. Foreground(t.TextMuted()).
  111. Render(fmt.Sprintf("%s (%s)", modelName, timestamp))
  112. for _, p := range msg.Parts {
  113. part, err := p.ValueByDiscriminator()
  114. if err != nil {
  115. continue //TODO: handle error?
  116. }
  117. switch part.(type) {
  118. // case client.MessagePartReasoning:
  119. // reasoningPart := part.(client.MessagePartReasoning)
  120. case client.MessagePartText:
  121. textPart := part.(client.MessagePartText)
  122. text := toMarkdown(textPart.Text, width)
  123. content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
  124. message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
  125. messages = append(messages, message)
  126. case client.MessagePartToolInvocation:
  127. if !showToolMessages {
  128. continue
  129. }
  130. toolInvocationPart := part.(client.MessagePartToolInvocation)
  131. toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
  132. var result *string
  133. resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
  134. if resultError == nil {
  135. result = &resultPart.Result
  136. }
  137. metadata := map[string]any{}
  138. if _, ok := msg.Metadata.Tool[toolCall.ToolCallId]; ok {
  139. metadata = msg.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
  140. }
  141. message := renderToolInvocation(toolCall, result, metadata, appInfo, width)
  142. messages = append(messages, message)
  143. }
  144. }
  145. return strings.Join(messages, "\n\n")
  146. }
  147. func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result *string, metadata map[string]any, appInfo client.AppInfo, width int) string {
  148. t := theme.CurrentTheme()
  149. style := styles.BaseStyle().
  150. BorderLeft(true).
  151. PaddingLeft(1).
  152. Foreground(t.TextMuted()).
  153. BorderForeground(t.TextMuted()).
  154. BorderStyle(lipgloss.ThickBorder())
  155. toolName := renderToolName(toolCall.ToolName)
  156. toolArgs := ""
  157. toolArgsMap := make(map[string]any)
  158. if toolCall.Args != nil {
  159. value := *toolCall.Args
  160. m, ok := value.(map[string]any)
  161. if ok {
  162. toolArgsMap = m
  163. firstKey := ""
  164. for key := range toolArgsMap {
  165. firstKey = key
  166. break
  167. }
  168. toolArgs = renderArgs(&toolArgsMap, appInfo, firstKey)
  169. }
  170. }
  171. title := fmt.Sprintf("%s: %s", toolName, toolArgs)
  172. finished := result != nil
  173. body := styles.BaseStyle().Render("In progress...")
  174. if finished {
  175. body = *result
  176. }
  177. footer := ""
  178. if metadata["time"] != nil {
  179. timeMap := metadata["time"].(map[string]any)
  180. start := timeMap["start"].(float64)
  181. end := timeMap["end"].(float64)
  182. durationMs := end - start
  183. duration := time.Duration(durationMs * float64(time.Millisecond))
  184. roundedDuration := time.Duration(duration.Round(time.Millisecond))
  185. if durationMs > 1000 {
  186. roundedDuration = time.Duration(duration.Round(time.Second))
  187. }
  188. footer = styles.Muted().Render(fmt.Sprintf("%s", roundedDuration))
  189. }
  190. switch toolCall.ToolName {
  191. case "opencode_edit":
  192. filename := toolArgsMap["filePath"].(string)
  193. filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
  194. title = fmt.Sprintf("%s: %s", toolName, filename)
  195. if finished && metadata["diff"] != nil {
  196. patch := metadata["diff"].(string)
  197. formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
  198. body = strings.TrimSpace(formattedDiff)
  199. return style.Render(lipgloss.JoinVertical(lipgloss.Left,
  200. title,
  201. body,
  202. styles.ForceReplaceBackgroundWithLipgloss(footer, t.Background()),
  203. ))
  204. }
  205. case "opencode_read":
  206. toolArgs = renderArgs(&toolArgsMap, appInfo, "filePath")
  207. title = fmt.Sprintf("%s: %s", toolName, toolArgs)
  208. filename := toolArgsMap["filePath"].(string)
  209. ext := filepath.Ext(filename)
  210. if ext == "" {
  211. ext = ""
  212. } else {
  213. ext = strings.ToLower(ext[1:])
  214. }
  215. if finished {
  216. if metadata["preview"] != nil {
  217. body = metadata["preview"].(string)
  218. }
  219. body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(body, 10))
  220. body = toMarkdown(body, width)
  221. }
  222. case "opencode_write":
  223. filename := toolArgsMap["filePath"].(string)
  224. filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
  225. title = fmt.Sprintf("%s: %s", toolName, filename)
  226. ext := filepath.Ext(filename)
  227. if ext == "" {
  228. ext = ""
  229. } else {
  230. ext = strings.ToLower(ext[1:])
  231. }
  232. content := toolArgsMap["content"].(string)
  233. body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(content, 10))
  234. body = toMarkdown(body, width)
  235. case "opencode_bash":
  236. if finished && metadata["stdout"] != nil {
  237. description := toolArgsMap["description"].(string)
  238. title = fmt.Sprintf("%s: %s", toolName, description)
  239. command := toolArgsMap["command"].(string)
  240. stdout := metadata["stdout"].(string)
  241. body = fmt.Sprintf("```console\n$ %s\n%s```", command, stdout)
  242. body = toMarkdown(body, width)
  243. }
  244. case "opencode_todoread":
  245. title = fmt.Sprintf("%s", toolName)
  246. if finished && metadata["todos"] != nil {
  247. body = ""
  248. todos := metadata["todos"].([]any)
  249. for _, todo := range todos {
  250. t := todo.(map[string]any)
  251. content := t["content"].(string)
  252. switch t["status"].(string) {
  253. case "completed":
  254. body += fmt.Sprintf("- [x] %s\n", content)
  255. // case "in-progress":
  256. // body += fmt.Sprintf("- [ ] _%s_\n", content)
  257. default:
  258. body += fmt.Sprintf("- [ ] %s\n", content)
  259. }
  260. }
  261. body = toMarkdown(body, width)
  262. }
  263. case "opencode_todowrite":
  264. title = fmt.Sprintf("%s", toolName)
  265. if finished && metadata["todos"] != nil {
  266. body = ""
  267. todos := metadata["todos"].([]any)
  268. for _, todo := range todos {
  269. t := todo.(map[string]any)
  270. content := t["content"].(string)
  271. switch t["status"].(string) {
  272. case "completed":
  273. body += fmt.Sprintf("- [x] %s\n", content)
  274. // case "in-progress":
  275. // body += fmt.Sprintf("- [ ] _%s_\n", content)
  276. default:
  277. body += fmt.Sprintf("- [ ] %s\n", content)
  278. }
  279. }
  280. body = toMarkdown(body, width)
  281. }
  282. default:
  283. body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10))
  284. body = toMarkdown(body, width)
  285. }
  286. if metadata["error"] != nil && metadata["message"] != nil {
  287. body = styles.BaseStyle().Foreground(t.Error()).Render(metadata["message"].(string))
  288. }
  289. content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
  290. title,
  291. body,
  292. footer,
  293. ))
  294. return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
  295. }
  296. func renderToolName(name string) string {
  297. switch name {
  298. // case agent.AgentToolName:
  299. // return "Task"
  300. case "opencode_ls":
  301. return "List"
  302. case "opencode_webfetch":
  303. return "Fetch"
  304. case "opencode_todoread":
  305. return "Read TODOs"
  306. case "opencode_todowrite":
  307. return "Update TODOs"
  308. default:
  309. normalizedName := name
  310. if strings.HasPrefix(name, "opencode_") {
  311. normalizedName = strings.TrimPrefix(name, "opencode_")
  312. }
  313. return cases.Title(language.Und).String(normalizedName)
  314. }
  315. }
  316. func renderToolAction(name string) string {
  317. switch name {
  318. // case agent.AgentToolName:
  319. // return "Preparing prompt..."
  320. case "opencode_bash":
  321. return "Building command..."
  322. case "opencode_edit":
  323. return "Preparing edit..."
  324. case "opencode_fetch":
  325. return "Writing fetch..."
  326. case "opencode_glob":
  327. return "Finding files..."
  328. case "opencode_grep":
  329. return "Searching content..."
  330. case "opencode_ls":
  331. return "Listing directory..."
  332. case "opencode_read":
  333. return "Reading file..."
  334. case "opencode_write":
  335. return "Preparing write..."
  336. case "opencode_patch":
  337. return "Preparing patch..."
  338. case "opencode_batch":
  339. return "Running batch operations..."
  340. }
  341. return "Working..."
  342. }
  343. func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) string {
  344. if args == nil || len(*args) == 0 {
  345. return ""
  346. }
  347. title := ""
  348. parts := []string{}
  349. for key, value := range *args {
  350. if key == "filePath" || key == "path" {
  351. value = strings.TrimPrefix(value.(string), appInfo.Path.Root+"/")
  352. }
  353. if key == titleKey {
  354. title = fmt.Sprintf("%s", value)
  355. continue
  356. }
  357. parts = append(parts, fmt.Sprintf("%s=%v", key, value))
  358. }
  359. if len(parts) == 0 {
  360. return title
  361. }
  362. return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
  363. }
  364. func truncateHeight(content string, height int) string {
  365. lines := strings.Split(content, "\n")
  366. if len(lines) > height {
  367. return strings.Join(lines[:height], "\n")
  368. }
  369. return content
  370. }