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