messages.go 11 KB

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