message.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. package chat
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "slices"
  6. "strings"
  7. "time"
  8. "github.com/charmbracelet/lipgloss/v2"
  9. "github.com/charmbracelet/lipgloss/v2/compat"
  10. "github.com/sst/opencode-sdk-go"
  11. "github.com/sst/opencode/internal/app"
  12. "github.com/sst/opencode/internal/commands"
  13. "github.com/sst/opencode/internal/components/diff"
  14. "github.com/sst/opencode/internal/layout"
  15. "github.com/sst/opencode/internal/styles"
  16. "github.com/sst/opencode/internal/theme"
  17. "github.com/sst/opencode/internal/util"
  18. "golang.org/x/text/cases"
  19. "golang.org/x/text/language"
  20. )
  21. type blockRenderer struct {
  22. textColor compat.AdaptiveColor
  23. border bool
  24. borderColor *compat.AdaptiveColor
  25. borderColorRight bool
  26. paddingTop int
  27. paddingBottom int
  28. paddingLeft int
  29. paddingRight int
  30. marginTop int
  31. marginBottom int
  32. }
  33. type renderingOption func(*blockRenderer)
  34. func WithTextColor(color compat.AdaptiveColor) renderingOption {
  35. return func(c *blockRenderer) {
  36. c.textColor = color
  37. }
  38. }
  39. func WithNoBorder() renderingOption {
  40. return func(c *blockRenderer) {
  41. c.border = false
  42. }
  43. }
  44. func WithBorderColor(color compat.AdaptiveColor) renderingOption {
  45. return func(c *blockRenderer) {
  46. c.borderColor = &color
  47. }
  48. }
  49. func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
  50. return func(c *blockRenderer) {
  51. c.borderColorRight = true
  52. c.borderColor = &color
  53. }
  54. }
  55. func WithMarginTop(padding int) renderingOption {
  56. return func(c *blockRenderer) {
  57. c.marginTop = padding
  58. }
  59. }
  60. func WithMarginBottom(padding int) renderingOption {
  61. return func(c *blockRenderer) {
  62. c.marginBottom = padding
  63. }
  64. }
  65. func WithPadding(padding int) renderingOption {
  66. return func(c *blockRenderer) {
  67. c.paddingTop = padding
  68. c.paddingBottom = padding
  69. c.paddingLeft = padding
  70. c.paddingRight = padding
  71. }
  72. }
  73. func WithPaddingLeft(padding int) renderingOption {
  74. return func(c *blockRenderer) {
  75. c.paddingLeft = padding
  76. }
  77. }
  78. func WithPaddingRight(padding int) renderingOption {
  79. return func(c *blockRenderer) {
  80. c.paddingRight = padding
  81. }
  82. }
  83. func WithPaddingTop(padding int) renderingOption {
  84. return func(c *blockRenderer) {
  85. c.paddingTop = padding
  86. }
  87. }
  88. func WithPaddingBottom(padding int) renderingOption {
  89. return func(c *blockRenderer) {
  90. c.paddingBottom = padding
  91. }
  92. }
  93. func renderContentBlock(
  94. app *app.App,
  95. content string,
  96. highlight bool,
  97. width int,
  98. options ...renderingOption,
  99. ) string {
  100. t := theme.CurrentTheme()
  101. renderer := &blockRenderer{
  102. textColor: t.TextMuted(),
  103. border: true,
  104. paddingTop: 1,
  105. paddingBottom: 1,
  106. paddingLeft: 2,
  107. paddingRight: 2,
  108. }
  109. for _, option := range options {
  110. option(renderer)
  111. }
  112. borderColor := t.BackgroundPanel()
  113. if renderer.borderColor != nil {
  114. borderColor = *renderer.borderColor
  115. }
  116. style := styles.NewStyle().
  117. Foreground(renderer.textColor).
  118. Background(t.BackgroundPanel()).
  119. Width(width).
  120. PaddingTop(renderer.paddingTop).
  121. PaddingBottom(renderer.paddingBottom).
  122. PaddingLeft(renderer.paddingLeft).
  123. PaddingRight(renderer.paddingRight).
  124. AlignHorizontal(lipgloss.Left)
  125. if renderer.border {
  126. style = style.
  127. BorderStyle(lipgloss.ThickBorder()).
  128. BorderLeft(true).
  129. BorderRight(true).
  130. BorderLeftForeground(borderColor).
  131. BorderLeftBackground(t.Background()).
  132. BorderRightForeground(t.BackgroundPanel()).
  133. BorderRightBackground(t.Background())
  134. if renderer.borderColorRight {
  135. style = style.
  136. BorderLeftBackground(t.Background()).
  137. BorderLeftForeground(t.BackgroundPanel()).
  138. BorderRightForeground(borderColor).
  139. BorderRightBackground(t.Background())
  140. }
  141. if highlight {
  142. style = style.
  143. BorderLeftForeground(borderColor).
  144. BorderRightForeground(borderColor)
  145. }
  146. }
  147. if highlight {
  148. style = style.
  149. Foreground(t.Text()).
  150. Background(t.BackgroundElement()).
  151. Bold(true)
  152. }
  153. content = style.Render(content)
  154. if renderer.marginTop > 0 {
  155. for range renderer.marginTop {
  156. content = "\n" + content
  157. }
  158. }
  159. if renderer.marginBottom > 0 {
  160. for range renderer.marginBottom {
  161. content = content + "\n"
  162. }
  163. }
  164. if highlight {
  165. copy := app.Key(commands.MessagesCopyCommand)
  166. // revert := app.Key(commands.MessagesRevertCommand)
  167. background := t.Background()
  168. header := layout.Render(
  169. layout.FlexOptions{
  170. Background: &background,
  171. Direction: layout.Row,
  172. Justify: layout.JustifyCenter,
  173. Align: layout.AlignStretch,
  174. Width: width - 2,
  175. Gap: 5,
  176. },
  177. layout.FlexItem{
  178. View: copy,
  179. },
  180. // layout.FlexItem{
  181. // View: revert,
  182. // },
  183. )
  184. header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
  185. content = "\n\n\n" + header + "\n\n" + content + "\n\n\n"
  186. }
  187. return content
  188. }
  189. func renderText(
  190. app *app.App,
  191. message opencode.MessageUnion,
  192. text string,
  193. author string,
  194. showToolDetails bool,
  195. highlight bool,
  196. width int,
  197. extra string,
  198. toolCalls ...opencode.ToolPart,
  199. ) string {
  200. t := theme.CurrentTheme()
  201. var ts time.Time
  202. backgroundColor := t.BackgroundPanel()
  203. if highlight {
  204. backgroundColor = t.BackgroundElement()
  205. }
  206. messageStyle := styles.NewStyle().Background(backgroundColor)
  207. content := messageStyle.Render(text)
  208. switch casted := message.(type) {
  209. case opencode.AssistantMessage:
  210. ts = time.UnixMilli(int64(casted.Time.Created))
  211. content = util.ToMarkdown(text, width, backgroundColor)
  212. case opencode.UserMessage:
  213. ts = time.UnixMilli(int64(casted.Time.Created))
  214. messageStyle = messageStyle.Width(width - 6)
  215. }
  216. timestamp := ts.
  217. Local().
  218. Format("02 Jan 2006 03:04 PM")
  219. if time.Now().Format("02 Jan 2006") == timestamp[:11] {
  220. // don't show the date if it's today
  221. timestamp = timestamp[12:]
  222. }
  223. info := fmt.Sprintf("%s (%s)", author, timestamp)
  224. info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
  225. if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
  226. content = content + "\n\n"
  227. for _, toolCall := range toolCalls {
  228. title := renderToolTitle(toolCall, width)
  229. style := styles.NewStyle()
  230. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  231. style = style.Foreground(t.Error())
  232. }
  233. title = style.Render(title)
  234. title = "∟ " + title + "\n"
  235. content = content + title
  236. }
  237. }
  238. sections := []string{content, info}
  239. if extra != "" {
  240. sections = append(sections, "\n"+extra)
  241. }
  242. content = strings.Join(sections, "\n")
  243. switch message.(type) {
  244. case opencode.UserMessage:
  245. return renderContentBlock(
  246. app,
  247. content,
  248. highlight,
  249. width,
  250. WithTextColor(t.Text()),
  251. WithBorderColorRight(t.Secondary()),
  252. )
  253. case opencode.AssistantMessage:
  254. return renderContentBlock(
  255. app,
  256. content,
  257. highlight,
  258. width,
  259. WithBorderColor(t.Accent()),
  260. )
  261. }
  262. return ""
  263. }
  264. func renderToolDetails(
  265. app *app.App,
  266. toolCall opencode.ToolPart,
  267. highlight bool,
  268. width int,
  269. ) string {
  270. ignoredTools := []string{"todoread"}
  271. if slices.Contains(ignoredTools, toolCall.Tool) {
  272. return ""
  273. }
  274. if toolCall.State.Status == opencode.ToolPartStateStatusPending || toolCall.State.Status == opencode.ToolPartStateStatusRunning {
  275. title := renderToolTitle(toolCall, width)
  276. return renderContentBlock(app, title, highlight, width)
  277. }
  278. var result *string
  279. if toolCall.State.Output != "" {
  280. result = &toolCall.State.Output
  281. }
  282. toolInputMap := make(map[string]any)
  283. if toolCall.State.Input != nil {
  284. value := toolCall.State.Input
  285. if m, ok := value.(map[string]any); ok {
  286. toolInputMap = m
  287. keys := make([]string, 0, len(toolInputMap))
  288. for key := range toolInputMap {
  289. keys = append(keys, key)
  290. }
  291. slices.Sort(keys)
  292. }
  293. }
  294. body := ""
  295. t := theme.CurrentTheme()
  296. backgroundColor := t.BackgroundPanel()
  297. borderColor := t.BackgroundPanel()
  298. if highlight {
  299. backgroundColor = t.BackgroundElement()
  300. borderColor = t.BorderActive()
  301. }
  302. if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
  303. metadata := toolCall.State.Metadata.(map[string]any)
  304. switch toolCall.Tool {
  305. case "read":
  306. preview := metadata["preview"]
  307. if preview != nil && toolInputMap["filePath"] != nil {
  308. filename := toolInputMap["filePath"].(string)
  309. body = preview.(string)
  310. body = util.RenderFile(filename, body, width, util.WithTruncate(6))
  311. }
  312. case "edit":
  313. if filename, ok := toolInputMap["filePath"].(string); ok {
  314. diffField := metadata["diff"]
  315. if diffField != nil {
  316. patch := diffField.(string)
  317. var formattedDiff string
  318. formattedDiff, _ = diff.FormatUnifiedDiff(
  319. filename,
  320. patch,
  321. diff.WithWidth(width-2),
  322. )
  323. body = strings.TrimSpace(formattedDiff)
  324. style := styles.NewStyle().
  325. Background(backgroundColor).
  326. Foreground(t.TextMuted()).
  327. Padding(1, 2).
  328. Width(width - 4)
  329. if highlight {
  330. style = style.Foreground(t.Text()).Bold(true)
  331. }
  332. if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
  333. diagnostics = style.Render(diagnostics)
  334. body += "\n" + diagnostics
  335. }
  336. title := renderToolTitle(toolCall, width)
  337. title = style.Render(title)
  338. content := title + "\n" + body
  339. content = renderContentBlock(
  340. app,
  341. content,
  342. highlight,
  343. width,
  344. WithPadding(0),
  345. WithBorderColor(borderColor),
  346. )
  347. return content
  348. }
  349. }
  350. case "write":
  351. if filename, ok := toolInputMap["filePath"].(string); ok {
  352. if content, ok := toolInputMap["content"].(string); ok {
  353. body = util.RenderFile(filename, content, width)
  354. if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
  355. body += "\n\n" + diagnostics
  356. }
  357. }
  358. }
  359. case "bash":
  360. stdout := metadata["stdout"]
  361. if stdout != nil {
  362. command := toolInputMap["command"].(string)
  363. body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
  364. body = util.ToMarkdown(body, width, backgroundColor)
  365. }
  366. case "webfetch":
  367. if format, ok := toolInputMap["format"].(string); ok && result != nil {
  368. body = *result
  369. body = util.TruncateHeight(body, 10)
  370. if format == "html" || format == "markdown" {
  371. body = util.ToMarkdown(body, width, backgroundColor)
  372. }
  373. }
  374. case "todowrite":
  375. todos := metadata["todos"]
  376. if todos != nil {
  377. for _, item := range todos.([]any) {
  378. todo := item.(map[string]any)
  379. content := todo["content"].(string)
  380. switch todo["status"] {
  381. case "completed":
  382. body += fmt.Sprintf("- [x] %s\n", content)
  383. // case "in-progress":
  384. // body += fmt.Sprintf("- [ ] %s\n", content)
  385. default:
  386. body += fmt.Sprintf("- [ ] %s\n", content)
  387. }
  388. }
  389. body = util.ToMarkdown(body, width, backgroundColor)
  390. }
  391. case "task":
  392. summary := metadata["summary"]
  393. if summary != nil {
  394. toolcalls := summary.([]any)
  395. steps := []string{}
  396. for _, toolcall := range toolcalls {
  397. call := toolcall.(map[string]any)
  398. if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
  399. data, _ := json.Marshal(toolInvocation)
  400. var toolCall opencode.ToolPart
  401. _ = json.Unmarshal(data, &toolCall)
  402. step := renderToolTitle(toolCall, width)
  403. step = "∟ " + step
  404. steps = append(steps, step)
  405. }
  406. }
  407. body = strings.Join(steps, "\n")
  408. }
  409. default:
  410. if result == nil {
  411. empty := ""
  412. result = &empty
  413. }
  414. body = *result
  415. body = util.TruncateHeight(body, 10)
  416. }
  417. }
  418. error := ""
  419. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  420. error = toolCall.State.Error
  421. }
  422. if error != "" {
  423. body = styles.NewStyle().
  424. Foreground(t.Error()).
  425. Background(backgroundColor).
  426. Render(error)
  427. }
  428. if body == "" && error == "" && result != nil {
  429. body = *result
  430. body = util.TruncateHeight(body, 10)
  431. }
  432. title := renderToolTitle(toolCall, width)
  433. content := title + "\n\n" + body
  434. return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
  435. }
  436. func renderToolName(name string) string {
  437. switch name {
  438. case "webfetch":
  439. return "Fetch"
  440. case "todowrite", "todoread":
  441. return "Plan"
  442. default:
  443. normalizedName := name
  444. if after, ok := strings.CutPrefix(name, "opencode_"); ok {
  445. normalizedName = after
  446. }
  447. return cases.Title(language.Und).String(normalizedName)
  448. }
  449. }
  450. func renderToolTitle(
  451. toolCall opencode.ToolPart,
  452. width int,
  453. ) string {
  454. // TODO: handle truncate to width
  455. if toolCall.State.Status == opencode.ToolPartStateStatusPending {
  456. return renderToolAction(toolCall.Tool)
  457. }
  458. toolArgs := ""
  459. toolArgsMap := make(map[string]any)
  460. if toolCall.State.Input != nil {
  461. value := toolCall.State.Input
  462. if m, ok := value.(map[string]any); ok {
  463. toolArgsMap = m
  464. keys := make([]string, 0, len(toolArgsMap))
  465. for key := range toolArgsMap {
  466. keys = append(keys, key)
  467. }
  468. slices.Sort(keys)
  469. firstKey := ""
  470. if len(keys) > 0 {
  471. firstKey = keys[0]
  472. }
  473. toolArgs = renderArgs(&toolArgsMap, firstKey)
  474. }
  475. }
  476. title := renderToolName(toolCall.Tool)
  477. switch toolCall.Tool {
  478. case "read":
  479. toolArgs = renderArgs(&toolArgsMap, "filePath")
  480. title = fmt.Sprintf("%s %s", title, toolArgs)
  481. case "edit", "write":
  482. if filename, ok := toolArgsMap["filePath"].(string); ok {
  483. title = fmt.Sprintf("%s %s", title, util.Relative(filename))
  484. }
  485. case "bash", "task":
  486. if description, ok := toolArgsMap["description"].(string); ok {
  487. title = fmt.Sprintf("%s %s", title, description)
  488. }
  489. case "webfetch":
  490. toolArgs = renderArgs(&toolArgsMap, "url")
  491. title = fmt.Sprintf("%s %s", title, toolArgs)
  492. case "todowrite", "todoread":
  493. // title is just the tool name
  494. default:
  495. toolName := renderToolName(toolCall.Tool)
  496. title = fmt.Sprintf("%s %s", toolName, toolArgs)
  497. }
  498. return title
  499. }
  500. func renderToolAction(name string) string {
  501. switch name {
  502. case "task":
  503. return "Searching..."
  504. case "bash":
  505. return "Writing command..."
  506. case "edit":
  507. return "Preparing edit..."
  508. case "webfetch":
  509. return "Fetching from the web..."
  510. case "glob":
  511. return "Finding files..."
  512. case "grep":
  513. return "Searching content..."
  514. case "list":
  515. return "Listing directory..."
  516. case "read":
  517. return "Reading file..."
  518. case "write":
  519. return "Preparing write..."
  520. case "todowrite", "todoread":
  521. return "Planning..."
  522. case "patch":
  523. return "Preparing patch..."
  524. }
  525. return "Working..."
  526. }
  527. func renderArgs(args *map[string]any, titleKey string) string {
  528. if args == nil || len(*args) == 0 {
  529. return ""
  530. }
  531. keys := make([]string, 0, len(*args))
  532. for key := range *args {
  533. keys = append(keys, key)
  534. }
  535. slices.Sort(keys)
  536. title := ""
  537. parts := []string{}
  538. for _, key := range keys {
  539. value := (*args)[key]
  540. if value == nil {
  541. continue
  542. }
  543. if key == "filePath" || key == "path" {
  544. value = util.Relative(value.(string))
  545. }
  546. if key == titleKey {
  547. title = fmt.Sprintf("%s", value)
  548. continue
  549. }
  550. parts = append(parts, fmt.Sprintf("%s=%v", key, value))
  551. }
  552. if len(parts) == 0 {
  553. return title
  554. }
  555. return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
  556. }
  557. // Diagnostic represents an LSP diagnostic
  558. type Diagnostic struct {
  559. Range struct {
  560. Start struct {
  561. Line int `json:"line"`
  562. Character int `json:"character"`
  563. } `json:"start"`
  564. } `json:"range"`
  565. Severity int `json:"severity"`
  566. Message string `json:"message"`
  567. }
  568. // renderDiagnostics formats LSP diagnostics for display in the TUI
  569. func renderDiagnostics(metadata map[string]any, filePath string) string {
  570. if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
  571. if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
  572. var errorDiagnostics []string
  573. for _, diagInterface := range fileDiagnostics {
  574. diagMap, ok := diagInterface.(map[string]any)
  575. if !ok {
  576. continue
  577. }
  578. // Parse the diagnostic
  579. var diag Diagnostic
  580. diagBytes, err := json.Marshal(diagMap)
  581. if err != nil {
  582. continue
  583. }
  584. if err := json.Unmarshal(diagBytes, &diag); err != nil {
  585. continue
  586. }
  587. // Only show error diagnostics (severity === 1)
  588. if diag.Severity != 1 {
  589. continue
  590. }
  591. line := diag.Range.Start.Line + 1 // 1-based
  592. column := diag.Range.Start.Character + 1 // 1-based
  593. errorDiagnostics = append(
  594. errorDiagnostics,
  595. fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
  596. )
  597. }
  598. if len(errorDiagnostics) == 0 {
  599. return ""
  600. }
  601. t := theme.CurrentTheme()
  602. var result strings.Builder
  603. for _, diagnostic := range errorDiagnostics {
  604. if result.Len() > 0 {
  605. result.WriteString("\n")
  606. }
  607. result.WriteString(styles.NewStyle().Foreground(t.Error()).Render(diagnostic))
  608. }
  609. return result.String()
  610. }
  611. }
  612. return ""
  613. // diagnosticsData should be a map[string][]Diagnostic
  614. // strDiagnosticsData := diagnosticsData.Raw()
  615. // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
  616. // fileDiagnostics, ok := diagnosticsMap[filePath]
  617. // if !ok {
  618. // return ""
  619. // }
  620. // diagnosticsList, ok := fileDiagnostics.([]any)
  621. // if !ok {
  622. // return ""
  623. // }
  624. }