@@ -1,17 +1,22 @@
11package chat
22
33import (
4− "fmt"
5− "sort"
4+ "context"
5+ "time"
66
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ tea "github.com/charmbracelet/bubbletea/v2"
79 "github.com/charmbracelet/lipgloss/v2"
8− "github.com/opencode-ai/opencode/internal/config"
10+ "github.com/opencode-ai/opencode/internal/app"
11+ "github.com/opencode-ai/opencode/internal/llm/agent"
912 "github.com/opencode-ai/opencode/internal/message"
13+ "github.com/opencode-ai/opencode/internal/pubsub"
1014 "github.com/opencode-ai/opencode/internal/session"
11− "github.com/opencode-ai/opencode/internal/tui/components/logo"
12− "github.com/opencode-ai/opencode/internal/tui/styles"
13− "github.com/opencode-ai/opencode/internal/tui/theme"
14− "github.com/opencode-ai/opencode/internal/version"
15+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
16+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
17+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
18+ "github.com/opencode-ai/opencode/internal/tui/layout"
19+ "github.com/opencode-ai/opencode/internal/tui/util"
1520)
1621
1722type SendMsg struct {
@@ -23,113 +28,470 @@ type SessionSelectedMsg = session.Session
2328
2429type SessionClearedMsg struct{}
2530
26−type EditorFocusMsg bool
31+const (
32+ NotFound = -1
33+)
34+
35+// MessageListCmp represents a component that displays a list of chat messages
36+// with support for real-time updates and session management.
37+type MessageListCmp interface {
38+ util.Model
39+ layout.Sizeable
40+}
41+
42+// messageListCmp implements MessageListCmp, providing a virtualized list
43+// of chat messages with support for tool calls, real-time updates, and
44+// session switching.
45+type messageListCmp struct {
46+ app *app.App
47+ width, height int
48+ session session.Session
49+ listCmp list.ListModel
50+
51+ lastUserMessageTime int64
52+}
53+
54+// NewMessagesListCmp creates a new message list component with custom keybindings
55+// and reverse ordering (newest messages at bottom).
56+func NewMessagesListCmp(app *app.App) MessageListCmp {
57+ defaultKeymaps := list.DefaultKeyMap()
58+ defaultKeymaps.Up.SetEnabled(false)
59+ defaultKeymaps.Down.SetEnabled(false)
60+ defaultKeymaps.NDown = key.NewBinding(
61+ key.WithKeys("ctrl+j"),
62+ )
63+ defaultKeymaps.NUp = key.NewBinding(
64+ key.WithKeys("ctrl+k"),
65+ )
66+ defaultKeymaps.Home = key.NewBinding(
67+ key.WithKeys("ctrl+shift+up"),
68+ )
69+ defaultKeymaps.End = key.NewBinding(
70+ key.WithKeys("ctrl+shift+down"),
71+ )
72+ return &messageListCmp{
73+ app: app,
74+ listCmp: list.New(
75+ list.WithGapSize(1),
76+ list.WithReverse(true),
77+ list.WithKeyMap(defaultKeymaps),
78+ ),
79+ }
80+}
81+
82+// Init initializes the component (no initialization needed).
83+func (m *messageListCmp) Init() tea.Cmd {
84+ return nil
85+}
86+
87+// Update handles incoming messages and updates the component state.
88+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89+ switch msg := msg.(type) {
90+ case dialog.ThemeChangedMsg:
91+ m.listCmp.ResetView()
92+ return m, nil
93+ case SessionSelectedMsg:
94+ if msg.ID != m.session.ID {
95+ cmd := m.SetSession(msg)
96+ return m, cmd
97+ }
98+ return m, nil
99+ case SessionClearedMsg:
100+ m.session = session.Session{}
101+ return m, m.listCmp.SetItems([]util.Model{})
102+
103+ case pubsub.Event[message.Message]:
104+ cmd := m.handleMessageEvent(msg)
105+ return m, cmd
106+ default:
107+ var cmds []tea.Cmd
108+ u, cmd := m.listCmp.Update(msg)
109+ m.listCmp = u.(list.ListModel)
110+ cmds = append(cmds, cmd)
111+ return m, tea.Batch(cmds...)
112+ }
113+}
27114
28−func header() string {
29− return lipgloss.JoinVertical(
30− lipgloss.Top,
31− logoBlock(),
32− repo(),
33− "",
34− cwd(),
115+// View renders the message list or an initial screen if empty.
116+func (m *messageListCmp) View() tea.View {
117+ return tea.NewView(
118+ lipgloss.JoinVertical(
119+ lipgloss.Left,
120+ m.listCmp.View().String(),
121+ ),
122+ )
123+}
124+
125+// handleChildSession handles messages from child sessions (agent tools).
126+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
127+ var cmds []tea.Cmd
128+ if len(event.Payload.ToolCalls()) == 0 {
129+ return nil
130+ }
131+ items := m.listCmp.Items()
132+ toolCallInx := NotFound
133+ var toolCall messages.ToolCallCmp
134+ for i := len(items) - 1; i >= 0; i-- {
135+ if msg, ok := items[i].(messages.ToolCallCmp); ok {
136+ if msg.GetToolCall().ID == event.Payload.SessionID {
137+ toolCallInx = i
138+ toolCall = msg
139+ }
140+ }
141+ }
142+ if toolCallInx == NotFound {
143+ return nil
144+ }
145+ nestedToolCalls := toolCall.GetNestedToolCalls()
146+ for _, tc := range event.Payload.ToolCalls() {
147+ found := false
148+ for existingInx, existingTC := range nestedToolCalls {
149+ if existingTC.GetToolCall().ID == tc.ID {
150+ nestedToolCalls[existingInx].SetToolCall(tc)
151+ found = true
152+ break
153+ }
154+ }
155+ if !found {
156+ nestedCall := messages.NewToolCallCmp(
157+ event.Payload.ID,
158+ tc,
159+ messages.WithToolCallNested(true),
160+ )
161+ cmds = append(cmds, nestedCall.Init())
162+ nestedToolCalls = append(
163+ nestedToolCalls,
164+ nestedCall,
165+ )
166+ }
167+ }
168+ toolCall.SetNestedToolCalls(nestedToolCalls)
169+ m.listCmp.UpdateItem(
170+ toolCallInx,
171+ toolCall,
35172 )
173+ return tea.Batch(cmds...)
174+}
175+
176+// handleMessageEvent processes different types of message events (created/updated).
177+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
178+ switch event.Type {
179+ case pubsub.CreatedEvent:
180+ if event.Payload.SessionID != m.session.ID {
181+ return m.handleChildSession(event)
182+ }
183+ if m.messageExists(event.Payload.ID) {
184+ return nil
185+ }
186+ return m.handleNewMessage(event.Payload)
187+ case pubsub.UpdatedEvent:
188+ if event.Payload.SessionID != m.session.ID {
189+ return m.handleChildSession(event)
190+ }
191+ return m.handleUpdateAssistantMessage(event.Payload)
192+ }
193+ return nil
194+}
195+
196+// messageExists checks if a message with the given ID already exists in the list.
197+func (m *messageListCmp) messageExists(messageID string) bool {
198+ items := m.listCmp.Items()
199+ // Search backwards as new messages are more likely to be at the end
200+ for i := len(items) - 1; i >= 0; i-- {
201+ if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
202+ return true
203+ }
204+ }
205+ return false
206+}
207+
208+// handleNewMessage routes new messages to appropriate handlers based on role.
209+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
210+ switch msg.Role {
211+ case message.User:
212+ return m.handleNewUserMessage(msg)
213+ case message.Assistant:
214+ return m.handleNewAssistantMessage(msg)
215+ case message.Tool:
216+ return m.handleToolMessage(msg)
217+ }
218+ return nil
219+}
220+
221+// handleNewUserMessage adds a new user message to the list and updates the timestamp.
222+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
223+ m.lastUserMessageTime = msg.CreatedAt
224+ return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
225+}
226+
227+// handleToolMessage updates existing tool calls with their results.
228+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
229+ items := m.listCmp.Items()
230+ for _, tr := range msg.ToolResults() {
231+ if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
232+ toolCall := items[toolCallIndex].(messages.ToolCallCmp)
233+ toolCall.SetToolResult(tr)
234+ m.listCmp.UpdateItem(toolCallIndex, toolCall)
235+ }
236+ }
237+ return nil
238+}
239+
240+// findToolCallByID searches for a tool call with the specified ID.
241+// Returns the index if found, NotFound otherwise.
242+func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
243+ // Search backwards as tool calls are more likely to be recent
244+ for i := len(items) - 1; i >= 0; i-- {
245+ if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
246+ return i
247+ }
248+ }
249+ return NotFound
36250}
37251
38−func lspsConfigured() string {
39− cfg := config.Get()
40− title := "LSP Configuration"
252+// handleUpdateAssistantMessage processes updates to assistant messages,
253+// managing both message content and associated tool calls.
254+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
255+ var cmds []tea.Cmd
256+ items := m.listCmp.Items()
41257
42− t := theme.CurrentTheme()
43− baseStyle := styles.BaseStyle()
258+ // Find existing assistant message and tool calls for this message
259+ assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
260+
261+ // Handle assistant message content
262+ if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
263+ cmds = append(cmds, cmd)
264+ }
265+
266+ // Handle tool calls
267+ if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
268+ cmds = append(cmds, cmd)
269+ }
270+
271+ return tea.Batch(cmds...)
272+}
44273
45− lsps := baseStyle.
46− Foreground(t.Primary()).
47− Bold(true).
48− Render(title)
274+// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
275+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
276+ assistantIndex := NotFound
277+ toolCalls := make(map[int]messages.ToolCallCmp)
49278
50− // Get LSP names and sort them for consistent ordering
51− var lspNames []string
52− for name := range cfg.LSP {
53− lspNames = append(lspNames, name)
279+ // Search backwards as messages are more likely to be at the end
280+ for i := len(items) - 1; i >= 0; i-- {
281+ item := items[i]
282+ if asMsg, ok := item.(messages.MessageCmp); ok {
283+ if asMsg.GetMessage().ID == messageID {
284+ assistantIndex = i
285+ }
286+ } else if tc, ok := item.(messages.ToolCallCmp); ok {
287+ if tc.ParentMessageId() == messageID {
288+ toolCalls[i] = tc
289+ }
290+ }
54291 }
55− sort.Strings(lspNames)
56292
57− var lspViews []string
58− for _, name := range lspNames {
59− lsp := cfg.LSP[name]
60− lspName := baseStyle.
61− Foreground(t.Text()).
62− Render(fmt.Sprintf("• %s", name))
293+ return assistantIndex, toolCalls
294+}
63295
64− cmd := lsp.Command
296+// updateAssistantMessageContent updates or removes the assistant message based on content.
297+func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
298+ if assistantIndex == NotFound {
299+ return nil
300+ }
65301
66− lspPath := baseStyle.
67− Foreground(t.TextMuted()).
68− Render(fmt.Sprintf(" (%s)", cmd))
302+ shouldShowMessage := m.shouldShowAssistantMessage(msg)
303+ hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
69304
70− lspViews = append(lspViews,
71− baseStyle.
72− Render(
73− lipgloss.JoinHorizontal(
74− lipgloss.Left,
75− lspName,
76− lspPath,
77− ),
78− ),
305+ if shouldShowMessage {
306+ m.listCmp.UpdateItem(
307+ assistantIndex,
308+ messages.NewMessageCmp(
309+ msg,
310+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
311+ ),
79312 )
313+ } else if hasToolCallsOnly {
314+ m.listCmp.DeleteItem(assistantIndex)
315+ }
316+
317+ return nil
318+}
319+
320+// shouldShowAssistantMessage determines if an assistant message should be displayed.
321+func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
322+ return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
323+}
324+
325+// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
326+func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
327+ var cmds []tea.Cmd
328+
329+ for _, tc := range msg.ToolCalls() {
330+ if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
331+ cmds = append(cmds, cmd)
332+ }
333+ }
334+
335+ return tea.Batch(cmds...)
336+}
337+
338+// updateOrAddToolCall updates an existing tool call or adds a new one.
339+func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
340+ // Try to find existing tool call
341+ for index, existingTC := range existingToolCalls {
342+ if tc.ID == existingTC.GetToolCall().ID {
343+ existingTC.SetToolCall(tc)
344+ m.listCmp.UpdateItem(index, existingTC)
345+ return nil
346+ }
80347 }
81348
82− return baseStyle.
83− Render(
84− lipgloss.JoinVertical(
85− lipgloss.Left,
86− lsps,
87− lipgloss.JoinVertical(
88− lipgloss.Left,
89− lspViews...,
90− ),
349+ // Add new tool call if not found
350+ return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
351+}
352+
353+// handleNewAssistantMessage processes new assistant messages and their tool calls.
354+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
355+ var cmds []tea.Cmd
356+
357+ // Add assistant message if it should be displayed
358+ if m.shouldShowAssistantMessage(msg) {
359+ cmd := m.listCmp.AppendItem(
360+ messages.NewMessageCmp(
361+ msg,
362+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
91363 ),
92364 )
365+ cmds = append(cmds, cmd)
366+ }
367+
368+ // Add tool calls
369+ for _, tc := range msg.ToolCalls() {
370+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
371+ cmds = append(cmds, cmd)
372+ }
373+
374+ return tea.Batch(cmds...)
375+}
376+
377+// SetSession loads and displays messages for a new session.
378+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
379+ if m.session.ID == session.ID {
380+ return nil
381+ }
382+
383+ m.session = session
384+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
385+ if err != nil {
386+ return util.ReportError(err)
387+ }
388+
389+ if len(sessionMessages) == 0 {
390+ return m.listCmp.SetItems([]util.Model{})
391+ }
392+
393+ // Initialize with first message timestamp
394+ m.lastUserMessageTime = sessionMessages[0].CreatedAt
395+
396+ // Build tool result map for efficient lookup
397+ toolResultMap := m.buildToolResultMap(sessionMessages)
398+
399+ // Convert messages to UI components
400+ uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
401+
402+ return m.listCmp.SetItems(uiMessages)
93403}
94404
95−func logoBlock() string {
96− t := theme.CurrentTheme()
97− return logo.Render(version.Version, true, logo.Opts{
98− FieldColor: t.Secondary(),
99− TitleColorA: t.Primary(),
100− TitleColorB: t.Secondary(),
101− CharmColor: t.Primary(),
102− VersionColor: t.Secondary(),
103− })
405+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
406+func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
407+ toolResultMap := make(map[string]message.ToolResult)
408+ for _, msg := range messages {
409+ for _, tr := range msg.ToolResults() {
410+ toolResultMap[tr.ToolCallID] = tr
411+ }
412+ }
413+ return toolResultMap
104414}
105415
106−func repo() string {
107− repo := "https://github.com/opencode-ai/opencode"
108− t := theme.CurrentTheme()
416+// convertMessagesToUI converts database messages to UI components.
417+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
418+ uiMessages := make([]util.Model, 0)
419+
420+ for _, msg := range sessionMessages {
421+ switch msg.Role {
422+ case message.User:
423+ m.lastUserMessageTime = msg.CreatedAt
424+ uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
425+ case message.Assistant:
426+ uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
427+ }
428+ }
429+
430+ return uiMessages
431+}
432+
433+// convertAssistantMessage converts an assistant message and its tool calls to UI components.
434+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
435+ var uiMessages []util.Model
436+
437+ // Add assistant message if it should be displayed
438+ if m.shouldShowAssistantMessage(msg) {
439+ uiMessages = append(
440+ uiMessages,
441+ messages.NewMessageCmp(
442+ msg,
443+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
444+ ),
445+ )
446+ }
109447
110− return styles.BaseStyle().
111− Foreground(t.TextMuted()).
112− Render(repo)
448+ // Add tool calls with their results and status
449+ for _, tc := range msg.ToolCalls() {
450+ options := m.buildToolCallOptions(tc, msg, toolResultMap)
451+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
452+ // If this tool call is the agent tool, fetch nested tool calls
453+ if tc.Name == agent.AgentToolName {
454+ nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
455+ nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
456+ nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
457+ for _, nestedMsg := range nestedUIMessages {
458+ if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
459+ toolCall.SetIsNested(true)
460+ nestedToolCalls = append(nestedToolCalls, toolCall)
461+ }
462+ }
463+ uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
464+ }
465+ }
466+
467+ return uiMessages
113468}
114469
115−func cwd() string {
116− cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
117− t := theme.CurrentTheme()
470+// buildToolCallOptions creates options for tool call components based on results and status.
471+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
472+ var options []messages.ToolCallOption
473+
474+ // Add tool result if available
475+ if tr, ok := toolResultMap[tc.ID]; ok {
476+ options = append(options, messages.WithToolCallResult(tr))
477+ }
118478
119− return styles.BaseStyle().
120− Foreground(t.TextMuted()).
121− Render(cwd)
479+ // Add cancelled status if applicable
480+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
481+ options = append(options, messages.WithToolCallCancelled())
482+ }
483+
484+ return options
122485}
123486
124−func initialScreen() string {
125− baseStyle := styles.BaseStyle()
487+// GetSize returns the current width and height of the component.
488+func (m *messageListCmp) GetSize() (int, int) {
489+ return m.width, m.height
490+}
126491
127− return baseStyle.Render(
128− lipgloss.JoinVertical(
129− lipgloss.Top,
130− header(),
131− "",
132− lspsConfigured(),
133− ),
134− )
492+// SetSize updates the component dimensions and propagates to the list component.
493+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
494+ m.width = width
495+ m.height = height - 1
496+ return m.listCmp.SetSize(width, height-1)
135497}