messages.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. package chat
  2. import (
  3. "slices"
  4. "strings"
  5. "time"
  6. "github.com/charmbracelet/bubbles/v2/spinner"
  7. "github.com/charmbracelet/bubbles/v2/viewport"
  8. tea "github.com/charmbracelet/bubbletea/v2"
  9. "github.com/charmbracelet/lipgloss/v2"
  10. "github.com/sst/opencode/internal/app"
  11. "github.com/sst/opencode/internal/components/commands"
  12. "github.com/sst/opencode/internal/components/dialog"
  13. "github.com/sst/opencode/internal/layout"
  14. "github.com/sst/opencode/internal/styles"
  15. "github.com/sst/opencode/internal/theme"
  16. "github.com/sst/opencode/pkg/client"
  17. )
  18. type MessagesComponent interface {
  19. tea.Model
  20. tea.ViewModel
  21. PageUp() (tea.Model, tea.Cmd)
  22. PageDown() (tea.Model, tea.Cmd)
  23. HalfPageUp() (tea.Model, tea.Cmd)
  24. HalfPageDown() (tea.Model, tea.Cmd)
  25. First() (tea.Model, tea.Cmd)
  26. Last() (tea.Model, tea.Cmd)
  27. // Previous() (tea.Model, tea.Cmd)
  28. // Next() (tea.Model, tea.Cmd)
  29. ToolDetailsVisible() bool
  30. }
  31. type messagesComponent struct {
  32. width, height int
  33. app *app.App
  34. viewport viewport.Model
  35. spinner spinner.Model
  36. attachments viewport.Model
  37. commands commands.CommandsComponent
  38. cache *MessageCache
  39. rendering bool
  40. showToolDetails bool
  41. tail bool
  42. }
  43. type renderFinishedMsg struct{}
  44. type ToggleToolDetailsMsg struct{}
  45. func (m *messagesComponent) Init() tea.Cmd {
  46. return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.commands.Init())
  47. }
  48. func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  49. var cmds []tea.Cmd
  50. switch msg.(type) {
  51. case app.SendMsg:
  52. m.viewport.GotoBottom()
  53. m.tail = true
  54. return m, nil
  55. case dialog.ThemeSelectedMsg:
  56. m.cache.Clear()
  57. return m, m.Reload()
  58. case ToggleToolDetailsMsg:
  59. m.showToolDetails = !m.showToolDetails
  60. return m, m.Reload()
  61. case app.SessionSelectedMsg:
  62. m.cache.Clear()
  63. m.tail = true
  64. return m, m.Reload()
  65. case app.SessionClearedMsg:
  66. m.cache.Clear()
  67. cmd := m.Reload()
  68. return m, cmd
  69. case renderFinishedMsg:
  70. m.rendering = false
  71. if m.tail {
  72. m.viewport.GotoBottom()
  73. }
  74. case client.EventSessionUpdated, client.EventMessageUpdated:
  75. m.renderView()
  76. if m.tail {
  77. m.viewport.GotoBottom()
  78. }
  79. }
  80. viewport, cmd := m.viewport.Update(msg)
  81. m.viewport = viewport
  82. m.tail = m.viewport.AtBottom()
  83. cmds = append(cmds, cmd)
  84. spinner, cmd := m.spinner.Update(msg)
  85. m.spinner = spinner
  86. cmds = append(cmds, cmd)
  87. updated, cmd := m.commands.Update(msg)
  88. m.commands = updated.(commands.CommandsComponent)
  89. cmds = append(cmds, cmd)
  90. return m, tea.Batch(cmds...)
  91. }
  92. type blockType int
  93. const (
  94. none blockType = iota
  95. userTextBlock
  96. assistantTextBlock
  97. toolInvocationBlock
  98. errorBlock
  99. )
  100. func (m *messagesComponent) renderView() {
  101. if m.width == 0 {
  102. return
  103. }
  104. t := theme.CurrentTheme()
  105. blocks := make([]string, 0)
  106. previousBlockType := none
  107. for _, message := range m.app.Messages {
  108. var content string
  109. var cached bool
  110. lastToolIndex := 0
  111. lastToolIndices := []int{}
  112. for i, p := range message.Parts {
  113. part, _ := p.ValueByDiscriminator()
  114. switch part.(type) {
  115. case client.MessagePartText:
  116. lastToolIndices = append(lastToolIndices, lastToolIndex)
  117. case client.MessagePartToolInvocation:
  118. lastToolIndex = i
  119. }
  120. }
  121. author := ""
  122. switch message.Role {
  123. case client.User:
  124. author = m.app.Info.User
  125. case client.Assistant:
  126. author = message.Metadata.Assistant.ModelID
  127. }
  128. for i, p := range message.Parts {
  129. part, err := p.ValueByDiscriminator()
  130. if err != nil {
  131. continue //TODO: handle error?
  132. }
  133. switch part.(type) {
  134. // case client.MessagePartStepStart:
  135. // messages = append(messages, "")
  136. case client.MessagePartText:
  137. text := part.(client.MessagePartText)
  138. key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
  139. content, cached = m.cache.Get(key)
  140. if !cached {
  141. content = renderText(message, text.Text, author)
  142. m.cache.Set(key, content)
  143. }
  144. if previousBlockType != none {
  145. blocks = append(blocks, "")
  146. }
  147. blocks = append(blocks, content)
  148. if message.Role == client.User {
  149. previousBlockType = userTextBlock
  150. } else if message.Role == client.Assistant {
  151. previousBlockType = assistantTextBlock
  152. }
  153. case client.MessagePartToolInvocation:
  154. isLastToolInvocation := slices.Contains(lastToolIndices, i)
  155. toolInvocationPart := part.(client.MessagePartToolInvocation)
  156. toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
  157. metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
  158. if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
  159. metadata = message.Metadata.Tool[toolCall.ToolCallId]
  160. }
  161. var result *string
  162. resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
  163. if resultError == nil {
  164. result = &resultPart.Result
  165. }
  166. if toolCall.State == "result" {
  167. key := m.cache.GenerateKey(message.Id,
  168. toolCall.ToolCallId,
  169. m.showToolDetails,
  170. layout.Current.Viewport.Width,
  171. )
  172. content, cached = m.cache.Get(key)
  173. if !cached {
  174. content = renderToolInvocation(
  175. toolCall,
  176. result,
  177. metadata,
  178. m.showToolDetails,
  179. isLastToolInvocation,
  180. false,
  181. )
  182. m.cache.Set(key, content)
  183. }
  184. } else {
  185. // if the tool call isn't finished, don't cache
  186. content = renderToolInvocation(
  187. toolCall,
  188. result,
  189. metadata,
  190. m.showToolDetails,
  191. isLastToolInvocation,
  192. false,
  193. )
  194. }
  195. if previousBlockType != toolInvocationBlock && m.showToolDetails {
  196. blocks = append(blocks, "")
  197. }
  198. blocks = append(blocks, content)
  199. previousBlockType = toolInvocationBlock
  200. }
  201. }
  202. error := ""
  203. if message.Metadata.Error != nil {
  204. errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
  205. switch errorValue.(type) {
  206. case client.UnknownError:
  207. clientError := errorValue.(client.UnknownError)
  208. error = clientError.Data.Message
  209. error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
  210. blocks = append(blocks, error)
  211. previousBlockType = errorBlock
  212. }
  213. }
  214. }
  215. centered := []string{}
  216. for _, block := range blocks {
  217. centered = append(centered, lipgloss.PlaceHorizontal(
  218. m.width,
  219. lipgloss.Center,
  220. block,
  221. lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
  222. ))
  223. }
  224. m.viewport.SetHeight(m.height - lipgloss.Height(m.header()))
  225. m.viewport.SetContent("\n" + strings.Join(centered, "\n") + "\n")
  226. }
  227. func (m *messagesComponent) header() string {
  228. if m.app.Session.Id == "" {
  229. return ""
  230. }
  231. t := theme.CurrentTheme()
  232. width := layout.Current.Container.Width
  233. base := styles.BaseStyle().Background(t.Background()).Render
  234. muted := styles.Muted().Background(t.Background()).Render
  235. headerLines := []string{}
  236. headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
  237. if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
  238. headerLines = append(headerLines, muted(m.app.Session.Share.Url))
  239. } else {
  240. headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
  241. }
  242. header := strings.Join(headerLines, "\n")
  243. header = styles.BaseStyle().
  244. Width(width).
  245. PaddingLeft(2).
  246. PaddingRight(2).
  247. Background(t.Background()).
  248. BorderLeft(true).
  249. BorderRight(true).
  250. BorderBackground(t.Background()).
  251. BorderForeground(t.BackgroundElement()).
  252. BorderStyle(lipgloss.ThickBorder()).
  253. Render(header)
  254. return "\n" + header + "\n"
  255. }
  256. func (m *messagesComponent) View() string {
  257. if len(m.app.Messages) == 0 {
  258. return m.home()
  259. }
  260. if m.rendering {
  261. return lipgloss.Place(
  262. m.width,
  263. m.height,
  264. lipgloss.Center,
  265. lipgloss.Center,
  266. "Loading session...",
  267. )
  268. }
  269. t := theme.CurrentTheme()
  270. return lipgloss.JoinVertical(
  271. lipgloss.Left,
  272. lipgloss.PlaceHorizontal(
  273. m.width,
  274. lipgloss.Center,
  275. m.header(),
  276. lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
  277. ),
  278. m.viewport.View(),
  279. )
  280. }
  281. func (m *messagesComponent) home() string {
  282. t := theme.CurrentTheme()
  283. baseStyle := styles.BaseStyle().Background(t.Background())
  284. base := baseStyle.Render
  285. muted := styles.Muted().Background(t.Background()).Render
  286. open := `
  287. █▀▀█ █▀▀█ █▀▀ █▀▀▄
  288. █░░█ █░░█ █▀▀ █░░█
  289. ▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `
  290. code := `
  291. █▀▀ █▀▀█ █▀▀▄ █▀▀
  292. █░░ █░░█ █░░█ █▀▀
  293. ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`
  294. logo := lipgloss.JoinHorizontal(
  295. lipgloss.Top,
  296. muted(open),
  297. base(code),
  298. )
  299. // cwd := app.Info.Path.Cwd
  300. // config := app.Info.Path.Config
  301. versionStyle := lipgloss.NewStyle().
  302. Background(t.Background()).
  303. Foreground(t.TextMuted()).
  304. Width(lipgloss.Width(logo)).
  305. Align(lipgloss.Right)
  306. version := versionStyle.Render(m.app.Version)
  307. logoAndVersion := strings.Join([]string{logo, version}, "\n")
  308. logoAndVersion = lipgloss.PlaceHorizontal(
  309. m.width,
  310. lipgloss.Center,
  311. logoAndVersion,
  312. lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
  313. )
  314. m.commands.SetBackgroundColor(t.Background())
  315. commands := lipgloss.PlaceHorizontal(
  316. m.width,
  317. lipgloss.Center,
  318. m.commands.View(),
  319. lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
  320. )
  321. lines := []string{}
  322. lines = append(lines, logoAndVersion)
  323. lines = append(lines, "")
  324. lines = append(lines, "")
  325. // lines = append(lines, base("cwd ")+muted(cwd))
  326. // lines = append(lines, base("config ")+muted(config))
  327. // lines = append(lines, "")
  328. lines = append(lines, commands)
  329. return lipgloss.Place(
  330. m.width,
  331. m.height,
  332. lipgloss.Center,
  333. lipgloss.Center,
  334. baseStyle.Render(strings.Join(lines, "\n")),
  335. lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
  336. )
  337. }
  338. func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
  339. if m.width == width && m.height == height {
  340. return nil
  341. }
  342. // Clear cache on resize since width affects rendering
  343. if m.width != width {
  344. m.cache.Clear()
  345. }
  346. m.width = width
  347. m.height = height
  348. m.viewport.SetWidth(width)
  349. m.viewport.SetHeight(height - lipgloss.Height(m.header()))
  350. m.attachments.SetWidth(width + 40)
  351. m.attachments.SetHeight(3)
  352. m.commands.SetSize(width, height)
  353. m.renderView()
  354. return nil
  355. }
  356. func (m *messagesComponent) GetSize() (int, int) {
  357. return m.width, m.height
  358. }
  359. func (m *messagesComponent) Reload() tea.Cmd {
  360. m.rendering = true
  361. return func() tea.Msg {
  362. m.renderView()
  363. return renderFinishedMsg{}
  364. }
  365. }
  366. func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
  367. m.viewport.ViewUp()
  368. return m, nil
  369. }
  370. func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
  371. m.viewport.ViewDown()
  372. return m, nil
  373. }
  374. func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
  375. m.viewport.HalfViewUp()
  376. return m, nil
  377. }
  378. func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
  379. m.viewport.HalfViewDown()
  380. return m, nil
  381. }
  382. func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
  383. m.viewport.GotoTop()
  384. m.tail = false
  385. return m, nil
  386. }
  387. func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
  388. m.viewport.GotoBottom()
  389. m.tail = true
  390. return m, nil
  391. }
  392. func (m *messagesComponent) ToolDetailsVisible() bool {
  393. return m.showToolDetails
  394. }
  395. func NewMessagesComponent(app *app.App) MessagesComponent {
  396. customSpinner := spinner.Spinner{
  397. Frames: []string{" ", "┃", "┃"},
  398. FPS: time.Second / 3,
  399. }
  400. s := spinner.New(spinner.WithSpinner(customSpinner))
  401. vp := viewport.New()
  402. attachments := viewport.New()
  403. vp.KeyMap = viewport.KeyMap{}
  404. t := theme.CurrentTheme()
  405. commandsView := commands.New(
  406. app,
  407. commands.WithBackground(t.Background()),
  408. commands.WithLimit(6),
  409. )
  410. return &messagesComponent{
  411. app: app,
  412. viewport: vp,
  413. spinner: s,
  414. attachments: attachments,
  415. commands: commandsView,
  416. showToolDetails: true,
  417. cache: NewMessageCache(),
  418. tail: true,
  419. }
  420. }