messages.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. package chat
  2. import (
  3. "fmt"
  4. "time"
  5. "github.com/charmbracelet/bubbles/key"
  6. "github.com/charmbracelet/bubbles/spinner"
  7. "github.com/charmbracelet/bubbles/viewport"
  8. tea "github.com/charmbracelet/bubbletea"
  9. "github.com/charmbracelet/lipgloss"
  10. "github.com/sst/opencode/internal/app"
  11. "github.com/sst/opencode/internal/components/dialog"
  12. "github.com/sst/opencode/internal/state"
  13. "github.com/sst/opencode/internal/styles"
  14. "github.com/sst/opencode/internal/theme"
  15. "github.com/sst/opencode/pkg/client"
  16. )
  17. type messagesCmp struct {
  18. app *app.App
  19. width, height int
  20. viewport viewport.Model
  21. spinner spinner.Model
  22. rendering bool
  23. attachments viewport.Model
  24. showToolMessages bool
  25. cache *MessageCache
  26. }
  27. type renderFinishedMsg struct{}
  28. type ToggleToolMessagesMsg struct{}
  29. type MessageKeys struct {
  30. PageDown key.Binding
  31. PageUp key.Binding
  32. HalfPageUp key.Binding
  33. HalfPageDown key.Binding
  34. }
  35. var messageKeys = MessageKeys{
  36. PageDown: key.NewBinding(
  37. key.WithKeys("pgdown"),
  38. key.WithHelp("f/pgdn", "page down"),
  39. ),
  40. PageUp: key.NewBinding(
  41. key.WithKeys("pgup"),
  42. key.WithHelp("b/pgup", "page up"),
  43. ),
  44. HalfPageUp: key.NewBinding(
  45. key.WithKeys("ctrl+u"),
  46. key.WithHelp("ctrl+u", "½ page up"),
  47. ),
  48. HalfPageDown: key.NewBinding(
  49. key.WithKeys("ctrl+d", "ctrl+d"),
  50. key.WithHelp("ctrl+d", "½ page down"),
  51. ),
  52. }
  53. func (m *messagesCmp) Init() tea.Cmd {
  54. return tea.Batch(m.viewport.Init(), m.spinner.Tick)
  55. }
  56. func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  57. var cmds []tea.Cmd
  58. switch msg := msg.(type) {
  59. case dialog.ThemeChangedMsg:
  60. m.cache.Clear()
  61. m.renderView()
  62. return m, nil
  63. case ToggleToolMessagesMsg:
  64. m.showToolMessages = !m.showToolMessages
  65. m.renderView()
  66. return m, nil
  67. case state.SessionSelectedMsg:
  68. // Clear cache when switching sessions
  69. m.cache.Clear()
  70. cmd := m.Reload()
  71. return m, cmd
  72. case state.SessionClearedMsg:
  73. // Clear cache when session is cleared
  74. m.cache.Clear()
  75. cmd := m.Reload()
  76. return m, cmd
  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 state.StateUpdatedMsg:
  88. m.renderView()
  89. m.viewport.GotoBottom()
  90. }
  91. spinner, cmd := m.spinner.Update(msg)
  92. m.spinner = spinner
  93. cmds = append(cmds, cmd)
  94. return m, tea.Batch(cmds...)
  95. }
  96. func (m *messagesCmp) renderView() {
  97. if m.width == 0 {
  98. return
  99. }
  100. messages := make([]string, 0)
  101. for _, msg := range m.app.Messages {
  102. var content string
  103. var cached bool
  104. switch msg.Role {
  105. case client.User:
  106. content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
  107. if !cached {
  108. content = renderUserMessage(m.app.Info.User, msg, m.width)
  109. m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
  110. }
  111. messages = append(messages, content+"\n")
  112. case client.Assistant:
  113. content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
  114. if !cached {
  115. content = renderAssistantMessage(msg, m.width, m.showToolMessages, *m.app.Info)
  116. m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
  117. }
  118. messages = append(messages, content+"\n")
  119. }
  120. }
  121. m.viewport.SetContent(
  122. styles.BaseStyle().
  123. Render(
  124. lipgloss.JoinVertical(
  125. lipgloss.Top,
  126. messages...,
  127. ),
  128. ),
  129. )
  130. }
  131. func (m *messagesCmp) View() string {
  132. baseStyle := styles.BaseStyle()
  133. if m.rendering {
  134. return baseStyle.
  135. Width(m.width).
  136. Render(
  137. lipgloss.JoinVertical(
  138. lipgloss.Top,
  139. "Loading...",
  140. m.working(),
  141. m.help(),
  142. ),
  143. )
  144. }
  145. if len(m.app.Messages) == 0 {
  146. content := baseStyle.
  147. Width(m.width).
  148. Height(m.height - 1).
  149. Render(
  150. m.initialScreen(),
  151. )
  152. return baseStyle.
  153. Width(m.width).
  154. Render(
  155. lipgloss.JoinVertical(
  156. lipgloss.Top,
  157. content,
  158. "",
  159. m.help(),
  160. ),
  161. )
  162. }
  163. return baseStyle.
  164. Width(m.width).
  165. Render(
  166. lipgloss.JoinVertical(
  167. lipgloss.Top,
  168. m.viewport.View(),
  169. m.working(),
  170. m.help(),
  171. ),
  172. )
  173. }
  174. // func hasToolsWithoutResponse(messages []message.Message) bool {
  175. // toolCalls := make([]message.ToolCall, 0)
  176. // toolResults := make([]message.ToolResult, 0)
  177. // for _, m := range messages {
  178. // toolCalls = append(toolCalls, m.ToolCalls()...)
  179. // toolResults = append(toolResults, m.ToolResults()...)
  180. // }
  181. //
  182. // for _, v := range toolCalls {
  183. // found := false
  184. // for _, r := range toolResults {
  185. // if v.ID == r.ToolCallID {
  186. // found = true
  187. // break
  188. // }
  189. // }
  190. // if !found && v.Finished {
  191. // return true
  192. // }
  193. // }
  194. // return false
  195. // }
  196. // func hasUnfinishedToolCalls(messages []message.Message) bool {
  197. // toolCalls := make([]message.ToolCall, 0)
  198. // for _, m := range messages {
  199. // toolCalls = append(toolCalls, m.ToolCalls()...)
  200. // }
  201. // for _, v := range toolCalls {
  202. // if !v.Finished {
  203. // return true
  204. // }
  205. // }
  206. // return false
  207. // }
  208. func (m *messagesCmp) working() string {
  209. text := ""
  210. if len(m.app.Messages) > 0 {
  211. t := theme.CurrentTheme()
  212. baseStyle := styles.BaseStyle()
  213. task := ""
  214. if m.app.IsBusy() {
  215. task = "Working..."
  216. }
  217. // lastMessage := m.app.Messages[len(m.app.Messages)-1]
  218. // if hasToolsWithoutResponse(m.app.Messages) {
  219. // task = "Waiting for tool response..."
  220. // } else if hasUnfinishedToolCalls(m.app.Messages) {
  221. // task = "Building tool call..."
  222. // } else if !lastMessage.IsFinished() {
  223. // task = "Generating..."
  224. // }
  225. if task != "" {
  226. text += baseStyle.
  227. Width(m.width).
  228. Foreground(t.Primary()).
  229. Bold(true).
  230. Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
  231. }
  232. }
  233. return text
  234. }
  235. func (m *messagesCmp) help() string {
  236. t := theme.CurrentTheme()
  237. baseStyle := styles.BaseStyle()
  238. text := ""
  239. if m.app.IsBusy() {
  240. text += lipgloss.JoinHorizontal(
  241. lipgloss.Left,
  242. baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
  243. baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
  244. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
  245. )
  246. } else {
  247. text += lipgloss.JoinHorizontal(
  248. lipgloss.Left,
  249. baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
  250. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"),
  251. baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
  252. baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
  253. baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
  254. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
  255. baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
  256. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
  257. baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
  258. baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
  259. )
  260. }
  261. return baseStyle.
  262. Width(m.width).
  263. Render(text)
  264. }
  265. func (m *messagesCmp) initialScreen() string {
  266. baseStyle := styles.BaseStyle()
  267. return baseStyle.Width(m.width).Render(
  268. lipgloss.JoinVertical(
  269. lipgloss.Top,
  270. header(m.app, m.width),
  271. "",
  272. lspsConfigured(m.width),
  273. ),
  274. )
  275. }
  276. func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
  277. if m.width == width && m.height == height {
  278. return nil
  279. }
  280. // Clear cache on resize since width affects rendering
  281. if m.width != width {
  282. m.cache.Clear()
  283. }
  284. m.width = width
  285. m.height = height
  286. m.viewport.Width = width
  287. m.viewport.Height = height - 2
  288. m.attachments.Width = width + 40
  289. m.attachments.Height = 3
  290. m.renderView()
  291. return nil
  292. }
  293. func (m *messagesCmp) GetSize() (int, int) {
  294. return m.width, m.height
  295. }
  296. func (m *messagesCmp) Reload() tea.Cmd {
  297. m.rendering = true
  298. return func() tea.Msg {
  299. m.renderView()
  300. return renderFinishedMsg{}
  301. }
  302. }
  303. func (m *messagesCmp) BindingKeys() []key.Binding {
  304. return []key.Binding{
  305. m.viewport.KeyMap.PageDown,
  306. m.viewport.KeyMap.PageUp,
  307. m.viewport.KeyMap.HalfPageUp,
  308. m.viewport.KeyMap.HalfPageDown,
  309. }
  310. }
  311. func NewMessagesCmp(app *app.App) tea.Model {
  312. customSpinner := spinner.Spinner{
  313. Frames: []string{" ", "┃", "┃"},
  314. FPS: time.Second / 3,
  315. }
  316. s := spinner.New(spinner.WithSpinner(customSpinner))
  317. vp := viewport.New(0, 0)
  318. attachments := viewport.New(0, 0)
  319. vp.KeyMap.PageUp = messageKeys.PageUp
  320. vp.KeyMap.PageDown = messageKeys.PageDown
  321. vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
  322. vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
  323. return &messagesCmp{
  324. app: app,
  325. viewport: vp,
  326. spinner: s,
  327. attachments: attachments,
  328. showToolMessages: true,
  329. cache: NewMessageCache(),
  330. }
  331. }