1package chat
2
3import (
4 "context"
5 "time"
6
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/opencode-ai/opencode/internal/app"
10 "github.com/opencode-ai/opencode/internal/logging"
11 "github.com/opencode-ai/opencode/internal/message"
12 "github.com/opencode-ai/opencode/internal/pubsub"
13 "github.com/opencode-ai/opencode/internal/session"
14 "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
15 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
16 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
17 "github.com/opencode-ai/opencode/internal/tui/layout"
18 "github.com/opencode-ai/opencode/internal/tui/util"
19)
20
21type MessageListCmp interface {
22 util.Model
23 layout.Sizeable
24}
25
26type messageListCmp struct {
27 app *app.App
28 width, height int
29 session session.Session
30 listCmp list.ListModel
31
32 lastUserMessageTime int64
33}
34
35func NewMessagesListCmp(app *app.App) MessageListCmp {
36 return &messageListCmp{
37 app: app,
38 listCmp: list.New(
39 list.WithGapSize(1),
40 list.WithReverse(true),
41 ),
42 }
43}
44
45func (m *messageListCmp) Init() tea.Cmd {
46 return nil
47}
48
49func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50 switch msg := msg.(type) {
51 case dialog.ThemeChangedMsg:
52 m.listCmp.ResetView()
53 return m, nil
54 case SessionSelectedMsg:
55 if msg.ID != m.session.ID {
56 cmd := m.SetSession(msg)
57 return m, cmd
58 }
59 return m, nil
60 case SessionClearedMsg:
61 m.session = session.Session{}
62 return m, m.listCmp.SetItems([]util.Model{})
63
64 case pubsub.Event[message.Message]:
65 cmd := m.handleMessageEvent(msg)
66 return m, cmd
67 default:
68 var cmds []tea.Cmd
69 u, cmd := m.listCmp.Update(msg)
70 m.listCmp = u.(list.ListModel)
71 cmds = append(cmds, cmd)
72 return m, tea.Batch(cmds...)
73 }
74}
75
76func (m *messageListCmp) View() string {
77 if len(m.listCmp.Items()) == 0 {
78 return initialScreen()
79 }
80 return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
81}
82
83func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
84 // TODO: update the agent tool message with the changes
85}
86
87func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
88 switch event.Type {
89 case pubsub.CreatedEvent:
90 if event.Payload.SessionID != m.session.ID {
91 m.handleChildSession(event)
92 }
93 messageExists := false
94 // more likely to be at the end of the list
95 items := m.listCmp.Items()
96 for i := len(items) - 1; i >= 0; i-- {
97 msg, ok := items[i].(messages.MessageCmp)
98 if ok && msg.GetMessage().ID == event.Payload.ID {
99 messageExists = true
100 break
101 }
102 }
103 if messageExists {
104 return nil
105 }
106 switch event.Payload.Role {
107 case message.User:
108 return m.handleNewUserMessage(event.Payload)
109 case message.Assistant:
110 return m.handleNewAssistantMessage(event.Payload)
111 case message.Tool:
112 return m.handleToolMessage(event.Payload)
113 }
114 case pubsub.UpdatedEvent:
115 return m.handleUpdateAssistantMessage(event.Payload)
116 }
117 return nil
118}
119
120func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
121 m.lastUserMessageTime = msg.CreatedAt
122 return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
123}
124
125func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
126 items := m.listCmp.Items()
127 for _, tr := range msg.ToolResults() {
128 for i := len(items) - 1; i >= 0; i-- {
129 message := items[i]
130 if toolCall, ok := message.(messages.ToolCallCmp); ok {
131 if toolCall.GetToolCall().ID == tr.ToolCallID {
132 toolCall.SetToolResult(tr)
133 m.listCmp.UpdateItem(
134 i,
135 toolCall,
136 )
137 break
138 }
139 }
140 }
141 }
142 return nil
143}
144
145func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
146 var cmds []tea.Cmd
147 // Simple update the content
148 items := m.listCmp.Items()
149 assistantMessageInx := -1
150 toolCalls := map[int]messages.ToolCallCmp{}
151
152 // we go backwards because the messages are most likely at the end of the list
153 for i := len(items) - 1; i >= 0; i-- {
154 message := items[i]
155 if asMsg, ok := message.(messages.MessageCmp); ok {
156 if asMsg.GetMessage().ID == msg.ID {
157 assistantMessageInx = i
158 }
159 } else if tc, ok := message.(messages.ToolCallCmp); ok {
160 if tc.ParentMessageId() == msg.ID {
161 toolCalls[i] = tc
162 }
163 }
164 }
165
166 logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantMessageInx, "toolCalls", toolCalls)
167
168 if assistantMessageInx > -1 && (len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()) {
169 m.listCmp.UpdateItem(
170 assistantMessageInx,
171 messages.NewMessageCmp(
172 msg,
173 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
174 ),
175 )
176 } else if assistantMessageInx > -1 && len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
177 m.listCmp.DeleteItem(assistantMessageInx)
178 }
179 for _, tc := range msg.ToolCalls() {
180 found := false
181 for inx, tcc := range toolCalls {
182 if tc.ID == tcc.GetToolCall().ID {
183 tcc.SetToolCall(tc)
184 m.listCmp.UpdateItem(
185 inx,
186 tcc,
187 )
188 found = true
189 break
190 }
191 }
192 if !found {
193 cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
194 cmds = append(cmds, cmd)
195 }
196 }
197
198 return tea.Batch(cmds...)
199}
200
201func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
202 var cmds []tea.Cmd
203 // Only add assistant messages if they don't have tool calls or there is some content
204 if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
205 cmd := m.listCmp.AppendItem(
206 messages.NewMessageCmp(
207 msg,
208 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
209 ),
210 )
211 cmds = append(cmds, cmd)
212 }
213 for _, tc := range msg.ToolCalls() {
214 cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
215 cmds = append(cmds, cmd)
216 }
217 return tea.Batch(cmds...)
218}
219
220func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
221 if m.session.ID == session.ID {
222 return nil
223 }
224 m.session = session
225 sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
226 if err != nil {
227 return util.ReportError(err)
228 }
229 uiMessages := make([]util.Model, 0)
230 m.lastUserMessageTime = sessionMessages[0].CreatedAt
231 toolResultMap := make(map[string]message.ToolResult)
232 // first pass to get all tool results
233 for _, msg := range sessionMessages {
234 for _, tr := range msg.ToolResults() {
235 toolResultMap[tr.ToolCallID] = tr
236 }
237 }
238 for _, msg := range sessionMessages {
239 switch msg.Role {
240 case message.User:
241 m.lastUserMessageTime = msg.CreatedAt
242 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
243 case message.Assistant:
244 // Only add assistant messages if they don't have tool calls or there is some content
245 if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
246 uiMessages = append(
247 uiMessages,
248 messages.NewMessageCmp(
249 msg,
250 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
251 ),
252 )
253 }
254 for _, tc := range msg.ToolCalls() {
255 options := []messages.ToolCallOption{}
256 if tr, ok := toolResultMap[tc.ID]; ok {
257 options = append(options, messages.WithToolCallResult(tr))
258 }
259 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
260 options = append(options, messages.WithToolCallCancelled())
261 }
262 uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
263 }
264 }
265 }
266 return m.listCmp.SetItems(uiMessages)
267}
268
269// GetSize implements MessageListCmp.
270func (m *messageListCmp) GetSize() (int, int) {
271 return m.width, m.height
272}
273
274// SetSize implements MessageListCmp.
275func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
276 m.width = width
277 m.height = height - 1
278 return m.listCmp.SetSize(width, height-1)
279}