list.go 10 KB

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