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