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