list.go 11 KB

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