message.go 25 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027
  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"]
  583. if content == nil {
  584. continue
  585. }
  586. switch todo["status"] {
  587. case "completed":
  588. body += fmt.Sprintf("- [x] %s\n", content)
  589. case "cancelled":
  590. // strike through cancelled todo
  591. body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
  592. case "in_progress":
  593. // highlight in progress todo
  594. body += fmt.Sprintf("- [ ] `%s`\n", content)
  595. default:
  596. body += fmt.Sprintf("- [ ] %s\n", content)
  597. }
  598. }
  599. body = util.ToMarkdown(body, width, backgroundColor)
  600. }
  601. case "task":
  602. summary := metadata["summary"]
  603. if summary != nil {
  604. toolcalls := summary.([]any)
  605. steps := []string{}
  606. for _, item := range toolcalls {
  607. data, _ := json.Marshal(item)
  608. var toolCall opencode.ToolPart
  609. _ = json.Unmarshal(data, &toolCall)
  610. step := renderToolTitle(toolCall, width-2)
  611. step = "∟ " + step
  612. steps = append(steps, step)
  613. }
  614. body = strings.Join(steps, "\n")
  615. body += "\n\n"
  616. // Build navigation hint with proper spacing
  617. cycleKeybind := app.Keybind(commands.SessionChildCycleCommand)
  618. cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand)
  619. var navParts []string
  620. if cycleKeybind != "" {
  621. navParts = append(navParts, baseStyle(cycleKeybind))
  622. }
  623. if cycleReverseKeybind != "" {
  624. navParts = append(navParts, baseStyle(cycleReverseKeybind))
  625. }
  626. if len(navParts) > 0 {
  627. body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions")
  628. }
  629. }
  630. body = defaultStyle(body)
  631. default:
  632. if result == nil {
  633. empty := ""
  634. result = &empty
  635. }
  636. body = *result
  637. body = util.TruncateHeight(body, 10)
  638. body = defaultStyle(body)
  639. }
  640. }
  641. error := ""
  642. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  643. error = toolCall.State.Error
  644. }
  645. if error != "" {
  646. errorContent := styles.NewStyle().
  647. Width(width - 6).
  648. Foreground(t.Error()).
  649. Background(backgroundColor).
  650. Render(error)
  651. if body == "" {
  652. body = errorContent
  653. } else {
  654. body += "\n\n" + errorContent
  655. }
  656. }
  657. if body == "" && error == "" && result != nil {
  658. body = *result
  659. body = util.TruncateHeight(body, 10)
  660. body = defaultStyle(body)
  661. }
  662. if body == "" {
  663. body = defaultStyle("")
  664. }
  665. title := renderToolTitle(toolCall, width)
  666. content := title + "\n\n" + body
  667. if permissionContent != "" {
  668. content += "\n\n\n" + permissionContent
  669. }
  670. return renderContentBlock(
  671. app,
  672. content,
  673. width,
  674. WithBorderColor(borderColor),
  675. WithBorderBoth(permission.ID != ""),
  676. )
  677. }
  678. func renderToolName(name string) string {
  679. switch name {
  680. case "bash":
  681. return "Shell"
  682. case "webfetch":
  683. return "Fetch"
  684. case "invalid":
  685. return "Invalid"
  686. default:
  687. normalizedName := name
  688. if after, ok := strings.CutPrefix(name, "opencode_"); ok {
  689. normalizedName = after
  690. }
  691. return cases.Title(language.Und).String(normalizedName)
  692. }
  693. }
  694. func getTodoPhase(metadata map[string]any) string {
  695. todos, ok := metadata["todos"].([]any)
  696. if !ok || len(todos) == 0 {
  697. return "Plan"
  698. }
  699. counts := map[string]int{"pending": 0, "completed": 0}
  700. for _, item := range todos {
  701. if todo, ok := item.(map[string]any); ok {
  702. if status, ok := todo["status"].(string); ok {
  703. counts[status]++
  704. }
  705. }
  706. }
  707. total := len(todos)
  708. switch {
  709. case counts["pending"] == total:
  710. return "Creating plan"
  711. case counts["completed"] == total:
  712. return "Completing plan"
  713. default:
  714. return "Updating plan"
  715. }
  716. }
  717. func getTodoTitle(toolCall opencode.ToolPart) string {
  718. if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
  719. if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
  720. return getTodoPhase(metadata)
  721. }
  722. }
  723. return "Plan"
  724. }
  725. func renderToolTitle(
  726. toolCall opencode.ToolPart,
  727. width int,
  728. ) string {
  729. if toolCall.State.Status == opencode.ToolPartStateStatusPending {
  730. title := renderToolAction(toolCall.Tool)
  731. t := theme.CurrentTheme()
  732. shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
  733. return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny)
  734. }
  735. toolArgs := ""
  736. toolArgsMap := make(map[string]any)
  737. if toolCall.State.Input != nil {
  738. value := toolCall.State.Input
  739. if m, ok := value.(map[string]any); ok {
  740. toolArgsMap = m
  741. keys := make([]string, 0, len(toolArgsMap))
  742. for key := range toolArgsMap {
  743. keys = append(keys, key)
  744. }
  745. slices.Sort(keys)
  746. firstKey := ""
  747. if len(keys) > 0 {
  748. firstKey = keys[0]
  749. }
  750. toolArgs = renderArgs(&toolArgsMap, firstKey)
  751. }
  752. }
  753. title := renderToolName(toolCall.Tool)
  754. switch toolCall.Tool {
  755. case "read":
  756. toolArgs = renderArgs(&toolArgsMap, "filePath")
  757. title = fmt.Sprintf("%s %s", title, toolArgs)
  758. case "edit", "write":
  759. if filename, ok := toolArgsMap["filePath"].(string); ok {
  760. title = fmt.Sprintf("%s %s", title, util.Relative(filename))
  761. }
  762. case "bash":
  763. if description, ok := toolArgsMap["description"].(string); ok {
  764. title = fmt.Sprintf("%s %s", title, description)
  765. }
  766. case "task":
  767. description := toolArgsMap["description"]
  768. subagent := toolArgsMap["subagent_type"]
  769. if description != nil && subagent != nil {
  770. title = fmt.Sprintf("%s[%s] %s", title, subagent, description)
  771. } else if description != nil {
  772. title = fmt.Sprintf("%s %s", title, description)
  773. }
  774. case "webfetch":
  775. toolArgs = renderArgs(&toolArgsMap, "url")
  776. title = fmt.Sprintf("%s %s", title, toolArgs)
  777. case "todowrite":
  778. title = getTodoTitle(toolCall)
  779. case "todoread":
  780. return "Plan"
  781. case "invalid":
  782. if actualTool, ok := toolArgsMap["tool"].(string); ok {
  783. title = renderToolName(actualTool)
  784. }
  785. default:
  786. toolName := renderToolName(toolCall.Tool)
  787. title = fmt.Sprintf("%s %s", toolName, toolArgs)
  788. }
  789. title = truncate.StringWithTail(title, uint(width-6), "...")
  790. if toolCall.State.Error != "" {
  791. t := theme.CurrentTheme()
  792. title = styles.NewStyle().Foreground(t.Error()).Render(title)
  793. }
  794. return title
  795. }
  796. func renderToolAction(name string) string {
  797. switch name {
  798. case "task":
  799. return "Delegating..."
  800. case "bash":
  801. return "Writing command..."
  802. case "edit":
  803. return "Preparing edit..."
  804. case "webfetch":
  805. return "Fetching from the web..."
  806. case "glob":
  807. return "Finding files..."
  808. case "grep":
  809. return "Searching content..."
  810. case "list":
  811. return "Listing directory..."
  812. case "read":
  813. return "Reading file..."
  814. case "write":
  815. return "Preparing write..."
  816. case "todowrite", "todoread":
  817. return "Planning..."
  818. case "patch":
  819. return "Preparing patch..."
  820. }
  821. return "Working..."
  822. }
  823. func renderArgs(args *map[string]any, titleKey string) string {
  824. if args == nil || len(*args) == 0 {
  825. return ""
  826. }
  827. keys := make([]string, 0, len(*args))
  828. for key := range *args {
  829. keys = append(keys, key)
  830. }
  831. slices.Sort(keys)
  832. title := ""
  833. parts := []string{}
  834. for _, key := range keys {
  835. value := (*args)[key]
  836. if value == nil {
  837. continue
  838. }
  839. if key == "filePath" || key == "path" {
  840. if strValue, ok := value.(string); ok {
  841. value = util.Relative(strValue)
  842. }
  843. }
  844. if key == titleKey {
  845. title = fmt.Sprintf("%s", value)
  846. continue
  847. }
  848. parts = append(parts, fmt.Sprintf("%s=%v", key, value))
  849. }
  850. if len(parts) == 0 {
  851. return title
  852. }
  853. return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
  854. }
  855. // Diagnostic represents an LSP diagnostic
  856. type Diagnostic struct {
  857. Range struct {
  858. Start struct {
  859. Line int `json:"line"`
  860. Character int `json:"character"`
  861. } `json:"start"`
  862. } `json:"range"`
  863. Severity int `json:"severity"`
  864. Message string `json:"message"`
  865. }
  866. // renderDiagnostics formats LSP diagnostics for display in the TUI
  867. func renderDiagnostics(
  868. metadata map[string]any,
  869. filePath string,
  870. backgroundColor compat.AdaptiveColor,
  871. width int,
  872. ) string {
  873. if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
  874. if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
  875. var errorDiagnostics []string
  876. for _, diagInterface := range fileDiagnostics {
  877. diagMap, ok := diagInterface.(map[string]any)
  878. if !ok {
  879. continue
  880. }
  881. // Parse the diagnostic
  882. var diag Diagnostic
  883. diagBytes, err := json.Marshal(diagMap)
  884. if err != nil {
  885. continue
  886. }
  887. if err := json.Unmarshal(diagBytes, &diag); err != nil {
  888. continue
  889. }
  890. // Only show error diagnostics (severity === 1)
  891. if diag.Severity != 1 {
  892. continue
  893. }
  894. line := diag.Range.Start.Line + 1 // 1-based
  895. column := diag.Range.Start.Character + 1 // 1-based
  896. errorDiagnostics = append(
  897. errorDiagnostics,
  898. fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
  899. )
  900. }
  901. if len(errorDiagnostics) == 0 {
  902. return ""
  903. }
  904. t := theme.CurrentTheme()
  905. var result strings.Builder
  906. for _, diagnostic := range errorDiagnostics {
  907. if result.Len() > 0 {
  908. result.WriteString("\n\n")
  909. }
  910. diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
  911. result.WriteString(
  912. styles.NewStyle().
  913. Background(backgroundColor).
  914. Foreground(t.Error()).
  915. Render(diagnostic),
  916. )
  917. }
  918. return result.String()
  919. }
  920. }
  921. return ""
  922. // diagnosticsData should be a map[string][]Diagnostic
  923. // strDiagnosticsData := diagnosticsData.Raw()
  924. // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
  925. // fileDiagnostics, ok := diagnosticsMap[filePath]
  926. // if !ok {
  927. // return ""
  928. // }
  929. // diagnosticsList, ok := fileDiagnostics.([]any)
  930. // if !ok {
  931. // return ""
  932. // }
  933. }