message.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793
  1. package chat
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "path/filepath"
  6. "slices"
  7. "strings"
  8. "time"
  9. "unicode"
  10. "github.com/charmbracelet/lipgloss/v2"
  11. "github.com/charmbracelet/lipgloss/v2/compat"
  12. "github.com/charmbracelet/x/ansi"
  13. "github.com/sst/opencode-sdk-go"
  14. "github.com/sst/opencode/internal/app"
  15. "github.com/sst/opencode/internal/components/diff"
  16. "github.com/sst/opencode/internal/layout"
  17. "github.com/sst/opencode/internal/styles"
  18. "github.com/sst/opencode/internal/theme"
  19. "github.com/tidwall/gjson"
  20. "golang.org/x/text/cases"
  21. "golang.org/x/text/language"
  22. )
  23. func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
  24. r := styles.GetMarkdownRenderer(width, backgroundColor)
  25. content = strings.ReplaceAll(content, app.RootPath+"/", "")
  26. rendered, _ := r.Render(content)
  27. lines := strings.Split(rendered, "\n")
  28. if len(lines) > 0 {
  29. firstLine := lines[0]
  30. cleaned := ansi.Strip(firstLine)
  31. nospace := strings.ReplaceAll(cleaned, " ", "")
  32. if nospace == "" {
  33. lines = lines[1:]
  34. }
  35. if len(lines) > 0 {
  36. lastLine := lines[len(lines)-1]
  37. cleaned = ansi.Strip(lastLine)
  38. nospace = strings.ReplaceAll(cleaned, " ", "")
  39. if nospace == "" {
  40. lines = lines[:len(lines)-1]
  41. }
  42. }
  43. }
  44. content = strings.Join(lines, "\n")
  45. return strings.TrimSuffix(content, "\n")
  46. }
  47. type blockRenderer struct {
  48. align *lipgloss.Position
  49. borderColor *compat.AdaptiveColor
  50. fullWidth bool
  51. paddingTop int
  52. paddingBottom int
  53. paddingLeft int
  54. paddingRight int
  55. marginTop int
  56. marginBottom int
  57. }
  58. type renderingOption func(*blockRenderer)
  59. func WithFullWidth() renderingOption {
  60. return func(c *blockRenderer) {
  61. c.fullWidth = true
  62. }
  63. }
  64. func WithAlign(align lipgloss.Position) renderingOption {
  65. return func(c *blockRenderer) {
  66. c.align = &align
  67. }
  68. }
  69. func WithBorderColor(color compat.AdaptiveColor) renderingOption {
  70. return func(c *blockRenderer) {
  71. c.borderColor = &color
  72. }
  73. }
  74. func WithMarginTop(padding int) renderingOption {
  75. return func(c *blockRenderer) {
  76. c.marginTop = padding
  77. }
  78. }
  79. func WithMarginBottom(padding int) renderingOption {
  80. return func(c *blockRenderer) {
  81. c.marginBottom = padding
  82. }
  83. }
  84. func WithPaddingLeft(padding int) renderingOption {
  85. return func(c *blockRenderer) {
  86. c.paddingLeft = padding
  87. }
  88. }
  89. func WithPaddingRight(padding int) renderingOption {
  90. return func(c *blockRenderer) {
  91. c.paddingRight = padding
  92. }
  93. }
  94. func WithPaddingTop(padding int) renderingOption {
  95. return func(c *blockRenderer) {
  96. c.paddingTop = padding
  97. }
  98. }
  99. func WithPaddingBottom(padding int) renderingOption {
  100. return func(c *blockRenderer) {
  101. c.paddingBottom = padding
  102. }
  103. }
  104. func renderContentBlock(content string, options ...renderingOption) string {
  105. t := theme.CurrentTheme()
  106. renderer := &blockRenderer{
  107. fullWidth: false,
  108. paddingTop: 1,
  109. paddingBottom: 1,
  110. paddingLeft: 2,
  111. paddingRight: 2,
  112. }
  113. for _, option := range options {
  114. option(renderer)
  115. }
  116. style := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).
  117. // MarginTop(renderer.marginTop).
  118. // MarginBottom(renderer.marginBottom).
  119. PaddingTop(renderer.paddingTop).
  120. PaddingBottom(renderer.paddingBottom).
  121. PaddingLeft(renderer.paddingLeft).
  122. PaddingRight(renderer.paddingRight).
  123. BorderStyle(lipgloss.ThickBorder())
  124. align := lipgloss.Left
  125. if renderer.align != nil {
  126. align = *renderer.align
  127. }
  128. borderColor := t.BackgroundPanel()
  129. if renderer.borderColor != nil {
  130. borderColor = *renderer.borderColor
  131. }
  132. switch align {
  133. case lipgloss.Left:
  134. style = style.
  135. BorderLeft(true).
  136. BorderRight(true).
  137. AlignHorizontal(align).
  138. BorderLeftForeground(borderColor).
  139. BorderLeftBackground(t.Background()).
  140. BorderRightForeground(t.BackgroundPanel()).
  141. BorderRightBackground(t.Background())
  142. case lipgloss.Right:
  143. style = style.
  144. BorderRight(true).
  145. BorderLeft(true).
  146. AlignHorizontal(align).
  147. BorderRightForeground(borderColor).
  148. BorderRightBackground(t.Background()).
  149. BorderLeftForeground(t.BackgroundPanel()).
  150. BorderLeftBackground(t.Background())
  151. }
  152. if renderer.fullWidth {
  153. style = style.Width(layout.Current.Container.Width)
  154. }
  155. content = style.Render(content)
  156. content = lipgloss.PlaceHorizontal(
  157. layout.Current.Container.Width,
  158. align,
  159. content,
  160. styles.WhitespaceStyle(t.Background()),
  161. )
  162. content = lipgloss.PlaceHorizontal(
  163. layout.Current.Viewport.Width,
  164. lipgloss.Center,
  165. content,
  166. styles.WhitespaceStyle(t.Background()),
  167. )
  168. if renderer.marginTop > 0 {
  169. for range renderer.marginTop {
  170. content = "\n" + content
  171. }
  172. }
  173. if renderer.marginBottom > 0 {
  174. for range renderer.marginBottom {
  175. content = content + "\n"
  176. }
  177. }
  178. return content
  179. }
  180. func calculatePadding() int {
  181. if layout.Current.Viewport.Width < 80 {
  182. return 5
  183. } else if layout.Current.Viewport.Width < 120 {
  184. return 15
  185. } else {
  186. return 20
  187. }
  188. }
  189. func renderText(message opencode.Message, text string, author string) string {
  190. t := theme.CurrentTheme()
  191. width := layout.Current.Container.Width
  192. padding := calculatePadding()
  193. timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
  194. if time.Now().Format("02 Jan 2006") == timestamp[:11] {
  195. // don't show the date if it's today
  196. timestamp = timestamp[12:]
  197. }
  198. info := fmt.Sprintf("%s (%s)", author, timestamp)
  199. textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
  200. markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
  201. if message.Role == opencode.MessageRoleAssistant {
  202. markdownWidth = width - padding - 4 - 3
  203. }
  204. minWidth := max(markdownWidth, (width-4)/2)
  205. messageStyle := styles.NewStyle().
  206. Width(minWidth).
  207. Background(t.BackgroundPanel()).
  208. Foreground(t.Text())
  209. if textWidth < minWidth {
  210. messageStyle = messageStyle.AlignHorizontal(lipgloss.Right)
  211. }
  212. content := messageStyle.Render(text)
  213. if message.Role == opencode.MessageRoleAssistant {
  214. content = toMarkdown(text, markdownWidth, t.BackgroundPanel())
  215. }
  216. content = strings.Join([]string{content, info}, "\n")
  217. switch message.Role {
  218. case opencode.MessageRoleUser:
  219. return renderContentBlock(content,
  220. WithAlign(lipgloss.Right),
  221. WithBorderColor(t.Secondary()),
  222. )
  223. case opencode.MessageRoleAssistant:
  224. return renderContentBlock(content,
  225. WithAlign(lipgloss.Left),
  226. WithBorderColor(t.Accent()),
  227. )
  228. }
  229. return ""
  230. }
  231. func renderToolInvocation(
  232. toolCall opencode.ToolInvocationPart,
  233. result *string,
  234. metadata opencode.MessageMetadataTool,
  235. showDetails bool,
  236. isLast bool,
  237. contentOnly bool,
  238. messageMetadata opencode.MessageMetadata,
  239. ) string {
  240. ignoredTools := []string{"todoread"}
  241. if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
  242. return ""
  243. }
  244. outerWidth := layout.Current.Container.Width
  245. innerWidth := outerWidth - 6
  246. paddingTop := 0
  247. paddingBottom := 0
  248. if showDetails {
  249. paddingTop = 1
  250. if result == nil || *result == "" {
  251. paddingBottom = 1
  252. }
  253. }
  254. t := theme.CurrentTheme()
  255. style := styles.NewStyle().
  256. Foreground(t.TextMuted()).
  257. Background(t.BackgroundPanel()).
  258. Width(outerWidth).
  259. PaddingTop(paddingTop).
  260. PaddingBottom(paddingBottom).
  261. PaddingLeft(2).
  262. PaddingRight(2).
  263. BorderLeft(true).
  264. BorderRight(true).
  265. BorderBackground(t.Background()).
  266. BorderForeground(t.BackgroundPanel()).
  267. BorderStyle(lipgloss.ThickBorder())
  268. if toolCall.ToolInvocation.State == "partial-call" {
  269. title := renderToolAction(toolCall.ToolInvocation.ToolName)
  270. if !showDetails {
  271. title = "∟ " + title
  272. padding := calculatePadding()
  273. style := styles.NewStyle().
  274. Background(t.BackgroundPanel()).
  275. Width(outerWidth - padding - 4 - 3)
  276. return renderContentBlock(style.Render(title),
  277. WithAlign(lipgloss.Left),
  278. WithBorderColor(t.Accent()),
  279. WithPaddingTop(0),
  280. WithPaddingBottom(1),
  281. )
  282. }
  283. style = style.Foreground(t.TextMuted())
  284. return style.Render(title)
  285. }
  286. toolArgs := ""
  287. toolArgsMap := make(map[string]any)
  288. if toolCall.ToolInvocation.Args != nil {
  289. value := toolCall.ToolInvocation.Args
  290. if m, ok := value.(map[string]any); ok {
  291. toolArgsMap = m
  292. keys := make([]string, 0, len(toolArgsMap))
  293. for key := range toolArgsMap {
  294. keys = append(keys, key)
  295. }
  296. slices.Sort(keys)
  297. firstKey := ""
  298. if len(keys) > 0 {
  299. firstKey = keys[0]
  300. }
  301. toolArgs = renderArgs(&toolArgsMap, firstKey)
  302. }
  303. }
  304. body := ""
  305. error := ""
  306. finished := result != nil && *result != ""
  307. er := messageMetadata.Error.AsUnion()
  308. switch er.(type) {
  309. case nil:
  310. default:
  311. clientError := er.(opencode.UnknownError)
  312. error = clientError.Data.Message
  313. }
  314. if error != "" {
  315. style = style.BorderLeftForeground(t.Error())
  316. error = styles.NewStyle().
  317. Foreground(t.Error()).
  318. Background(t.BackgroundPanel()).
  319. Render(error)
  320. error = renderContentBlock(
  321. error,
  322. WithFullWidth(),
  323. WithBorderColor(t.Error()),
  324. WithMarginBottom(1),
  325. )
  326. }
  327. title := ""
  328. switch toolCall.ToolInvocation.ToolName {
  329. case "read":
  330. toolArgs = renderArgs(&toolArgsMap, "filePath")
  331. title = fmt.Sprintf("READ %s", toolArgs)
  332. preview := metadata.ExtraFields["preview"]
  333. if preview != nil && toolArgsMap["filePath"] != nil {
  334. filename := toolArgsMap["filePath"].(string)
  335. body = preview.(string)
  336. body = renderFile(filename, body, WithTruncate(6))
  337. }
  338. case "edit":
  339. if filename, ok := toolArgsMap["filePath"].(string); ok {
  340. title = fmt.Sprintf("EDIT %s", relative(filename))
  341. diffField := metadata.ExtraFields["diff"]
  342. if diffField != nil {
  343. patch := diffField.(string)
  344. var formattedDiff string
  345. if layout.Current.Viewport.Width < 80 {
  346. formattedDiff, _ = diff.FormatUnifiedDiff(
  347. filename,
  348. patch,
  349. diff.WithWidth(layout.Current.Container.Width-2),
  350. )
  351. } else {
  352. diffWidth := min(layout.Current.Viewport.Width-2, 120)
  353. formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
  354. }
  355. formattedDiff = strings.TrimSpace(formattedDiff)
  356. formattedDiff = styles.NewStyle().
  357. BorderStyle(lipgloss.ThickBorder()).
  358. BorderBackground(t.Background()).
  359. BorderForeground(t.BackgroundPanel()).
  360. BorderLeft(true).
  361. BorderRight(true).
  362. Render(formattedDiff)
  363. if showDetails {
  364. style = style.Width(lipgloss.Width(formattedDiff))
  365. title += "\n"
  366. }
  367. body = strings.TrimSpace(formattedDiff)
  368. body = lipgloss.Place(
  369. layout.Current.Viewport.Width,
  370. lipgloss.Height(body)+1,
  371. lipgloss.Center,
  372. lipgloss.Top,
  373. body,
  374. styles.WhitespaceStyle(t.Background()),
  375. )
  376. // Add diagnostics at the bottom if they exist
  377. if diagnostics := renderDiagnostics(messageMetadata, filename); diagnostics != "" {
  378. body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
  379. }
  380. }
  381. }
  382. case "write":
  383. if filename, ok := toolArgsMap["filePath"].(string); ok {
  384. title = fmt.Sprintf("WRITE %s", relative(filename))
  385. if content, ok := toolArgsMap["content"].(string); ok {
  386. body = renderFile(filename, content)
  387. // Add diagnostics at the bottom if they exist
  388. if diagnostics := renderDiagnostics(messageMetadata, filename); diagnostics != "" {
  389. body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
  390. }
  391. }
  392. }
  393. case "bash":
  394. if description, ok := toolArgsMap["description"].(string); ok {
  395. title = fmt.Sprintf("SHELL %s", description)
  396. }
  397. stdout := metadata.JSON.ExtraFields["stdout"]
  398. if !stdout.IsNull() {
  399. command := toolArgsMap["command"].(string)
  400. stdout := stdout.Raw()
  401. body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
  402. body = toMarkdown(body, innerWidth, t.BackgroundPanel())
  403. body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
  404. }
  405. case "webfetch":
  406. toolArgs = renderArgs(&toolArgsMap, "url")
  407. title = fmt.Sprintf("FETCH %s", toolArgs)
  408. if format, ok := toolArgsMap["format"].(string); ok {
  409. if result != nil {
  410. body = *result
  411. body = truncateHeight(body, 10)
  412. if format == "html" || format == "markdown" {
  413. body = toMarkdown(body, innerWidth, t.BackgroundPanel())
  414. }
  415. body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
  416. }
  417. }
  418. case "todowrite":
  419. title = fmt.Sprintf("PLAN")
  420. todos := metadata.JSON.ExtraFields["todos"]
  421. if !todos.IsNull() && finished {
  422. strTodos := todos.Raw()
  423. todos := gjson.Parse(strTodos)
  424. for _, todo := range todos.Array() {
  425. content := todo.Get("content").String()
  426. switch todo.Get("status").String() {
  427. case "completed":
  428. body += fmt.Sprintf("- [x] %s\n", content)
  429. // case "in-progress":
  430. // body += fmt.Sprintf("- [ ] %s\n", content)
  431. default:
  432. body += fmt.Sprintf("- [ ] %s\n", content)
  433. }
  434. }
  435. body = toMarkdown(body, innerWidth, t.BackgroundPanel())
  436. body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
  437. }
  438. case "task":
  439. if description, ok := toolArgsMap["description"].(string); ok {
  440. title = fmt.Sprintf("TASK %s", description)
  441. summary := metadata.JSON.ExtraFields["summary"]
  442. if !summary.IsNull() {
  443. strValue := summary.Raw()
  444. toolcalls := gjson.Parse(strValue).Array()
  445. steps := []string{}
  446. for _, toolcall := range toolcalls {
  447. call := toolcall.Value().(map[string]any)
  448. if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
  449. data, _ := json.Marshal(toolInvocation)
  450. var toolCall opencode.ToolInvocationPart
  451. _ = json.Unmarshal(data, &toolCall)
  452. if metadata, ok := call["metadata"].(map[string]any); ok {
  453. data, _ = json.Marshal(metadata)
  454. var toolMetadata opencode.MessageMetadataTool
  455. _ = json.Unmarshal(data, &toolMetadata)
  456. step := renderToolInvocation(
  457. toolCall,
  458. nil,
  459. toolMetadata,
  460. false,
  461. false,
  462. true,
  463. messageMetadata,
  464. )
  465. steps = append(steps, step)
  466. }
  467. }
  468. }
  469. body = strings.Join(steps, "\n")
  470. body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
  471. }
  472. }
  473. default:
  474. toolName := renderToolName(toolCall.ToolInvocation.ToolName)
  475. title = fmt.Sprintf("%s %s", toolName, toolArgs)
  476. if result == nil {
  477. empty := ""
  478. result = &empty
  479. }
  480. body = *result
  481. body = truncateHeight(body, 10)
  482. body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
  483. }
  484. if contentOnly {
  485. title = "∟ " + title
  486. return title
  487. }
  488. if !showDetails {
  489. title = "∟ " + title
  490. padding := calculatePadding()
  491. style := styles.NewStyle().Background(t.BackgroundPanel()).Width(outerWidth - padding - 4 - 3)
  492. paddingBottom := 0
  493. if isLast {
  494. paddingBottom = 1
  495. }
  496. return renderContentBlock(style.Render(title),
  497. WithAlign(lipgloss.Left),
  498. WithBorderColor(t.Accent()),
  499. WithPaddingTop(0),
  500. WithPaddingBottom(paddingBottom),
  501. )
  502. }
  503. if body == "" && error == "" && result != nil {
  504. body = *result
  505. body = truncateHeight(body, 10)
  506. body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
  507. }
  508. content := style.Render(title)
  509. content = lipgloss.PlaceHorizontal(
  510. layout.Current.Viewport.Width,
  511. lipgloss.Center,
  512. content,
  513. styles.WhitespaceStyle(t.Background()),
  514. )
  515. if showDetails && body != "" && error == "" {
  516. content += "\n" + body
  517. }
  518. if showDetails && error != "" {
  519. content += "\n" + error
  520. }
  521. return content
  522. }
  523. func renderToolName(name string) string {
  524. switch name {
  525. case "list":
  526. return "LIST"
  527. case "webfetch":
  528. return "FETCH"
  529. case "todowrite":
  530. return "PLAN"
  531. default:
  532. normalizedName := name
  533. if strings.HasPrefix(name, "opencode_") {
  534. normalizedName = strings.TrimPrefix(name, "opencode_")
  535. }
  536. return cases.Upper(language.Und).String(normalizedName)
  537. }
  538. }
  539. type fileRenderer struct {
  540. filename string
  541. content string
  542. height int
  543. }
  544. type fileRenderingOption func(*fileRenderer)
  545. func WithTruncate(height int) fileRenderingOption {
  546. return func(c *fileRenderer) {
  547. c.height = height
  548. }
  549. }
  550. func renderFile(filename string, content string, options ...fileRenderingOption) string {
  551. t := theme.CurrentTheme()
  552. renderer := &fileRenderer{
  553. filename: filename,
  554. content: content,
  555. }
  556. for _, option := range options {
  557. option(renderer)
  558. }
  559. lines := []string{}
  560. for line := range strings.SplitSeq(content, "\n") {
  561. line = strings.TrimRightFunc(line, unicode.IsSpace)
  562. line = strings.ReplaceAll(line, "\t", " ")
  563. lines = append(lines, line)
  564. }
  565. content = strings.Join(lines, "\n")
  566. width := layout.Current.Container.Width - 8
  567. if renderer.height > 0 {
  568. content = truncateHeight(content, renderer.height)
  569. }
  570. content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
  571. content = toMarkdown(content, width, t.BackgroundPanel())
  572. return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1))
  573. }
  574. func renderToolAction(name string) string {
  575. switch name {
  576. case "task":
  577. return "Searching..."
  578. case "bash":
  579. return "Building command..."
  580. case "edit":
  581. return "Preparing edit..."
  582. case "webfetch":
  583. return "Fetching from the web..."
  584. case "glob":
  585. return "Finding files..."
  586. case "grep":
  587. return "Searching content..."
  588. case "list":
  589. return "Listing directory..."
  590. case "read":
  591. return "Reading file..."
  592. case "write":
  593. return "Preparing write..."
  594. case "todowrite", "todoread":
  595. return "Planning..."
  596. case "patch":
  597. return "Preparing patch..."
  598. case "batch":
  599. return "Running batch operations..."
  600. }
  601. return "Working..."
  602. }
  603. func renderArgs(args *map[string]any, titleKey string) string {
  604. if args == nil || len(*args) == 0 {
  605. return ""
  606. }
  607. keys := make([]string, 0, len(*args))
  608. for key := range *args {
  609. keys = append(keys, key)
  610. }
  611. slices.Sort(keys)
  612. title := ""
  613. parts := []string{}
  614. for _, key := range keys {
  615. value := (*args)[key]
  616. if value == nil {
  617. continue
  618. }
  619. if key == "filePath" || key == "path" {
  620. value = relative(value.(string))
  621. }
  622. if key == titleKey {
  623. title = fmt.Sprintf("%s", value)
  624. continue
  625. }
  626. parts = append(parts, fmt.Sprintf("%s=%v", key, value))
  627. }
  628. if len(parts) == 0 {
  629. return title
  630. }
  631. return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
  632. }
  633. func truncateHeight(content string, height int) string {
  634. lines := strings.Split(content, "\n")
  635. if len(lines) > height {
  636. return strings.Join(lines[:height], "\n")
  637. }
  638. return content
  639. }
  640. func relative(path string) string {
  641. return strings.TrimPrefix(path, app.RootPath+"/")
  642. }
  643. func extension(path string) string {
  644. ext := filepath.Ext(path)
  645. if ext == "" {
  646. ext = ""
  647. } else {
  648. ext = strings.ToLower(ext[1:])
  649. }
  650. return ext
  651. }
  652. // Diagnostic represents an LSP diagnostic
  653. type Diagnostic struct {
  654. Range struct {
  655. Start struct {
  656. Line int `json:"line"`
  657. Character int `json:"character"`
  658. } `json:"start"`
  659. } `json:"range"`
  660. Severity int `json:"severity"`
  661. Message string `json:"message"`
  662. }
  663. // renderDiagnostics formats LSP diagnostics for display in the TUI
  664. func renderDiagnostics(metadata opencode.MessageMetadata, filePath string) string {
  665. diagnosticsData := metadata.JSON.ExtraFields["diagnostics"]
  666. if diagnosticsData.IsNull() {
  667. return ""
  668. }
  669. // diagnosticsData should be a map[string][]Diagnostic
  670. strDiagnosticsData := diagnosticsData.Raw()
  671. diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
  672. fileDiagnostics, ok := diagnosticsMap[filePath]
  673. if !ok {
  674. return ""
  675. }
  676. diagnosticsList, ok := fileDiagnostics.([]any)
  677. if !ok {
  678. return ""
  679. }
  680. var errorDiagnostics []string
  681. for _, diagInterface := range diagnosticsList {
  682. diagMap, ok := diagInterface.(map[string]any)
  683. if !ok {
  684. continue
  685. }
  686. // Parse the diagnostic
  687. var diag Diagnostic
  688. diagBytes, err := json.Marshal(diagMap)
  689. if err != nil {
  690. continue
  691. }
  692. if err := json.Unmarshal(diagBytes, &diag); err != nil {
  693. continue
  694. }
  695. // Only show error diagnostics (severity === 1)
  696. if diag.Severity != 1 {
  697. continue
  698. }
  699. line := diag.Range.Start.Line + 1 // 1-based
  700. column := diag.Range.Start.Character + 1 // 1-based
  701. errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message))
  702. }
  703. if len(errorDiagnostics) == 0 {
  704. return ""
  705. }
  706. t := theme.CurrentTheme()
  707. var result strings.Builder
  708. for _, diagnostic := range errorDiagnostics {
  709. if result.Len() > 0 {
  710. result.WriteString("\n")
  711. }
  712. result.WriteString(styles.NewStyle().Foreground(t.Error()).Render(diagnostic))
  713. }
  714. return result.String()
  715. }