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