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