message.go 25 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031
  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. if permission.Type == "doom-loop" {
  453. permissionContent = permission.Title + "\n\n"
  454. } else {
  455. permissionContent = "Permission required to run this tool:\n\n"
  456. }
  457. permissionContent += text(
  458. "enter ",
  459. ) + muted(
  460. "accept ",
  461. ) + text(
  462. "a",
  463. ) + muted(
  464. " accept always ",
  465. ) + text(
  466. "esc",
  467. ) + muted(
  468. " reject",
  469. )
  470. }
  471. if permission.Metadata != nil {
  472. metadata, ok := toolCall.State.Metadata.(map[string]any)
  473. if metadata == nil || !ok {
  474. metadata = map[string]any{}
  475. }
  476. maps.Copy(metadata, permission.Metadata)
  477. toolCall.State.Metadata = metadata
  478. }
  479. if toolCall.State.Metadata != nil {
  480. metadata := toolCall.State.Metadata.(map[string]any)
  481. switch toolCall.Tool {
  482. case "read":
  483. var preview any
  484. if metadata != nil {
  485. preview = metadata["preview"]
  486. }
  487. if preview != nil && toolInputMap["filePath"] != nil {
  488. filename := toolInputMap["filePath"].(string)
  489. body = preview.(string)
  490. body = util.RenderFile(filename, body, width, util.WithTruncate(6))
  491. }
  492. case "edit":
  493. if filename, ok := toolInputMap["filePath"].(string); ok {
  494. var diffField any
  495. if metadata != nil {
  496. diffField = metadata["diff"]
  497. }
  498. if diffField != nil {
  499. patch := diffField.(string)
  500. var formattedDiff string
  501. if width < 120 {
  502. formattedDiff, _ = diff.FormatUnifiedDiff(
  503. filename,
  504. patch,
  505. diff.WithWidth(width-2),
  506. )
  507. } else {
  508. formattedDiff, _ = diff.FormatDiff(
  509. filename,
  510. patch,
  511. diff.WithWidth(width-2),
  512. )
  513. }
  514. body = strings.TrimSpace(formattedDiff)
  515. style := styles.NewStyle().
  516. Background(backgroundColor).
  517. Foreground(t.TextMuted()).
  518. Padding(1, 2).
  519. Width(width - 4)
  520. if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
  521. diagnostics = style.Render(diagnostics)
  522. body += "\n" + diagnostics
  523. }
  524. title := renderToolTitle(toolCall, width)
  525. title = style.Render(title)
  526. content := title + "\n" + body
  527. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  528. errorStyle := styles.NewStyle().
  529. Background(backgroundColor).
  530. Foreground(t.Error()).
  531. Padding(1, 2).
  532. Width(width - 4)
  533. errorContent := errorStyle.Render(toolCall.State.Error)
  534. content += "\n" + errorContent
  535. }
  536. if permissionContent != "" {
  537. permissionContent = styles.NewStyle().
  538. Background(backgroundColor).
  539. Padding(1, 2).
  540. Render(permissionContent)
  541. content += "\n" + permissionContent
  542. }
  543. content = renderContentBlock(
  544. app,
  545. content,
  546. width,
  547. WithPadding(0),
  548. WithBorderColor(borderColor),
  549. WithBorderBoth(permission.ID != ""),
  550. )
  551. return content
  552. }
  553. }
  554. case "write":
  555. if filename, ok := toolInputMap["filePath"].(string); ok {
  556. if content, ok := toolInputMap["content"].(string); ok {
  557. body = util.RenderFile(filename, content, width)
  558. if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
  559. body += "\n\n" + diagnostics
  560. }
  561. }
  562. }
  563. case "bash":
  564. if command, ok := toolInputMap["command"].(string); ok {
  565. body = fmt.Sprintf("```console\n$ %s\n", command)
  566. output := metadata["output"]
  567. if output != nil {
  568. body += ansi.Strip(fmt.Sprintf("%s", output))
  569. }
  570. body += "```"
  571. body = util.ToMarkdown(body, width, backgroundColor)
  572. }
  573. case "webfetch":
  574. if format, ok := toolInputMap["format"].(string); ok && result != nil {
  575. body = *result
  576. body = util.TruncateHeight(body, 10)
  577. if format == "html" || format == "markdown" {
  578. body = util.ToMarkdown(body, width, backgroundColor)
  579. }
  580. }
  581. case "todowrite":
  582. todos := metadata["todos"]
  583. if todos != nil {
  584. for _, item := range todos.([]any) {
  585. todo := item.(map[string]any)
  586. content := todo["content"]
  587. if content == nil {
  588. continue
  589. }
  590. switch todo["status"] {
  591. case "completed":
  592. body += fmt.Sprintf("- [x] %s\n", content)
  593. case "cancelled":
  594. // strike through cancelled todo
  595. body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
  596. case "in_progress":
  597. // highlight in progress todo
  598. body += fmt.Sprintf("- [ ] `%s`\n", content)
  599. default:
  600. body += fmt.Sprintf("- [ ] %s\n", content)
  601. }
  602. }
  603. body = util.ToMarkdown(body, width, backgroundColor)
  604. }
  605. case "task":
  606. summary := metadata["summary"]
  607. if summary != nil {
  608. toolcalls := summary.([]any)
  609. steps := []string{}
  610. for _, item := range toolcalls {
  611. data, _ := json.Marshal(item)
  612. var toolCall opencode.ToolPart
  613. _ = json.Unmarshal(data, &toolCall)
  614. step := renderToolTitle(toolCall, width-2)
  615. step = "∟ " + step
  616. steps = append(steps, step)
  617. }
  618. body = strings.Join(steps, "\n")
  619. body += "\n\n"
  620. // Build navigation hint with proper spacing
  621. cycleKeybind := app.Keybind(commands.SessionChildCycleCommand)
  622. cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand)
  623. var navParts []string
  624. if cycleKeybind != "" {
  625. navParts = append(navParts, baseStyle(cycleKeybind))
  626. }
  627. if cycleReverseKeybind != "" {
  628. navParts = append(navParts, baseStyle(cycleReverseKeybind))
  629. }
  630. if len(navParts) > 0 {
  631. body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions")
  632. }
  633. }
  634. body = defaultStyle(body)
  635. default:
  636. if result == nil {
  637. empty := ""
  638. result = &empty
  639. }
  640. body = *result
  641. body = util.TruncateHeight(body, 10)
  642. body = defaultStyle(body)
  643. }
  644. }
  645. error := ""
  646. if toolCall.State.Status == opencode.ToolPartStateStatusError {
  647. error = toolCall.State.Error
  648. }
  649. if error != "" {
  650. errorContent := styles.NewStyle().
  651. Width(width - 6).
  652. Foreground(t.Error()).
  653. Background(backgroundColor).
  654. Render(error)
  655. if body == "" {
  656. body = errorContent
  657. } else {
  658. body += "\n\n" + errorContent
  659. }
  660. }
  661. if body == "" && error == "" && result != nil {
  662. body = *result
  663. body = util.TruncateHeight(body, 10)
  664. body = defaultStyle(body)
  665. }
  666. if body == "" {
  667. body = defaultStyle("")
  668. }
  669. title := renderToolTitle(toolCall, width)
  670. content := title + "\n\n" + body
  671. if permissionContent != "" {
  672. content += "\n\n\n" + permissionContent
  673. }
  674. return renderContentBlock(
  675. app,
  676. content,
  677. width,
  678. WithBorderColor(borderColor),
  679. WithBorderBoth(permission.ID != ""),
  680. )
  681. }
  682. func renderToolName(name string) string {
  683. switch name {
  684. case "bash":
  685. return "Shell"
  686. case "webfetch":
  687. return "Fetch"
  688. case "invalid":
  689. return "Invalid"
  690. default:
  691. normalizedName := name
  692. if after, ok := strings.CutPrefix(name, "opencode_"); ok {
  693. normalizedName = after
  694. }
  695. return cases.Title(language.Und).String(normalizedName)
  696. }
  697. }
  698. func getTodoPhase(metadata map[string]any) string {
  699. todos, ok := metadata["todos"].([]any)
  700. if !ok || len(todos) == 0 {
  701. return "Plan"
  702. }
  703. counts := map[string]int{"pending": 0, "completed": 0}
  704. for _, item := range todos {
  705. if todo, ok := item.(map[string]any); ok {
  706. if status, ok := todo["status"].(string); ok {
  707. counts[status]++
  708. }
  709. }
  710. }
  711. total := len(todos)
  712. switch {
  713. case counts["pending"] == total:
  714. return "Creating plan"
  715. case counts["completed"] == total:
  716. return "Completing plan"
  717. default:
  718. return "Updating plan"
  719. }
  720. }
  721. func getTodoTitle(toolCall opencode.ToolPart) string {
  722. if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
  723. if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
  724. return getTodoPhase(metadata)
  725. }
  726. }
  727. return "Plan"
  728. }
  729. func renderToolTitle(
  730. toolCall opencode.ToolPart,
  731. width int,
  732. ) string {
  733. if toolCall.State.Status == opencode.ToolPartStateStatusPending {
  734. title := renderToolAction(toolCall.Tool)
  735. t := theme.CurrentTheme()
  736. shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
  737. return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny)
  738. }
  739. toolArgs := ""
  740. toolArgsMap := make(map[string]any)
  741. if toolCall.State.Input != nil {
  742. value := toolCall.State.Input
  743. if m, ok := value.(map[string]any); ok {
  744. toolArgsMap = m
  745. keys := make([]string, 0, len(toolArgsMap))
  746. for key := range toolArgsMap {
  747. keys = append(keys, key)
  748. }
  749. slices.Sort(keys)
  750. firstKey := ""
  751. if len(keys) > 0 {
  752. firstKey = keys[0]
  753. }
  754. toolArgs = renderArgs(&toolArgsMap, firstKey)
  755. }
  756. }
  757. title := renderToolName(toolCall.Tool)
  758. switch toolCall.Tool {
  759. case "read":
  760. toolArgs = renderArgs(&toolArgsMap, "filePath")
  761. title = fmt.Sprintf("%s %s", title, toolArgs)
  762. case "edit", "write":
  763. if filename, ok := toolArgsMap["filePath"].(string); ok {
  764. title = fmt.Sprintf("%s %s", title, util.Relative(filename))
  765. }
  766. case "bash":
  767. if description, ok := toolArgsMap["description"].(string); ok {
  768. title = fmt.Sprintf("%s %s", title, description)
  769. }
  770. case "task":
  771. description := toolArgsMap["description"]
  772. subagent := toolArgsMap["subagent_type"]
  773. if description != nil && subagent != nil {
  774. title = fmt.Sprintf("%s[%s] %s", title, subagent, description)
  775. } else if description != nil {
  776. title = fmt.Sprintf("%s %s", title, description)
  777. }
  778. case "webfetch":
  779. toolArgs = renderArgs(&toolArgsMap, "url")
  780. title = fmt.Sprintf("%s %s", title, toolArgs)
  781. case "todowrite":
  782. title = getTodoTitle(toolCall)
  783. case "todoread":
  784. return "Plan"
  785. case "invalid":
  786. if actualTool, ok := toolArgsMap["tool"].(string); ok {
  787. title = renderToolName(actualTool)
  788. }
  789. default:
  790. toolName := renderToolName(toolCall.Tool)
  791. title = fmt.Sprintf("%s %s", toolName, toolArgs)
  792. }
  793. title = truncate.StringWithTail(title, uint(width-6), "...")
  794. if toolCall.State.Error != "" {
  795. t := theme.CurrentTheme()
  796. title = styles.NewStyle().Foreground(t.Error()).Render(title)
  797. }
  798. return title
  799. }
  800. func renderToolAction(name string) string {
  801. switch name {
  802. case "task":
  803. return "Delegating..."
  804. case "bash":
  805. return "Writing command..."
  806. case "edit":
  807. return "Preparing edit..."
  808. case "webfetch":
  809. return "Fetching from the web..."
  810. case "glob":
  811. return "Finding files..."
  812. case "grep":
  813. return "Searching content..."
  814. case "list":
  815. return "Listing directory..."
  816. case "read":
  817. return "Reading file..."
  818. case "write":
  819. return "Preparing write..."
  820. case "todowrite", "todoread":
  821. return "Planning..."
  822. case "patch":
  823. return "Preparing patch..."
  824. }
  825. return "Working..."
  826. }
  827. func renderArgs(args *map[string]any, titleKey string) string {
  828. if args == nil || len(*args) == 0 {
  829. return ""
  830. }
  831. keys := make([]string, 0, len(*args))
  832. for key := range *args {
  833. keys = append(keys, key)
  834. }
  835. slices.Sort(keys)
  836. title := ""
  837. parts := []string{}
  838. for _, key := range keys {
  839. value := (*args)[key]
  840. if value == nil {
  841. continue
  842. }
  843. if key == "filePath" || key == "path" {
  844. if strValue, ok := value.(string); ok {
  845. value = util.Relative(strValue)
  846. }
  847. }
  848. if key == titleKey {
  849. title = fmt.Sprintf("%s", value)
  850. continue
  851. }
  852. parts = append(parts, fmt.Sprintf("%s=%v", key, value))
  853. }
  854. if len(parts) == 0 {
  855. return title
  856. }
  857. return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
  858. }
  859. // Diagnostic represents an LSP diagnostic
  860. type Diagnostic struct {
  861. Range struct {
  862. Start struct {
  863. Line int `json:"line"`
  864. Character int `json:"character"`
  865. } `json:"start"`
  866. } `json:"range"`
  867. Severity int `json:"severity"`
  868. Message string `json:"message"`
  869. }
  870. // renderDiagnostics formats LSP diagnostics for display in the TUI
  871. func renderDiagnostics(
  872. metadata map[string]any,
  873. filePath string,
  874. backgroundColor compat.AdaptiveColor,
  875. width int,
  876. ) string {
  877. if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
  878. if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
  879. var errorDiagnostics []string
  880. for _, diagInterface := range fileDiagnostics {
  881. diagMap, ok := diagInterface.(map[string]any)
  882. if !ok {
  883. continue
  884. }
  885. // Parse the diagnostic
  886. var diag Diagnostic
  887. diagBytes, err := json.Marshal(diagMap)
  888. if err != nil {
  889. continue
  890. }
  891. if err := json.Unmarshal(diagBytes, &diag); err != nil {
  892. continue
  893. }
  894. // Only show error diagnostics (severity === 1)
  895. if diag.Severity != 1 {
  896. continue
  897. }
  898. line := diag.Range.Start.Line + 1 // 1-based
  899. column := diag.Range.Start.Character + 1 // 1-based
  900. errorDiagnostics = append(
  901. errorDiagnostics,
  902. fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
  903. )
  904. }
  905. if len(errorDiagnostics) == 0 {
  906. return ""
  907. }
  908. t := theme.CurrentTheme()
  909. var result strings.Builder
  910. for _, diagnostic := range errorDiagnostics {
  911. if result.Len() > 0 {
  912. result.WriteString("\n\n")
  913. }
  914. diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
  915. result.WriteString(
  916. styles.NewStyle().
  917. Background(backgroundColor).
  918. Foreground(t.Error()).
  919. Render(diagnostic),
  920. )
  921. }
  922. return result.String()
  923. }
  924. }
  925. return ""
  926. // diagnosticsData should be a map[string][]Diagnostic
  927. // strDiagnosticsData := diagnosticsData.Raw()
  928. // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
  929. // fileDiagnostics, ok := diagnosticsMap[filePath]
  930. // if !ok {
  931. // return ""
  932. // }
  933. // diagnosticsList, ok := fileDiagnostics.([]any)
  934. // if !ok {
  935. // return ""
  936. // }
  937. }