message.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  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 "cancelled":
  384. body += fmt.Sprintf("- [~] %s\n", content)
  385. // case "in-progress":
  386. // body += fmt.Sprintf("- [ ] %s\n", content)
  387. default:
  388. body += fmt.Sprintf("- [ ] %s\n", content)
  389. }
  390. }
  391. body = util.ToMarkdown(body, width, backgroundColor)
  392. }
  393. case "task":
  394. summary := metadata["summary"]
  395. if summary != nil {
  396. toolcalls := summary.([]any)
  397. steps := []string{}
  398. for _, toolcall := range toolcalls {
  399. call := toolcall.(map[string]any)
  400. if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
  401. data, _ := json.Marshal(toolInvocation)
  402. var toolCall opencode.ToolPart
  403. _ = json.Unmarshal(data, &toolCall)
  404. step := renderToolTitle(toolCall, width)
  405. step = "∟ " + step
  406. steps = append(steps, step)
  407. }
  408. }
  409. body = strings.Join(steps, "\n")
  410. }
  411. default:
  412. if result == nil {
  413. empty := ""
  414. result = &empty
  415. }
  416. body = *result
  417. body = util.TruncateHeight(body, 10)
  418. }
  419. }
  420. error := ""
  421. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  422. error = toolCall.State.Error
  423. }
  424. if error != "" {
  425. body = styles.NewStyle().
  426. Foreground(t.Error()).
  427. Background(backgroundColor).
  428. Render(error)
  429. }
  430. if body == "" && error == "" && result != nil {
  431. body = *result
  432. body = util.TruncateHeight(body, 10)
  433. }
  434. title := renderToolTitle(toolCall, width)
  435. content := title + "\n\n" + body
  436. return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
  437. }
  438. func renderToolName(name string) string {
  439. switch name {
  440. case "webfetch":
  441. return "Fetch"
  442. case "todowrite", "todoread":
  443. return "Plan"
  444. default:
  445. normalizedName := name
  446. if after, ok := strings.CutPrefix(name, "opencode_"); ok {
  447. normalizedName = after
  448. }
  449. return cases.Title(language.Und).String(normalizedName)
  450. }
  451. }
  452. func renderToolTitle(
  453. toolCall opencode.ToolPart,
  454. width int,
  455. ) string {
  456. // TODO: handle truncate to width
  457. if toolCall.State.Status == opencode.ToolPartStateStatusPending {
  458. return renderToolAction(toolCall.Tool)
  459. }
  460. toolArgs := ""
  461. toolArgsMap := make(map[string]any)
  462. if toolCall.State.Input != nil {
  463. value := toolCall.State.Input
  464. if m, ok := value.(map[string]any); ok {
  465. toolArgsMap = m
  466. keys := make([]string, 0, len(toolArgsMap))
  467. for key := range toolArgsMap {
  468. keys = append(keys, key)
  469. }
  470. slices.Sort(keys)
  471. firstKey := ""
  472. if len(keys) > 0 {
  473. firstKey = keys[0]
  474. }
  475. toolArgs = renderArgs(&toolArgsMap, firstKey)
  476. }
  477. }
  478. title := renderToolName(toolCall.Tool)
  479. switch toolCall.Tool {
  480. case "read":
  481. toolArgs = renderArgs(&toolArgsMap, "filePath")
  482. title = fmt.Sprintf("%s %s", title, toolArgs)
  483. case "edit", "write":
  484. if filename, ok := toolArgsMap["filePath"].(string); ok {
  485. title = fmt.Sprintf("%s %s", title, util.Relative(filename))
  486. }
  487. case "bash", "task":
  488. if description, ok := toolArgsMap["description"].(string); ok {
  489. title = fmt.Sprintf("%s %s", title, description)
  490. }
  491. case "webfetch":
  492. toolArgs = renderArgs(&toolArgsMap, "url")
  493. title = fmt.Sprintf("%s %s", title, toolArgs)
  494. case "todowrite", "todoread":
  495. // title is just the tool name
  496. default:
  497. toolName := renderToolName(toolCall.Tool)
  498. title = fmt.Sprintf("%s %s", toolName, toolArgs)
  499. }
  500. return title
  501. }
  502. func renderToolAction(name string) string {
  503. switch name {
  504. case "task":
  505. return "Searching..."
  506. case "bash":
  507. return "Writing command..."
  508. case "edit":
  509. return "Preparing edit..."
  510. case "webfetch":
  511. return "Fetching from the web..."
  512. case "glob":
  513. return "Finding files..."
  514. case "grep":
  515. return "Searching content..."
  516. case "list":
  517. return "Listing directory..."
  518. case "read":
  519. return "Reading file..."
  520. case "write":
  521. return "Preparing write..."
  522. case "todowrite", "todoread":
  523. return "Planning..."
  524. case "patch":
  525. return "Preparing patch..."
  526. }
  527. return "Working..."
  528. }
  529. func renderArgs(args *map[string]any, titleKey string) string {
  530. if args == nil || len(*args) == 0 {
  531. return ""
  532. }
  533. keys := make([]string, 0, len(*args))
  534. for key := range *args {
  535. keys = append(keys, key)
  536. }
  537. slices.Sort(keys)
  538. title := ""
  539. parts := []string{}
  540. for _, key := range keys {
  541. value := (*args)[key]
  542. if value == nil {
  543. continue
  544. }
  545. if key == "filePath" || key == "path" {
  546. value = util.Relative(value.(string))
  547. }
  548. if key == titleKey {
  549. title = fmt.Sprintf("%s", value)
  550. continue
  551. }
  552. parts = append(parts, fmt.Sprintf("%s=%v", key, value))
  553. }
  554. if len(parts) == 0 {
  555. return title
  556. }
  557. return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
  558. }
  559. // Diagnostic represents an LSP diagnostic
  560. type Diagnostic struct {
  561. Range struct {
  562. Start struct {
  563. Line int `json:"line"`
  564. Character int `json:"character"`
  565. } `json:"start"`
  566. } `json:"range"`
  567. Severity int `json:"severity"`
  568. Message string `json:"message"`
  569. }
  570. // renderDiagnostics formats LSP diagnostics for display in the TUI
  571. func renderDiagnostics(metadata map[string]any, filePath string) string {
  572. if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
  573. if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
  574. var errorDiagnostics []string
  575. for _, diagInterface := range fileDiagnostics {
  576. diagMap, ok := diagInterface.(map[string]any)
  577. if !ok {
  578. continue
  579. }
  580. // Parse the diagnostic
  581. var diag Diagnostic
  582. diagBytes, err := json.Marshal(diagMap)
  583. if err != nil {
  584. continue
  585. }
  586. if err := json.Unmarshal(diagBytes, &diag); err != nil {
  587. continue
  588. }
  589. // Only show error diagnostics (severity === 1)
  590. if diag.Severity != 1 {
  591. continue
  592. }
  593. line := diag.Range.Start.Line + 1 // 1-based
  594. column := diag.Range.Start.Character + 1 // 1-based
  595. errorDiagnostics = append(
  596. errorDiagnostics,
  597. fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
  598. )
  599. }
  600. if len(errorDiagnostics) == 0 {
  601. return ""
  602. }
  603. t := theme.CurrentTheme()
  604. var result strings.Builder
  605. for _, diagnostic := range errorDiagnostics {
  606. if result.Len() > 0 {
  607. result.WriteString("\n")
  608. }
  609. result.WriteString(styles.NewStyle().Foreground(t.Error()).Render(diagnostic))
  610. }
  611. return result.String()
  612. }
  613. }
  614. return ""
  615. // diagnosticsData should be a map[string][]Diagnostic
  616. // strDiagnosticsData := diagnosticsData.Raw()
  617. // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
  618. // fileDiagnostics, ok := diagnosticsMap[filePath]
  619. // if !ok {
  620. // return ""
  621. // }
  622. // diagnosticsList, ok := fileDiagnostics.([]any)
  623. // if !ok {
  624. // return ""
  625. // }
  626. }