messages.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. package chat
  2. import (
  3. "fmt"
  4. "strings"
  5. "github.com/charmbracelet/bubbles/v2/viewport"
  6. tea "github.com/charmbracelet/bubbletea/v2"
  7. "github.com/charmbracelet/lipgloss/v2"
  8. "github.com/sst/opencode-sdk-go"
  9. "github.com/sst/opencode/internal/app"
  10. "github.com/sst/opencode/internal/components/dialog"
  11. "github.com/sst/opencode/internal/layout"
  12. "github.com/sst/opencode/internal/styles"
  13. "github.com/sst/opencode/internal/theme"
  14. "github.com/sst/opencode/internal/util"
  15. )
  16. type MessagesComponent interface {
  17. tea.Model
  18. View(width, height int) string
  19. SetWidth(width int) tea.Cmd
  20. PageUp() (tea.Model, tea.Cmd)
  21. PageDown() (tea.Model, tea.Cmd)
  22. HalfPageUp() (tea.Model, tea.Cmd)
  23. HalfPageDown() (tea.Model, tea.Cmd)
  24. First() (tea.Model, tea.Cmd)
  25. Last() (tea.Model, tea.Cmd)
  26. Previous() (tea.Model, tea.Cmd)
  27. Next() (tea.Model, tea.Cmd)
  28. ToolDetailsVisible() bool
  29. Selected() string
  30. }
  31. type messagesComponent struct {
  32. width int
  33. app *app.App
  34. viewport viewport.Model
  35. cache *MessageCache
  36. rendering bool
  37. showToolDetails bool
  38. tail bool
  39. partCount int
  40. lineCount int
  41. selectedPart int
  42. selectedText string
  43. }
  44. type renderFinishedMsg struct{}
  45. type selectedMessagePartChangedMsg struct {
  46. part int
  47. }
  48. type ToggleToolDetailsMsg struct{}
  49. func (m *messagesComponent) Init() tea.Cmd {
  50. return tea.Batch(m.viewport.Init())
  51. }
  52. func (m *messagesComponent) Selected() string {
  53. return m.selectedText
  54. }
  55. func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  56. var cmds []tea.Cmd
  57. switch msg := msg.(type) {
  58. case app.SendMsg:
  59. m.viewport.GotoBottom()
  60. m.tail = true
  61. m.selectedPart = -1
  62. return m, nil
  63. case app.OptimisticMessageAddedMsg:
  64. m.tail = true
  65. m.rendering = true
  66. return m, m.Reload()
  67. case dialog.ThemeSelectedMsg:
  68. m.cache.Clear()
  69. m.rendering = true
  70. return m, m.Reload()
  71. case ToggleToolDetailsMsg:
  72. m.showToolDetails = !m.showToolDetails
  73. m.rendering = true
  74. return m, m.Reload()
  75. case app.SessionLoadedMsg, app.SessionClearedMsg:
  76. m.cache.Clear()
  77. m.tail = true
  78. m.rendering = true
  79. return m, m.Reload()
  80. case renderFinishedMsg:
  81. m.rendering = false
  82. if m.tail {
  83. m.viewport.GotoBottom()
  84. }
  85. case selectedMessagePartChangedMsg:
  86. return m, m.Reload()
  87. case opencode.EventListResponseEventSessionUpdated:
  88. if msg.Properties.Info.ID == m.app.Session.ID {
  89. m.renderView(m.width)
  90. if m.tail {
  91. m.viewport.GotoBottom()
  92. }
  93. }
  94. case opencode.EventListResponseEventMessageUpdated:
  95. if msg.Properties.Info.SessionID == m.app.Session.ID {
  96. m.renderView(m.width)
  97. if m.tail {
  98. m.viewport.GotoBottom()
  99. }
  100. }
  101. }
  102. viewport, cmd := m.viewport.Update(msg)
  103. m.viewport = viewport
  104. m.tail = m.viewport.AtBottom()
  105. cmds = append(cmds, cmd)
  106. return m, tea.Batch(cmds...)
  107. }
  108. func (m *messagesComponent) renderView(width int) {
  109. measure := util.Measure("messages.renderView")
  110. defer measure("messageCount", len(m.app.Messages))
  111. t := theme.CurrentTheme()
  112. blocks := make([]string, 0)
  113. m.partCount = 0
  114. m.lineCount = 0
  115. orphanedToolCalls := make([]opencode.ToolPart, 0)
  116. for _, message := range m.app.Messages {
  117. var content string
  118. var cached bool
  119. switch casted := message.(type) {
  120. case opencode.UserMessage:
  121. userLoop:
  122. for partIndex, part := range casted.Parts {
  123. switch part := part.AsUnion().(type) {
  124. case opencode.TextPart:
  125. remainingParts := casted.Parts[partIndex+1:]
  126. fileParts := make([]opencode.FilePart, 0)
  127. for _, part := range remainingParts {
  128. switch part := part.AsUnion().(type) {
  129. case opencode.FilePart:
  130. fileParts = append(fileParts, part)
  131. }
  132. }
  133. flexItems := []layout.FlexItem{}
  134. if len(fileParts) > 0 {
  135. fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
  136. mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
  137. for _, filePart := range fileParts {
  138. mediaType := ""
  139. switch filePart.Mime {
  140. case "text/plain":
  141. mediaType = "txt"
  142. case "image/png", "image/jpeg", "image/gif", "image/webp":
  143. mediaType = "img"
  144. mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
  145. case "application/pdf":
  146. mediaType = "pdf"
  147. mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
  148. }
  149. flexItems = append(flexItems, layout.FlexItem{
  150. View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
  151. })
  152. }
  153. }
  154. bgColor := t.BackgroundPanel()
  155. files := layout.Render(
  156. layout.FlexOptions{
  157. Background: &bgColor,
  158. Width: width - 6,
  159. Direction: layout.Column,
  160. },
  161. flexItems...,
  162. )
  163. key := m.cache.GenerateKey(casted.ID, part.Text, width, m.selectedPart == m.partCount, files)
  164. content, cached = m.cache.Get(key)
  165. if !cached {
  166. content = renderText(
  167. m.app,
  168. message,
  169. part.Text,
  170. m.app.Info.User,
  171. m.showToolDetails,
  172. m.partCount == m.selectedPart,
  173. width,
  174. files,
  175. )
  176. m.cache.Set(key, content)
  177. }
  178. if content != "" {
  179. m = m.updateSelected(content, part.Text)
  180. blocks = append(blocks, content)
  181. }
  182. // Only render the first text part
  183. break userLoop
  184. }
  185. }
  186. case opencode.AssistantMessage:
  187. hasTextPart := false
  188. for partIndex, p := range casted.Parts {
  189. switch part := p.AsUnion().(type) {
  190. case opencode.TextPart:
  191. hasTextPart = true
  192. finished := casted.Time.Completed > 0
  193. remainingParts := casted.Parts[partIndex+1:]
  194. toolCallParts := make([]opencode.ToolPart, 0)
  195. // sometimes tool calls happen without an assistant message
  196. // these should be included in this assistant message as well
  197. if len(orphanedToolCalls) > 0 {
  198. toolCallParts = append(toolCallParts, orphanedToolCalls...)
  199. orphanedToolCalls = make([]opencode.ToolPart, 0)
  200. }
  201. remaining := true
  202. for _, part := range remainingParts {
  203. if !remaining {
  204. break
  205. }
  206. switch part := part.AsUnion().(type) {
  207. case opencode.TextPart:
  208. // we only want tool calls associated with the current text part.
  209. // if we hit another text part, we're done.
  210. remaining = false
  211. case opencode.ToolPart:
  212. toolCallParts = append(toolCallParts, part)
  213. if part.State.Status != opencode.ToolPartStateStatusCompleted || part.State.Status != opencode.ToolPartStateStatusError {
  214. // i don't think there's a case where a tool call isn't in result state
  215. // and the message time is 0, but just in case
  216. finished = false
  217. }
  218. }
  219. }
  220. if finished {
  221. key := m.cache.GenerateKey(casted.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
  222. content, cached = m.cache.Get(key)
  223. if !cached {
  224. content = renderText(
  225. m.app,
  226. message,
  227. p.Text,
  228. casted.ModelID,
  229. m.showToolDetails,
  230. m.partCount == m.selectedPart,
  231. width,
  232. "",
  233. toolCallParts...,
  234. )
  235. m.cache.Set(key, content)
  236. }
  237. } else {
  238. content = renderText(
  239. m.app,
  240. message,
  241. p.Text,
  242. casted.ModelID,
  243. m.showToolDetails,
  244. m.partCount == m.selectedPart,
  245. width,
  246. "",
  247. toolCallParts...,
  248. )
  249. }
  250. if content != "" {
  251. m = m.updateSelected(content, p.Text)
  252. blocks = append(blocks, content)
  253. }
  254. case opencode.ToolPart:
  255. if !m.showToolDetails {
  256. if !hasTextPart {
  257. orphanedToolCalls = append(orphanedToolCalls, part)
  258. }
  259. continue
  260. }
  261. if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
  262. key := m.cache.GenerateKey(casted.ID,
  263. part.ID,
  264. m.showToolDetails,
  265. width,
  266. m.partCount == m.selectedPart,
  267. )
  268. content, cached = m.cache.Get(key)
  269. if !cached {
  270. content = renderToolDetails(
  271. m.app,
  272. part,
  273. m.partCount == m.selectedPart,
  274. width,
  275. )
  276. m.cache.Set(key, content)
  277. }
  278. } else {
  279. // if the tool call isn't finished, don't cache
  280. content = renderToolDetails(
  281. m.app,
  282. part,
  283. m.partCount == m.selectedPart,
  284. width,
  285. )
  286. }
  287. if content != "" {
  288. m = m.updateSelected(content, "")
  289. blocks = append(blocks, content)
  290. }
  291. }
  292. }
  293. }
  294. error := ""
  295. if assistant, ok := message.(opencode.AssistantMessage); ok {
  296. switch err := assistant.Error.AsUnion().(type) {
  297. case nil:
  298. case opencode.AssistantMessageErrorMessageOutputLengthError:
  299. error = "Message output length exceeded"
  300. case opencode.ProviderAuthError:
  301. error = err.Data.Message
  302. case opencode.MessageAbortedError:
  303. error = "Request was aborted"
  304. case opencode.UnknownError:
  305. error = err.Data.Message
  306. }
  307. }
  308. if error != "" {
  309. error = styles.NewStyle().Width(width - 6).Render(error)
  310. error = renderContentBlock(
  311. m.app,
  312. error,
  313. false,
  314. width,
  315. WithBorderColor(t.Error()),
  316. )
  317. blocks = append(blocks, error)
  318. m.lineCount += lipgloss.Height(error) + 1
  319. }
  320. }
  321. m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
  322. if m.selectedPart == m.partCount {
  323. m.viewport.GotoBottom()
  324. }
  325. }
  326. func (m *messagesComponent) updateSelected(content string, selectedText string) *messagesComponent {
  327. if m.selectedPart == m.partCount {
  328. m.viewport.SetYOffset(m.lineCount - (m.viewport.Height() / 2) + 4)
  329. m.selectedText = selectedText
  330. }
  331. m.partCount++
  332. m.lineCount += lipgloss.Height(content) + 1
  333. return m
  334. }
  335. func (m *messagesComponent) header(width int) string {
  336. if m.app.Session.ID == "" {
  337. return ""
  338. }
  339. t := theme.CurrentTheme()
  340. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  341. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  342. headerLines := []string{}
  343. headerLines = append(
  344. headerLines,
  345. util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()),
  346. )
  347. share := ""
  348. if m.app.Session.Share.URL != "" {
  349. share = muted(m.app.Session.Share.URL + " /unshare")
  350. } else {
  351. share = base("/share") + muted(" to create a shareable link")
  352. }
  353. sessionInfo := ""
  354. tokens := float64(0)
  355. cost := float64(0)
  356. contextWindow := m.app.Model.Limit.Context
  357. for _, message := range m.app.Messages {
  358. if assistant, ok := message.(opencode.AssistantMessage); ok {
  359. cost += assistant.Cost
  360. usage := assistant.Tokens
  361. if usage.Output > 0 {
  362. if assistant.Summary {
  363. tokens = usage.Output
  364. continue
  365. }
  366. tokens = (usage.Input +
  367. usage.Cache.Write +
  368. usage.Cache.Read +
  369. usage.Output +
  370. usage.Reasoning)
  371. }
  372. }
  373. }
  374. // Check if current model is a subscription model (cost is 0 for both input and output)
  375. isSubscriptionModel := m.app.Model != nil &&
  376. m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
  377. sessionInfo = styles.NewStyle().
  378. Foreground(t.TextMuted()).
  379. Background(t.Background()).
  380. Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
  381. background := t.Background()
  382. share = layout.Render(
  383. layout.FlexOptions{
  384. Background: &background,
  385. Direction: layout.Row,
  386. Justify: layout.JustifySpaceBetween,
  387. Align: layout.AlignStretch,
  388. Width: width - 6,
  389. },
  390. layout.FlexItem{
  391. View: share,
  392. },
  393. layout.FlexItem{
  394. View: sessionInfo,
  395. },
  396. )
  397. headerLines = append(headerLines, share)
  398. header := strings.Join(headerLines, "\n")
  399. header = styles.NewStyle().
  400. Background(t.Background()).
  401. Width(width).
  402. PaddingLeft(2).
  403. PaddingRight(2).
  404. BorderLeft(true).
  405. BorderRight(true).
  406. BorderBackground(t.Background()).
  407. BorderForeground(t.BackgroundElement()).
  408. BorderStyle(lipgloss.ThickBorder()).
  409. Render(header)
  410. return "\n" + header + "\n"
  411. }
  412. func formatTokensAndCost(
  413. tokens float64,
  414. contextWindow float64,
  415. cost float64,
  416. isSubscriptionModel bool,
  417. ) string {
  418. // Format tokens in human-readable format (e.g., 110K, 1.2M)
  419. var formattedTokens string
  420. switch {
  421. case tokens >= 1_000_000:
  422. formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
  423. case tokens >= 1_000:
  424. formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
  425. default:
  426. formattedTokens = fmt.Sprintf("%d", int(tokens))
  427. }
  428. // Remove .0 suffix if present
  429. if strings.HasSuffix(formattedTokens, ".0K") {
  430. formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
  431. }
  432. if strings.HasSuffix(formattedTokens, ".0M") {
  433. formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
  434. }
  435. percentage := (float64(tokens) / float64(contextWindow)) * 100
  436. if isSubscriptionModel {
  437. return fmt.Sprintf(
  438. "%s/%d%%",
  439. formattedTokens,
  440. int(percentage),
  441. )
  442. }
  443. formattedCost := fmt.Sprintf("$%.2f", cost)
  444. return fmt.Sprintf(
  445. "%s/%d%% (%s)",
  446. formattedTokens,
  447. int(percentage),
  448. formattedCost,
  449. )
  450. }
  451. func (m *messagesComponent) View(width, height int) string {
  452. t := theme.CurrentTheme()
  453. if m.rendering {
  454. return lipgloss.Place(
  455. width,
  456. height,
  457. lipgloss.Center,
  458. lipgloss.Center,
  459. styles.NewStyle().Background(t.Background()).Render(""),
  460. styles.WhitespaceStyle(t.Background()),
  461. )
  462. }
  463. header := m.header(width)
  464. m.viewport.SetWidth(width)
  465. m.viewport.SetHeight(height - lipgloss.Height(header))
  466. return styles.NewStyle().
  467. Background(t.Background()).
  468. Render(header + "\n" + m.viewport.View())
  469. }
  470. func (m *messagesComponent) SetWidth(width int) tea.Cmd {
  471. if m.width == width {
  472. return nil
  473. }
  474. // Clear cache on resize since width affects rendering
  475. if m.width != width {
  476. m.cache.Clear()
  477. }
  478. m.width = width
  479. m.viewport.SetWidth(width)
  480. m.renderView(width)
  481. return nil
  482. }
  483. func (m *messagesComponent) Reload() tea.Cmd {
  484. return func() tea.Msg {
  485. m.renderView(m.width)
  486. return renderFinishedMsg{}
  487. }
  488. }
  489. func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
  490. m.viewport.ViewUp()
  491. return m, nil
  492. }
  493. func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
  494. m.viewport.ViewDown()
  495. return m, nil
  496. }
  497. func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
  498. m.viewport.HalfViewUp()
  499. return m, nil
  500. }
  501. func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
  502. m.viewport.HalfViewDown()
  503. return m, nil
  504. }
  505. func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) {
  506. m.tail = false
  507. if m.selectedPart < 0 {
  508. m.selectedPart = m.partCount
  509. }
  510. m.selectedPart--
  511. if m.selectedPart < 0 {
  512. m.selectedPart = 0
  513. }
  514. return m, util.CmdHandler(selectedMessagePartChangedMsg{
  515. part: m.selectedPart,
  516. })
  517. }
  518. func (m *messagesComponent) Next() (tea.Model, tea.Cmd) {
  519. m.tail = false
  520. m.selectedPart++
  521. if m.selectedPart >= m.partCount {
  522. m.selectedPart = m.partCount
  523. }
  524. return m, util.CmdHandler(selectedMessagePartChangedMsg{
  525. part: m.selectedPart,
  526. })
  527. }
  528. func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
  529. m.selectedPart = 0
  530. m.tail = false
  531. return m, util.CmdHandler(selectedMessagePartChangedMsg{
  532. part: m.selectedPart,
  533. })
  534. }
  535. func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
  536. m.selectedPart = m.partCount - 1
  537. m.tail = true
  538. return m, util.CmdHandler(selectedMessagePartChangedMsg{
  539. part: m.selectedPart,
  540. })
  541. }
  542. func (m *messagesComponent) ToolDetailsVisible() bool {
  543. return m.showToolDetails
  544. }
  545. func NewMessagesComponent(app *app.App) MessagesComponent {
  546. vp := viewport.New()
  547. vp.KeyMap = viewport.KeyMap{}
  548. return &messagesComponent{
  549. app: app,
  550. viewport: vp,
  551. showToolDetails: true,
  552. cache: NewMessageCache(),
  553. tail: true,
  554. selectedPart: -1,
  555. }
  556. }