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