message.go 25 KB

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