list.go 9.4 KB

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