message.go 25 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  1. package chat
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "maps"
  6. "slices"
  7. "strings"
  8. "time"
  9. "github.com/charmbracelet/lipgloss/v2"
  10. "github.com/charmbracelet/lipgloss/v2/compat"
  11. "github.com/charmbracelet/x/ansi"
  12. "github.com/muesli/reflow/truncate"
  13. "github.com/sst/opencode-sdk-go"
  14. "github.com/sst/opencode/internal/app"
  15. "github.com/sst/opencode/internal/commands"
  16. "github.com/sst/opencode/internal/components/diff"
  17. "github.com/sst/opencode/internal/styles"
  18. "github.com/sst/opencode/internal/theme"
  19. "github.com/sst/opencode/internal/util"
  20. "golang.org/x/text/cases"
  21. "golang.org/x/text/language"
  22. )
  23. type blockRenderer struct {
  24. textColor compat.AdaptiveColor
  25. backgroundColor compat.AdaptiveColor
  26. border bool
  27. borderColor *compat.AdaptiveColor
  28. borderLeft bool
  29. borderRight bool
  30. paddingTop int
  31. paddingBottom int
  32. paddingLeft int
  33. paddingRight int
  34. marginTop int
  35. marginBottom int
  36. }
  37. type renderingOption func(*blockRenderer)
  38. func WithTextColor(color compat.AdaptiveColor) renderingOption {
  39. return func(c *blockRenderer) {
  40. c.textColor = color
  41. }
  42. }
  43. func WithBackgroundColor(color compat.AdaptiveColor) renderingOption {
  44. return func(c *blockRenderer) {
  45. c.backgroundColor = color
  46. }
  47. }
  48. func WithNoBorder() renderingOption {
  49. return func(c *blockRenderer) {
  50. c.border = false
  51. c.paddingLeft++
  52. c.paddingRight++
  53. }
  54. }
  55. func WithBorderColor(color compat.AdaptiveColor) renderingOption {
  56. return func(c *blockRenderer) {
  57. c.borderColor = &color
  58. }
  59. }
  60. func WithBorderLeft() renderingOption {
  61. return func(c *blockRenderer) {
  62. c.borderLeft = true
  63. c.borderRight = false
  64. }
  65. }
  66. func WithBorderRight() renderingOption {
  67. return func(c *blockRenderer) {
  68. c.borderLeft = false
  69. c.borderRight = true
  70. }
  71. }
  72. func WithBorderBoth(value bool) renderingOption {
  73. return func(c *blockRenderer) {
  74. if value {
  75. c.borderLeft = true
  76. c.borderRight = true
  77. }
  78. }
  79. }
  80. func WithMarginTop(padding int) renderingOption {
  81. return func(c *blockRenderer) {
  82. c.marginTop = padding
  83. }
  84. }
  85. func WithMarginBottom(padding int) renderingOption {
  86. return func(c *blockRenderer) {
  87. c.marginBottom = padding
  88. }
  89. }
  90. func WithPadding(padding int) renderingOption {
  91. return func(c *blockRenderer) {
  92. c.paddingTop = padding
  93. c.paddingBottom = padding
  94. c.paddingLeft = padding
  95. c.paddingRight = padding
  96. }
  97. }
  98. func WithPaddingLeft(padding int) renderingOption {
  99. return func(c *blockRenderer) {
  100. c.paddingLeft = padding
  101. }
  102. }
  103. func WithPaddingRight(padding int) renderingOption {
  104. return func(c *blockRenderer) {
  105. c.paddingRight = padding
  106. }
  107. }
  108. func WithPaddingTop(padding int) renderingOption {
  109. return func(c *blockRenderer) {
  110. c.paddingTop = padding
  111. }
  112. }
  113. func WithPaddingBottom(padding int) renderingOption {
  114. return func(c *blockRenderer) {
  115. c.paddingBottom = padding
  116. }
  117. }
  118. func renderContentBlock(
  119. app *app.App,
  120. content string,
  121. width int,
  122. options ...renderingOption,
  123. ) string {
  124. t := theme.CurrentTheme()
  125. renderer := &blockRenderer{
  126. textColor: t.TextMuted(),
  127. backgroundColor: t.BackgroundPanel(),
  128. border: true,
  129. borderLeft: true,
  130. borderRight: false,
  131. paddingTop: 1,
  132. paddingBottom: 1,
  133. paddingLeft: 2,
  134. paddingRight: 2,
  135. }
  136. for _, option := range options {
  137. option(renderer)
  138. }
  139. borderColor := t.BackgroundPanel()
  140. if renderer.borderColor != nil {
  141. borderColor = *renderer.borderColor
  142. }
  143. style := styles.NewStyle().
  144. Foreground(renderer.textColor).
  145. Background(renderer.backgroundColor).
  146. PaddingTop(renderer.paddingTop).
  147. PaddingBottom(renderer.paddingBottom).
  148. PaddingLeft(renderer.paddingLeft).
  149. PaddingRight(renderer.paddingRight).
  150. AlignHorizontal(lipgloss.Left)
  151. if renderer.border {
  152. style = style.
  153. BorderStyle(lipgloss.ThickBorder()).
  154. BorderLeft(true).
  155. BorderRight(true).
  156. BorderLeftForeground(t.BackgroundPanel()).
  157. BorderLeftBackground(t.Background()).
  158. BorderRightForeground(t.BackgroundPanel()).
  159. BorderRightBackground(t.Background())
  160. if renderer.borderLeft {
  161. style = style.BorderLeftForeground(borderColor)
  162. }
  163. if renderer.borderRight {
  164. style = style.BorderRightForeground(borderColor)
  165. }
  166. } else {
  167. style = style.PaddingLeft(renderer.paddingLeft).PaddingRight(renderer.paddingRight)
  168. }
  169. content = style.Render(content)
  170. if renderer.marginTop > 0 {
  171. for range renderer.marginTop {
  172. content = "\n" + content
  173. }
  174. }
  175. if renderer.marginBottom > 0 {
  176. for range renderer.marginBottom {
  177. content = content + "\n"
  178. }
  179. }
  180. return content
  181. }
  182. func renderText(
  183. app *app.App,
  184. message opencode.MessageUnion,
  185. text string,
  186. author string,
  187. showToolDetails bool,
  188. width int,
  189. extra string,
  190. isThinking bool,
  191. isQueued bool,
  192. shimmer bool,
  193. fileParts []opencode.FilePart,
  194. agentParts []opencode.AgentPart,
  195. toolCalls ...opencode.ToolPart,
  196. ) string {
  197. t := theme.CurrentTheme()
  198. var ts time.Time
  199. backgroundColor := t.BackgroundPanel()
  200. var content string
  201. switch casted := message.(type) {
  202. case opencode.AssistantMessage:
  203. backgroundColor = t.Background()
  204. if isThinking {
  205. backgroundColor = t.BackgroundPanel()
  206. }
  207. ts = time.UnixMilli(int64(casted.Time.Created))
  208. if casted.Time.Completed > 0 {
  209. ts = time.UnixMilli(int64(casted.Time.Completed))
  210. }
  211. content = util.ToMarkdown(text, width, backgroundColor)
  212. if isThinking {
  213. var label string
  214. if shimmer {
  215. label = util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent())
  216. } else {
  217. label = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking...")
  218. }
  219. label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
  220. content = label + "\n\n" + content
  221. } else if strings.TrimSpace(text) == "Generating..." {
  222. label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text())
  223. label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
  224. content = label
  225. }
  226. case opencode.UserMessage:
  227. ts = time.UnixMilli(int64(casted.Time.Created))
  228. base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
  229. var result strings.Builder
  230. lastEnd := int64(0)
  231. // Apply highlighting to filenames and base style to rest of text BEFORE wrapping
  232. textLen := int64(len(text))
  233. // Collect all parts to highlight (both file and agent parts)
  234. type highlightPart struct {
  235. start int64
  236. end int64
  237. color compat.AdaptiveColor
  238. }
  239. var highlights []highlightPart
  240. // Add file parts with secondary color
  241. for _, filePart := range fileParts {
  242. highlights = append(highlights, highlightPart{
  243. start: filePart.Source.Text.Start,
  244. end: filePart.Source.Text.End,
  245. color: t.Secondary(),
  246. })
  247. }
  248. // Add agent parts with secondary color (same as file parts)
  249. for _, agentPart := range agentParts {
  250. highlights = append(highlights, highlightPart{
  251. start: agentPart.Source.Start,
  252. end: agentPart.Source.End,
  253. color: t.Secondary(),
  254. })
  255. }
  256. // Sort highlights by start position
  257. slices.SortFunc(highlights, func(a, b highlightPart) int {
  258. if a.start < b.start {
  259. return -1
  260. }
  261. if a.start > b.start {
  262. return 1
  263. }
  264. return 0
  265. })
  266. // Merge overlapping highlights to prevent duplication
  267. merged := make([]highlightPart, 0)
  268. for _, part := range highlights {
  269. if len(merged) == 0 {
  270. merged = append(merged, part)
  271. continue
  272. }
  273. last := &merged[len(merged)-1]
  274. // If current part overlaps with the last one, merge them
  275. if part.start <= last.end {
  276. if part.end > last.end {
  277. last.end = part.end
  278. }
  279. } else {
  280. merged = append(merged, part)
  281. }
  282. }
  283. for _, part := range merged {
  284. highlight := base.Foreground(part.color)
  285. start, end := part.start, part.end
  286. if end > textLen {
  287. end = textLen
  288. }
  289. if start > textLen {
  290. start = textLen
  291. }
  292. if start > lastEnd {
  293. result.WriteString(base.Render(text[lastEnd:start]))
  294. }
  295. if start < end {
  296. result.WriteString(highlight.Render(text[start:end]))
  297. }
  298. lastEnd = end
  299. }
  300. if lastEnd < textLen {
  301. result.WriteString(base.Render(text[lastEnd:]))
  302. }
  303. // wrap styled text
  304. styledText := result.String()
  305. styledText = strings.ReplaceAll(styledText, "-", "\u2011")
  306. wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
  307. wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
  308. content = base.Width(width - 6).Render(wrappedText)
  309. if isQueued {
  310. queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1)
  311. content = queuedStyle.Render("QUEUED") + "\n\n" + content
  312. }
  313. }
  314. timestamp := ts.
  315. Local().
  316. Format("02 Jan 2006 03:04 PM")
  317. if time.Now().Format("02 Jan 2006") == timestamp[:11] {
  318. timestamp = timestamp[12:]
  319. }
  320. timestamp = styles.NewStyle().
  321. Background(backgroundColor).
  322. Foreground(t.TextMuted()).
  323. Render(" (" + timestamp + ")")
  324. // Check if this is an assistant message with agent information
  325. var modelAndAgentSuffix string
  326. if assistantMsg, ok := message.(opencode.AssistantMessage); ok && assistantMsg.Mode != "" {
  327. // Find the agent index by name to get the correct color
  328. var agentIndex int
  329. for i, agent := range app.Agents {
  330. if agent.Name == assistantMsg.Mode {
  331. agentIndex = i
  332. break
  333. }
  334. }
  335. // Get agent color based on the original agent index (same as status bar)
  336. agentColor := util.GetAgentColor(agentIndex)
  337. // Style the agent name with the same color as status bar
  338. agentName := cases.Title(language.Und).String(assistantMsg.Mode)
  339. styledAgentName := styles.NewStyle().
  340. Background(backgroundColor).
  341. Foreground(agentColor).
  342. Render(agentName + " ")
  343. styledModelID := styles.NewStyle().
  344. Background(backgroundColor).
  345. Foreground(t.TextMuted()).
  346. Render(assistantMsg.ModelID)
  347. modelAndAgentSuffix = styledAgentName + styledModelID
  348. }
  349. var info string
  350. if modelAndAgentSuffix != "" {
  351. info = modelAndAgentSuffix + timestamp
  352. } else {
  353. info = author + timestamp
  354. }
  355. if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
  356. for _, toolCall := range toolCalls {
  357. title := renderToolTitle(toolCall, width-2)
  358. style := styles.NewStyle()
  359. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  360. style = style.Foreground(t.Error())
  361. }
  362. title = style.Render(title)
  363. title = "\n∟ " + title
  364. content = content + title
  365. }
  366. }
  367. sections := []string{content}
  368. if extra != "" {
  369. sections = append(sections, "\n"+extra+"\n")
  370. }
  371. sections = append(sections, info)
  372. content = strings.Join(sections, "\n")
  373. switch message.(type) {
  374. case opencode.UserMessage:
  375. borderColor := t.Secondary()
  376. if isQueued {
  377. borderColor = t.Accent()
  378. }
  379. return renderContentBlock(
  380. app,
  381. content,
  382. width,
  383. WithTextColor(t.Text()),
  384. WithBorderColor(borderColor),
  385. )
  386. case opencode.AssistantMessage:
  387. if isThinking {
  388. return renderContentBlock(
  389. app,
  390. content,
  391. width,
  392. WithTextColor(t.Text()),
  393. WithBackgroundColor(t.BackgroundPanel()),
  394. WithBorderColor(t.BackgroundPanel()),
  395. )
  396. }
  397. return renderContentBlock(
  398. app,
  399. content,
  400. width,
  401. WithNoBorder(),
  402. WithBackgroundColor(t.Background()),
  403. )
  404. }
  405. return ""
  406. }
  407. func renderToolDetails(
  408. app *app.App,
  409. toolCall opencode.ToolPart,
  410. permission opencode.Permission,
  411. width int,
  412. ) string {
  413. measure := util.Measure("chat.renderToolDetails")
  414. defer measure("tool", toolCall.Tool)
  415. ignoredTools := []string{"todoread"}
  416. if slices.Contains(ignoredTools, toolCall.Tool) {
  417. return ""
  418. }
  419. if toolCall.State.Status == opencode.ToolPartStateStatusPending {
  420. title := renderToolTitle(toolCall, width)
  421. return renderContentBlock(app, title, width)
  422. }
  423. var result *string
  424. if toolCall.State.Output != "" {
  425. result = &toolCall.State.Output
  426. }
  427. toolInputMap := make(map[string]any)
  428. if toolCall.State.Input != nil {
  429. value := toolCall.State.Input
  430. if m, ok := value.(map[string]any); ok {
  431. toolInputMap = m
  432. keys := make([]string, 0, len(toolInputMap))
  433. for key := range toolInputMap {
  434. keys = append(keys, key)
  435. }
  436. slices.Sort(keys)
  437. }
  438. }
  439. body := ""
  440. t := theme.CurrentTheme()
  441. backgroundColor := t.BackgroundPanel()
  442. borderColor := t.BackgroundPanel()
  443. defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
  444. baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render
  445. mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render
  446. permissionContent := ""
  447. if permission.ID != "" {
  448. borderColor = t.Warning()
  449. base := styles.NewStyle().Background(backgroundColor)
  450. text := base.Foreground(t.Text()).Bold(true).Render
  451. muted := base.Foreground(t.TextMuted()).Render
  452. permissionContent = "Permission required to run this tool:\n\n"
  453. permissionContent += text(
  454. "enter ",
  455. ) + muted(
  456. "accept ",
  457. ) + text(
  458. "a",
  459. ) + muted(
  460. " accept always ",
  461. ) + text(
  462. "esc",
  463. ) + muted(
  464. " reject",
  465. )
  466. }
  467. if permission.Metadata != nil {
  468. metadata, ok := toolCall.State.Metadata.(map[string]any)
  469. if metadata == nil || !ok {
  470. metadata = map[string]any{}
  471. }
  472. maps.Copy(metadata, permission.Metadata)
  473. toolCall.State.Metadata = metadata
  474. }
  475. if toolCall.State.Metadata != nil {
  476. metadata := toolCall.State.Metadata.(map[string]any)
  477. switch toolCall.Tool {
  478. case "read":
  479. var preview any
  480. if metadata != nil {
  481. preview = metadata["preview"]
  482. }
  483. if preview != nil && toolInputMap["filePath"] != nil {
  484. filename := toolInputMap["filePath"].(string)
  485. body = preview.(string)
  486. body = util.RenderFile(filename, body, width, util.WithTruncate(6))
  487. }
  488. case "edit":
  489. if filename, ok := toolInputMap["filePath"].(string); ok {
  490. var diffField any
  491. if metadata != nil {
  492. diffField = metadata["diff"]
  493. }
  494. if diffField != nil {
  495. patch := diffField.(string)
  496. var formattedDiff string
  497. if width < 120 {
  498. formattedDiff, _ = diff.FormatUnifiedDiff(
  499. filename,
  500. patch,
  501. diff.WithWidth(width-2),
  502. )
  503. } else {
  504. formattedDiff, _ = diff.FormatDiff(
  505. filename,
  506. patch,
  507. diff.WithWidth(width-2),
  508. )
  509. }
  510. body = strings.TrimSpace(formattedDiff)
  511. style := styles.NewStyle().
  512. Background(backgroundColor).
  513. Foreground(t.TextMuted()).
  514. Padding(1, 2).
  515. Width(width - 4)
  516. if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
  517. diagnostics = style.Render(diagnostics)
  518. body += "\n" + diagnostics
  519. }
  520. title := renderToolTitle(toolCall, width)
  521. title = style.Render(title)
  522. content := title + "\n" + body
  523. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  524. errorStyle := styles.NewStyle().
  525. Background(backgroundColor).
  526. Foreground(t.Error()).
  527. Padding(1, 2).
  528. Width(width - 4)
  529. errorContent := errorStyle.Render(toolCall.State.Error)
  530. content += "\n" + errorContent
  531. }
  532. if permissionContent != "" {
  533. permissionContent = styles.NewStyle().
  534. Background(backgroundColor).
  535. Padding(1, 2).
  536. Render(permissionContent)
  537. content += "\n" + permissionContent
  538. }
  539. content = renderContentBlock(
  540. app,
  541. content,
  542. width,
  543. WithPadding(0),
  544. WithBorderColor(borderColor),
  545. WithBorderBoth(permission.ID != ""),
  546. )
  547. return content
  548. }
  549. }
  550. case "write":
  551. if filename, ok := toolInputMap["filePath"].(string); ok {
  552. if content, ok := toolInputMap["content"].(string); ok {
  553. body = util.RenderFile(filename, content, width)
  554. if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
  555. body += "\n\n" + diagnostics
  556. }
  557. }
  558. }
  559. case "bash":
  560. if command, ok := toolInputMap["command"].(string); ok {
  561. body = fmt.Sprintf("```console\n$ %s\n", command)
  562. output := metadata["output"]
  563. if output != nil {
  564. body += ansi.Strip(fmt.Sprintf("%s", output))
  565. }
  566. body += "```"
  567. body = util.ToMarkdown(body, width, backgroundColor)
  568. }
  569. case "webfetch":
  570. if format, ok := toolInputMap["format"].(string); ok && result != nil {
  571. body = *result
  572. body = util.TruncateHeight(body, 10)
  573. if format == "html" || format == "markdown" {
  574. body = util.ToMarkdown(body, width, backgroundColor)
  575. }
  576. }
  577. case "todowrite":
  578. todos := metadata["todos"]
  579. if todos != nil {
  580. for _, item := range todos.([]any) {
  581. todo := item.(map[string]any)
  582. content := todo["content"].(string)
  583. switch todo["status"] {
  584. case "completed":
  585. body += fmt.Sprintf("- [x] %s\n", content)
  586. case "cancelled":
  587. // strike through cancelled todo
  588. body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
  589. case "in_progress":
  590. // highlight in progress todo
  591. body += fmt.Sprintf("- [ ] `%s`\n", content)
  592. default:
  593. body += fmt.Sprintf("- [ ] %s\n", content)
  594. }
  595. }
  596. body = util.ToMarkdown(body, width, backgroundColor)
  597. }
  598. case "task":
  599. summary := metadata["summary"]
  600. if summary != nil {
  601. toolcalls := summary.([]any)
  602. steps := []string{}
  603. for _, item := range toolcalls {
  604. data, _ := json.Marshal(item)
  605. var toolCall opencode.ToolPart
  606. _ = json.Unmarshal(data, &toolCall)
  607. step := renderToolTitle(toolCall, width-2)
  608. step = "∟ " + step
  609. steps = append(steps, step)
  610. }
  611. body = strings.Join(steps, "\n")
  612. body += "\n\n"
  613. // Build navigation hint with proper spacing
  614. cycleKeybind := app.Keybind(commands.SessionChildCycleCommand)
  615. cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand)
  616. var navParts []string
  617. if cycleKeybind != "" {
  618. navParts = append(navParts, baseStyle(cycleKeybind))
  619. }
  620. if cycleReverseKeybind != "" {
  621. navParts = append(navParts, baseStyle(cycleReverseKeybind))
  622. }
  623. if len(navParts) > 0 {
  624. body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions")
  625. }
  626. }
  627. body = defaultStyle(body)
  628. default:
  629. if result == nil {
  630. empty := ""
  631. result = &empty
  632. }
  633. body = *result
  634. body = util.TruncateHeight(body, 10)
  635. body = defaultStyle(body)
  636. }
  637. }
  638. error := ""
  639. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  640. error = toolCall.State.Error
  641. }
  642. if error != "" {
  643. errorContent := styles.NewStyle().
  644. Width(width - 6).
  645. Foreground(t.Error()).
  646. Background(backgroundColor).
  647. Render(error)
  648. if body == "" {
  649. body = errorContent
  650. } else {
  651. body += "\n\n" + errorContent
  652. }
  653. }
  654. if body == "" && error == "" && result != nil {
  655. body = *result
  656. body = util.TruncateHeight(body, 10)
  657. body = defaultStyle(body)
  658. }
  659. if body == "" {
  660. body = defaultStyle("")
  661. }
  662. title := renderToolTitle(toolCall, width)
  663. content := title + "\n\n" + body
  664. if permissionContent != "" {
  665. content += "\n\n\n" + permissionContent
  666. }
  667. return renderContentBlock(
  668. app,
  669. content,
  670. width,
  671. WithBorderColor(borderColor),
  672. WithBorderBoth(permission.ID != ""),
  673. )
  674. }
  675. func renderToolName(name string) string {
  676. switch name {
  677. case "bash":
  678. return "Shell"
  679. case "webfetch":
  680. return "Fetch"
  681. case "invalid":
  682. return "Invalid"
  683. default:
  684. normalizedName := name
  685. if after, ok := strings.CutPrefix(name, "opencode_"); ok {
  686. normalizedName = after
  687. }
  688. return cases.Title(language.Und).String(normalizedName)
  689. }
  690. }
  691. func getTodoPhase(metadata map[string]any) string {
  692. todos, ok := metadata["todos"].([]any)
  693. if !ok || len(todos) == 0 {
  694. return "Plan"
  695. }
  696. counts := map[string]int{"pending": 0, "completed": 0}
  697. for _, item := range todos {
  698. if todo, ok := item.(map[string]any); ok {
  699. if status, ok := todo["status"].(string); ok {
  700. counts[status]++
  701. }
  702. }
  703. }
  704. total := len(todos)
  705. switch {
  706. case counts["pending"] == total:
  707. return "Creating plan"
  708. case counts["completed"] == total:
  709. return "Completing plan"
  710. default:
  711. return "Updating plan"
  712. }
  713. }
  714. func getTodoTitle(toolCall opencode.ToolPart) string {
  715. if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
  716. if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
  717. return getTodoPhase(metadata)
  718. }
  719. }
  720. return "Plan"
  721. }
  722. func renderToolTitle(
  723. toolCall opencode.ToolPart,
  724. width int,
  725. ) string {
  726. if toolCall.State.Status == opencode.ToolPartStateStatusPending {
  727. title := renderToolAction(toolCall.Tool)
  728. t := theme.CurrentTheme()
  729. shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
  730. return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny)
  731. }
  732. toolArgs := ""
  733. toolArgsMap := make(map[string]any)
  734. if toolCall.State.Input != nil {
  735. value := toolCall.State.Input
  736. if m, ok := value.(map[string]any); ok {
  737. toolArgsMap = m
  738. keys := make([]string, 0, len(toolArgsMap))
  739. for key := range toolArgsMap {
  740. keys = append(keys, key)
  741. }
  742. slices.Sort(keys)
  743. firstKey := ""
  744. if len(keys) > 0 {
  745. firstKey = keys[0]
  746. }
  747. toolArgs = renderArgs(&toolArgsMap, firstKey)
  748. }
  749. }
  750. title := renderToolName(toolCall.Tool)
  751. switch toolCall.Tool {
  752. case "read":
  753. toolArgs = renderArgs(&toolArgsMap, "filePath")
  754. title = fmt.Sprintf("%s %s", title, toolArgs)
  755. case "edit", "write":
  756. if filename, ok := toolArgsMap["filePath"].(string); ok {
  757. title = fmt.Sprintf("%s %s", title, util.Relative(filename))
  758. }
  759. case "bash":
  760. if description, ok := toolArgsMap["description"].(string); ok {
  761. title = fmt.Sprintf("%s %s", title, description)
  762. }
  763. case "task":
  764. description := toolArgsMap["description"]
  765. subagent := toolArgsMap["subagent_type"]
  766. if description != nil && subagent != nil {
  767. title = fmt.Sprintf("%s[%s] %s", title, subagent, description)
  768. } else if description != nil {
  769. title = fmt.Sprintf("%s %s", title, description)
  770. }
  771. case "webfetch":
  772. toolArgs = renderArgs(&toolArgsMap, "url")
  773. title = fmt.Sprintf("%s %s", title, toolArgs)
  774. case "todowrite":
  775. title = getTodoTitle(toolCall)
  776. case "todoread":
  777. return "Plan"
  778. case "invalid":
  779. if actualTool, ok := toolArgsMap["tool"].(string); ok {
  780. title = renderToolName(actualTool)
  781. }
  782. default:
  783. toolName := renderToolName(toolCall.Tool)
  784. title = fmt.Sprintf("%s %s", toolName, toolArgs)
  785. }
  786. title = truncate.StringWithTail(title, uint(width-6), "...")
  787. if toolCall.State.Error != "" {
  788. t := theme.CurrentTheme()
  789. title = styles.NewStyle().Foreground(t.Error()).Render(title)
  790. }
  791. return title
  792. }
  793. func renderToolAction(name string) string {
  794. switch name {
  795. case "task":
  796. return "Delegating..."
  797. case "bash":
  798. return "Writing command..."
  799. case "edit":
  800. return "Preparing edit..."
  801. case "webfetch":
  802. return "Fetching from the web..."
  803. case "glob":
  804. return "Finding files..."
  805. case "grep":
  806. return "Searching content..."
  807. case "list":
  808. return "Listing directory..."
  809. case "read":
  810. return "Reading file..."
  811. case "write":
  812. return "Preparing write..."
  813. case "todowrite", "todoread":
  814. return "Planning..."
  815. case "patch":
  816. return "Preparing patch..."
  817. }
  818. return "Working..."
  819. }
  820. func renderArgs(args *map[string]any, titleKey string) string {
  821. if args == nil || len(*args) == 0 {
  822. return ""
  823. }
  824. keys := make([]string, 0, len(*args))
  825. for key := range *args {
  826. keys = append(keys, key)
  827. }
  828. slices.Sort(keys)
  829. title := ""
  830. parts := []string{}
  831. for _, key := range keys {
  832. value := (*args)[key]
  833. if value == nil {
  834. continue
  835. }
  836. if key == "filePath" || key == "path" {
  837. if strValue, ok := value.(string); ok {
  838. value = util.Relative(strValue)
  839. }
  840. }
  841. if key == titleKey {
  842. title = fmt.Sprintf("%s", value)
  843. continue
  844. }
  845. parts = append(parts, fmt.Sprintf("%s=%v", key, value))
  846. }
  847. if len(parts) == 0 {
  848. return title
  849. }
  850. return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
  851. }
  852. // Diagnostic represents an LSP diagnostic
  853. type Diagnostic struct {
  854. Range struct {
  855. Start struct {
  856. Line int `json:"line"`
  857. Character int `json:"character"`
  858. } `json:"start"`
  859. } `json:"range"`
  860. Severity int `json:"severity"`
  861. Message string `json:"message"`
  862. }
  863. // renderDiagnostics formats LSP diagnostics for display in the TUI
  864. func renderDiagnostics(
  865. metadata map[string]any,
  866. filePath string,
  867. backgroundColor compat.AdaptiveColor,
  868. width int,
  869. ) string {
  870. if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
  871. if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
  872. var errorDiagnostics []string
  873. for _, diagInterface := range fileDiagnostics {
  874. diagMap, ok := diagInterface.(map[string]any)
  875. if !ok {
  876. continue
  877. }
  878. // Parse the diagnostic
  879. var diag Diagnostic
  880. diagBytes, err := json.Marshal(diagMap)
  881. if err != nil {
  882. continue
  883. }
  884. if err := json.Unmarshal(diagBytes, &diag); err != nil {
  885. continue
  886. }
  887. // Only show error diagnostics (severity === 1)
  888. if diag.Severity != 1 {
  889. continue
  890. }
  891. line := diag.Range.Start.Line + 1 // 1-based
  892. column := diag.Range.Start.Character + 1 // 1-based
  893. errorDiagnostics = append(
  894. errorDiagnostics,
  895. fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
  896. )
  897. }
  898. if len(errorDiagnostics) == 0 {
  899. return ""
  900. }
  901. t := theme.CurrentTheme()
  902. var result strings.Builder
  903. for _, diagnostic := range errorDiagnostics {
  904. if result.Len() > 0 {
  905. result.WriteString("\n\n")
  906. }
  907. diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
  908. result.WriteString(
  909. styles.NewStyle().
  910. Background(backgroundColor).
  911. Foreground(t.Error()).
  912. Render(diagnostic),
  913. )
  914. }
  915. return result.String()
  916. }
  917. }
  918. return ""
  919. // diagnosticsData should be a map[string][]Diagnostic
  920. // strDiagnosticsData := diagnosticsData.Raw()
  921. // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
  922. // fileDiagnostics, ok := diagnosticsMap[filePath]
  923. // if !ok {
  924. // return ""
  925. // }
  926. // diagnosticsList, ok := fileDiagnostics.([]any)
  927. // if !ok {
  928. // return ""
  929. // }
  930. }