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