list.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  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/opencode-ai/opencode/internal/app"
  13. "github.com/opencode-ai/opencode/internal/message"
  14. "github.com/opencode-ai/opencode/internal/pubsub"
  15. "github.com/opencode-ai/opencode/internal/session"
  16. "github.com/opencode-ai/opencode/internal/tui/components/dialog"
  17. "github.com/opencode-ai/opencode/internal/tui/styles"
  18. "github.com/opencode-ai/opencode/internal/tui/theme"
  19. "github.com/opencode-ai/opencode/internal/tui/util"
  20. )
  21. type cacheItem struct {
  22. width int
  23. content []uiMessage
  24. }
  25. type messagesCmp struct {
  26. app *app.App
  27. width, height int
  28. viewport viewport.Model
  29. session session.Session
  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 SessionSelectedMsg:
  81. if msg.ID != m.session.ID {
  82. cmd := m.SetSession(msg)
  83. return m, cmd
  84. }
  85. return m, nil
  86. case SessionClearedMsg:
  87. m.session = session.Session{}
  88. m.messages = make([]message.Message, 0)
  89. m.currentMsgID = ""
  90. m.rendering = false
  91. return m, nil
  92. case tea.KeyMsg:
  93. if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
  94. key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
  95. u, cmd := m.viewport.Update(msg)
  96. m.viewport = u
  97. cmds = append(cmds, cmd)
  98. }
  99. case renderFinishedMsg:
  100. m.rendering = false
  101. m.viewport.GotoBottom()
  102. case pubsub.Event[message.Message]:
  103. needsRerender := false
  104. if msg.Type == pubsub.CreatedEvent {
  105. if msg.Payload.SessionID == m.session.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 == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.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 == pubsub.CreatedEvent) ||
  147. (msg.Type == pubsub.UpdatedEvent && 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.CoderAgent.IsSessionBusy(m.session.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. m.viewport.SetContent(
  232. baseStyle.
  233. Width(m.width).
  234. Render(
  235. lipgloss.JoinVertical(
  236. lipgloss.Top,
  237. messages...,
  238. ),
  239. ),
  240. )
  241. }
  242. func (m *messagesCmp) View() string {
  243. baseStyle := styles.BaseStyle()
  244. if m.rendering {
  245. return baseStyle.
  246. Width(m.width).
  247. Render(
  248. lipgloss.JoinVertical(
  249. lipgloss.Top,
  250. "Loading...",
  251. m.working(),
  252. m.help(),
  253. ),
  254. )
  255. }
  256. if len(m.messages) == 0 {
  257. content := baseStyle.
  258. Width(m.width).
  259. Height(m.height - 1).
  260. Render(
  261. m.initialScreen(),
  262. )
  263. return baseStyle.
  264. Width(m.width).
  265. Render(
  266. lipgloss.JoinVertical(
  267. lipgloss.Top,
  268. content,
  269. "",
  270. m.help(),
  271. ),
  272. )
  273. }
  274. return baseStyle.
  275. Width(m.width).
  276. Render(
  277. lipgloss.JoinVertical(
  278. lipgloss.Top,
  279. m.viewport.View(),
  280. m.working(),
  281. m.help(),
  282. ),
  283. )
  284. }
  285. func hasToolsWithoutResponse(messages []message.Message) bool {
  286. toolCalls := make([]message.ToolCall, 0)
  287. toolResults := make([]message.ToolResult, 0)
  288. for _, m := range messages {
  289. toolCalls = append(toolCalls, m.ToolCalls()...)
  290. toolResults = append(toolResults, m.ToolResults()...)
  291. }
  292. for _, v := range toolCalls {
  293. found := false
  294. for _, r := range toolResults {
  295. if v.ID == r.ToolCallID {
  296. found = true
  297. break
  298. }
  299. }
  300. if !found && v.Finished {
  301. return true
  302. }
  303. }
  304. return false
  305. }
  306. func hasUnfinishedToolCalls(messages []message.Message) bool {
  307. toolCalls := make([]message.ToolCall, 0)
  308. for _, m := range messages {
  309. toolCalls = append(toolCalls, m.ToolCalls()...)
  310. }
  311. for _, v := range toolCalls {
  312. if !v.Finished {
  313. return true
  314. }
  315. }
  316. return false
  317. }
  318. func (m *messagesCmp) working() string {
  319. text := ""
  320. if m.IsAgentWorking() && len(m.messages) > 0 {
  321. t := theme.CurrentTheme()
  322. baseStyle := styles.BaseStyle()
  323. task := "Thinking..."
  324. lastMessage := m.messages[len(m.messages)-1]
  325. if hasToolsWithoutResponse(m.messages) {
  326. task = "Waiting for tool response..."
  327. } else if hasUnfinishedToolCalls(m.messages) {
  328. task = "Building tool call..."
  329. } else if !lastMessage.IsFinished() {
  330. task = "Generating..."
  331. }
  332. if task != "" {
  333. text += baseStyle.
  334. Width(m.width).
  335. Foreground(t.Primary()).
  336. Bold(true).
  337. Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
  338. }
  339. }
  340. return text
  341. }
  342. func (m *messagesCmp) help() string {
  343. t := theme.CurrentTheme()
  344. baseStyle := styles.BaseStyle()
  345. text := ""
  346. if m.app.CoderAgent.IsBusy() {
  347. text += lipgloss.JoinHorizontal(
  348. lipgloss.Left,
  349. baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
  350. baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
  351. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
  352. )
  353. } else {
  354. text += lipgloss.JoinHorizontal(
  355. lipgloss.Left,
  356. baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
  357. baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
  358. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"),
  359. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"),
  360. baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
  361. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line,"),
  362. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" press"),
  363. baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
  364. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
  365. )
  366. }
  367. return baseStyle.
  368. Width(m.width).
  369. Render(text)
  370. }
  371. func (m *messagesCmp) initialScreen() string {
  372. baseStyle := styles.BaseStyle()
  373. return baseStyle.Width(m.width).Render(
  374. lipgloss.JoinVertical(
  375. lipgloss.Top,
  376. header(m.width),
  377. "",
  378. lspsConfigured(m.width),
  379. ),
  380. )
  381. }
  382. func (m *messagesCmp) rerender() {
  383. for _, msg := range m.messages {
  384. delete(m.cachedContent, msg.ID)
  385. }
  386. m.renderView()
  387. }
  388. func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
  389. if m.width == width && m.height == height {
  390. return nil
  391. }
  392. m.width = width
  393. m.height = height
  394. m.viewport.Width = width
  395. m.viewport.Height = height - 2
  396. m.attachments.Width = width + 40
  397. m.attachments.Height = 3
  398. m.rerender()
  399. return nil
  400. }
  401. func (m *messagesCmp) GetSize() (int, int) {
  402. return m.width, m.height
  403. }
  404. func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
  405. if m.session.ID == session.ID {
  406. return nil
  407. }
  408. m.session = session
  409. messages, err := m.app.Messages.List(context.Background(), session.ID)
  410. if err != nil {
  411. return util.ReportError(err)
  412. }
  413. m.messages = messages
  414. if len(m.messages) > 0 {
  415. m.currentMsgID = m.messages[len(m.messages)-1].ID
  416. }
  417. delete(m.cachedContent, m.currentMsgID)
  418. m.rendering = true
  419. return func() tea.Msg {
  420. m.renderView()
  421. return renderFinishedMsg{}
  422. }
  423. }
  424. func (m *messagesCmp) BindingKeys() []key.Binding {
  425. return []key.Binding{
  426. m.viewport.KeyMap.PageDown,
  427. m.viewport.KeyMap.PageUp,
  428. m.viewport.KeyMap.HalfPageUp,
  429. m.viewport.KeyMap.HalfPageDown,
  430. }
  431. }
  432. func NewMessagesCmp(app *app.App) tea.Model {
  433. customSpinner := spinner.Spinner{
  434. Frames: []string{" ", "┃"},
  435. FPS: time.Second / 2, //nolint:gomnd
  436. }
  437. s := spinner.New(spinner.WithSpinner(customSpinner))
  438. vp := viewport.New(0, 0)
  439. attachmets := viewport.New(0, 0)
  440. vp.KeyMap.PageUp = messageKeys.PageUp
  441. vp.KeyMap.PageDown = messageKeys.PageDown
  442. vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
  443. vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
  444. return &messagesCmp{
  445. app: app,
  446. cachedContent: make(map[string]cacheItem),
  447. viewport: vp,
  448. spinner: s,
  449. attachments: attachmets,
  450. showToolMessages: true,
  451. }
  452. }