messages.go 12 KB

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