messages.go 11 KB

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