messages.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. package chat
  2. import (
  3. "context"
  4. "fmt"
  5. "math"
  6. "time"
  7. "github.com/charmbracelet/bubbles/key"
  8. "github.com/charmbracelet/bubbles/spinner"
  9. "github.com/charmbracelet/bubbles/viewport"
  10. tea "github.com/charmbracelet/bubbletea"
  11. "github.com/charmbracelet/lipgloss"
  12. "github.com/sst/opencode/internal/app"
  13. "github.com/sst/opencode/internal/message"
  14. "github.com/sst/opencode/internal/pubsub"
  15. "github.com/sst/opencode/internal/session"
  16. "github.com/sst/opencode/internal/status"
  17. "github.com/sst/opencode/internal/tui/components/dialog"
  18. "github.com/sst/opencode/internal/tui/state"
  19. "github.com/sst/opencode/internal/tui/styles"
  20. "github.com/sst/opencode/internal/tui/theme"
  21. )
  22. type cacheItem struct {
  23. width int
  24. content []uiMessage
  25. }
  26. type messagesCmp struct {
  27. app *app.App
  28. width, height int
  29. viewport viewport.Model
  30. messages []message.Message
  31. uiMessages []uiMessage
  32. currentMsgID string
  33. cachedContent map[string]cacheItem
  34. spinner spinner.Model
  35. rendering bool
  36. attachments viewport.Model
  37. showToolMessages bool
  38. }
  39. type renderFinishedMsg struct{}
  40. type ToggleToolMessagesMsg struct{}
  41. type MessageKeys struct {
  42. PageDown key.Binding
  43. PageUp key.Binding
  44. HalfPageUp key.Binding
  45. HalfPageDown key.Binding
  46. }
  47. var messageKeys = MessageKeys{
  48. PageDown: key.NewBinding(
  49. key.WithKeys("pgdown"),
  50. key.WithHelp("f/pgdn", "page down"),
  51. ),
  52. PageUp: key.NewBinding(
  53. key.WithKeys("pgup"),
  54. key.WithHelp("b/pgup", "page up"),
  55. ),
  56. HalfPageUp: key.NewBinding(
  57. key.WithKeys("ctrl+u"),
  58. key.WithHelp("ctrl+u", "½ page up"),
  59. ),
  60. HalfPageDown: key.NewBinding(
  61. key.WithKeys("ctrl+d", "ctrl+d"),
  62. key.WithHelp("ctrl+d", "½ page down"),
  63. ),
  64. }
  65. func (m *messagesCmp) Init() tea.Cmd {
  66. return tea.Batch(m.viewport.Init(), m.spinner.Tick)
  67. }
  68. func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  69. var cmds []tea.Cmd
  70. switch msg := msg.(type) {
  71. case dialog.ThemeChangedMsg:
  72. m.rerender()
  73. return m, nil
  74. case ToggleToolMessagesMsg:
  75. m.showToolMessages = !m.showToolMessages
  76. // Clear the cache to force re-rendering of all messages
  77. m.cachedContent = make(map[string]cacheItem)
  78. m.renderView()
  79. return m, nil
  80. case state.SessionSelectedMsg:
  81. cmd := m.Reload(msg)
  82. return m, cmd
  83. case state.SessionClearedMsg:
  84. m.messages = make([]message.Message, 0)
  85. m.currentMsgID = ""
  86. m.rendering = false
  87. return m, nil
  88. case tea.KeyMsg:
  89. if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
  90. key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
  91. u, cmd := m.viewport.Update(msg)
  92. m.viewport = u
  93. cmds = append(cmds, cmd)
  94. }
  95. case renderFinishedMsg:
  96. m.rendering = false
  97. m.viewport.GotoBottom()
  98. case pubsub.Event[message.Message]:
  99. needsRerender := false
  100. if msg.Type == message.EventMessageCreated {
  101. if msg.Payload.SessionID == m.app.CurrentSession.ID {
  102. messageExists := false
  103. for _, v := range m.messages {
  104. if v.ID == msg.Payload.ID {
  105. messageExists = true
  106. break
  107. }
  108. }
  109. if !messageExists {
  110. if len(m.messages) > 0 {
  111. lastMsgID := m.messages[len(m.messages)-1].ID
  112. delete(m.cachedContent, lastMsgID)
  113. }
  114. m.messages = append(m.messages, msg.Payload)
  115. delete(m.cachedContent, m.currentMsgID)
  116. m.currentMsgID = msg.Payload.ID
  117. needsRerender = true
  118. }
  119. }
  120. // There are tool calls from the child task
  121. for _, v := range m.messages {
  122. for _, c := range v.ToolCalls() {
  123. if c.ID == msg.Payload.SessionID {
  124. delete(m.cachedContent, v.ID)
  125. needsRerender = true
  126. }
  127. }
  128. }
  129. } else if msg.Type == message.EventMessageUpdated && msg.Payload.SessionID == m.app.CurrentSession.ID {
  130. for i, v := range m.messages {
  131. if v.ID == msg.Payload.ID {
  132. m.messages[i] = msg.Payload
  133. delete(m.cachedContent, msg.Payload.ID)
  134. needsRerender = true
  135. break
  136. }
  137. }
  138. }
  139. if needsRerender {
  140. m.renderView()
  141. if len(m.messages) > 0 {
  142. if (msg.Type == message.EventMessageCreated) ||
  143. (msg.Type == message.EventMessageUpdated && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
  144. m.viewport.GotoBottom()
  145. }
  146. }
  147. }
  148. }
  149. spinner, cmd := m.spinner.Update(msg)
  150. m.spinner = spinner
  151. cmds = append(cmds, cmd)
  152. return m, tea.Batch(cmds...)
  153. }
  154. func (m *messagesCmp) IsAgentWorking() bool {
  155. return m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID)
  156. }
  157. func formatTimeDifference(unixTime1, unixTime2 int64) string {
  158. diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
  159. if diffSeconds < 60 {
  160. return fmt.Sprintf("%.1fs", diffSeconds)
  161. }
  162. minutes := int(diffSeconds / 60)
  163. seconds := int(diffSeconds) % 60
  164. return fmt.Sprintf("%dm%ds", minutes, seconds)
  165. }
  166. func (m *messagesCmp) renderView() {
  167. m.uiMessages = make([]uiMessage, 0)
  168. pos := 0
  169. baseStyle := styles.BaseStyle()
  170. if m.width == 0 {
  171. return
  172. }
  173. for inx, msg := range m.messages {
  174. switch msg.Role {
  175. case message.User:
  176. if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
  177. m.uiMessages = append(m.uiMessages, cache.content...)
  178. continue
  179. }
  180. userMsg := renderUserMessage(
  181. msg,
  182. msg.ID == m.currentMsgID,
  183. m.width,
  184. pos,
  185. )
  186. m.uiMessages = append(m.uiMessages, userMsg)
  187. m.cachedContent[msg.ID] = cacheItem{
  188. width: m.width,
  189. content: []uiMessage{userMsg},
  190. }
  191. pos += userMsg.height + 1 // + 1 for spacing
  192. case message.Assistant:
  193. if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
  194. m.uiMessages = append(m.uiMessages, cache.content...)
  195. continue
  196. }
  197. assistantMessages := renderAssistantMessage(
  198. msg,
  199. inx,
  200. m.messages,
  201. m.app.Messages,
  202. m.currentMsgID,
  203. m.width,
  204. pos,
  205. m.showToolMessages,
  206. )
  207. for _, msg := range assistantMessages {
  208. m.uiMessages = append(m.uiMessages, msg)
  209. pos += msg.height + 1 // + 1 for spacing
  210. }
  211. m.cachedContent[msg.ID] = cacheItem{
  212. width: m.width,
  213. content: assistantMessages,
  214. }
  215. }
  216. }
  217. messages := make([]string, 0)
  218. for _, v := range m.uiMessages {
  219. messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
  220. baseStyle.
  221. Width(m.width).
  222. Render(
  223. "",
  224. ),
  225. )
  226. }
  227. m.viewport.SetContent(
  228. baseStyle.
  229. Width(m.width).
  230. Render(
  231. lipgloss.JoinVertical(
  232. lipgloss.Top,
  233. messages...,
  234. ),
  235. ),
  236. )
  237. }
  238. func (m *messagesCmp) View() string {
  239. baseStyle := styles.BaseStyle()
  240. if m.rendering {
  241. return baseStyle.
  242. Width(m.width).
  243. Render(
  244. lipgloss.JoinVertical(
  245. lipgloss.Top,
  246. "Loading...",
  247. m.working(),
  248. m.help(),
  249. ),
  250. )
  251. }
  252. if len(m.messages) == 0 {
  253. content := baseStyle.
  254. Width(m.width).
  255. Height(m.height - 1).
  256. Render(
  257. m.initialScreen(),
  258. )
  259. return baseStyle.
  260. Width(m.width).
  261. Render(
  262. lipgloss.JoinVertical(
  263. lipgloss.Top,
  264. content,
  265. "",
  266. m.help(),
  267. ),
  268. )
  269. }
  270. return baseStyle.
  271. Width(m.width).
  272. Render(
  273. lipgloss.JoinVertical(
  274. lipgloss.Top,
  275. m.viewport.View(),
  276. m.working(),
  277. m.help(),
  278. ),
  279. )
  280. }
  281. func hasToolsWithoutResponse(messages []message.Message) bool {
  282. toolCalls := make([]message.ToolCall, 0)
  283. toolResults := make([]message.ToolResult, 0)
  284. for _, m := range messages {
  285. toolCalls = append(toolCalls, m.ToolCalls()...)
  286. toolResults = append(toolResults, m.ToolResults()...)
  287. }
  288. for _, v := range toolCalls {
  289. found := false
  290. for _, r := range toolResults {
  291. if v.ID == r.ToolCallID {
  292. found = true
  293. break
  294. }
  295. }
  296. if !found && v.Finished {
  297. return true
  298. }
  299. }
  300. return false
  301. }
  302. func hasUnfinishedToolCalls(messages []message.Message) bool {
  303. toolCalls := make([]message.ToolCall, 0)
  304. for _, m := range messages {
  305. toolCalls = append(toolCalls, m.ToolCalls()...)
  306. }
  307. for _, v := range toolCalls {
  308. if !v.Finished {
  309. return true
  310. }
  311. }
  312. return false
  313. }
  314. func (m *messagesCmp) working() string {
  315. text := ""
  316. if m.IsAgentWorking() && len(m.messages) > 0 {
  317. t := theme.CurrentTheme()
  318. baseStyle := styles.BaseStyle()
  319. task := "Thinking..."
  320. lastMessage := m.messages[len(m.messages)-1]
  321. if hasToolsWithoutResponse(m.messages) {
  322. task = "Waiting for tool response..."
  323. } else if hasUnfinishedToolCalls(m.messages) {
  324. task = "Building tool call..."
  325. } else if !lastMessage.IsFinished() {
  326. task = "Generating..."
  327. }
  328. if task != "" {
  329. text += baseStyle.
  330. Width(m.width).
  331. Foreground(t.Primary()).
  332. Bold(true).
  333. Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
  334. }
  335. }
  336. return text
  337. }
  338. func (m *messagesCmp) help() string {
  339. t := theme.CurrentTheme()
  340. baseStyle := styles.BaseStyle()
  341. text := ""
  342. if m.app.PrimaryAgent.IsBusy() {
  343. text += lipgloss.JoinHorizontal(
  344. lipgloss.Left,
  345. baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
  346. baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
  347. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
  348. )
  349. } else {
  350. text += lipgloss.JoinHorizontal(
  351. lipgloss.Left,
  352. baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
  353. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"),
  354. baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
  355. baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
  356. baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
  357. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
  358. baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
  359. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
  360. baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
  361. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
  362. )
  363. }
  364. return baseStyle.
  365. Width(m.width).
  366. Render(text)
  367. }
  368. func (m *messagesCmp) initialScreen() string {
  369. baseStyle := styles.BaseStyle()
  370. return baseStyle.Width(m.width).Render(
  371. lipgloss.JoinVertical(
  372. lipgloss.Top,
  373. header(m.width),
  374. "",
  375. lspsConfigured(m.width),
  376. ),
  377. )
  378. }
  379. func (m *messagesCmp) rerender() {
  380. for _, msg := range m.messages {
  381. delete(m.cachedContent, msg.ID)
  382. }
  383. m.renderView()
  384. }
  385. func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
  386. if m.width == width && m.height == height {
  387. return nil
  388. }
  389. m.width = width
  390. m.height = height
  391. m.viewport.Width = width
  392. m.viewport.Height = height - 2
  393. m.attachments.Width = width + 40
  394. m.attachments.Height = 3
  395. m.rerender()
  396. return nil
  397. }
  398. func (m *messagesCmp) GetSize() (int, int) {
  399. return m.width, m.height
  400. }
  401. func (m *messagesCmp) Reload(session *session.Session) tea.Cmd {
  402. messages, err := m.app.Messages.List(context.Background(), session.ID)
  403. if err != nil {
  404. status.Error(err.Error())
  405. return nil
  406. }
  407. m.messages = messages
  408. if len(m.messages) > 0 {
  409. m.currentMsgID = m.messages[len(m.messages)-1].ID
  410. }
  411. delete(m.cachedContent, m.currentMsgID)
  412. m.rendering = true
  413. return func() tea.Msg {
  414. m.renderView()
  415. return renderFinishedMsg{}
  416. }
  417. }
  418. func (m *messagesCmp) BindingKeys() []key.Binding {
  419. return []key.Binding{
  420. m.viewport.KeyMap.PageDown,
  421. m.viewport.KeyMap.PageUp,
  422. m.viewport.KeyMap.HalfPageUp,
  423. m.viewport.KeyMap.HalfPageDown,
  424. }
  425. }
  426. func NewMessagesCmp(app *app.App) tea.Model {
  427. customSpinner := spinner.Spinner{
  428. Frames: []string{" ", "┃", "┃"},
  429. FPS: time.Second / 3,
  430. }
  431. s := spinner.New(spinner.WithSpinner(customSpinner))
  432. vp := viewport.New(0, 0)
  433. attachmets := viewport.New(0, 0)
  434. vp.KeyMap.PageUp = messageKeys.PageUp
  435. vp.KeyMap.PageDown = messageKeys.PageDown
  436. vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
  437. vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
  438. return &messagesCmp{
  439. app: app,
  440. cachedContent: make(map[string]cacheItem),
  441. viewport: vp,
  442. spinner: s,
  443. attachments: attachmets,
  444. showToolMessages: true,
  445. }
  446. }