@@ -1,17 +1,22 @@
1package chat
1package chat
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/lipgloss/v2"
9 "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"
9 "github.com/opencode-ai/opencode/internal/message"
12 "github.com/opencode-ai/opencode/internal/message"
13+ "github.com/opencode-ai/opencode/internal/pubsub"
10 "github.com/opencode-ai/opencode/internal/session"
14 "github.com/opencode-ai/opencode/internal/session"
11− "github.com/opencode-ai/opencode/internal/tui/components/logo"
15+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
12− "github.com/opencode-ai/opencode/internal/tui/styles"
16+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
13− "github.com/opencode-ai/opencode/internal/tui/theme"
17+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
14− "github.com/opencode-ai/opencode/internal/version"
18+ "github.com/opencode-ai/opencode/internal/tui/layout"
19+ "github.com/opencode-ai/opencode/internal/tui/util"
17type SendMsg struct {
22type SendMsg struct {
@@ -23,113 +28,470 @@ type SessionSelectedMsg = session.Session
24type SessionClearedMsg struct{}
29type SessionClearedMsg struct{}
26−type EditorFocusMsg bool
31+const (
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 {
42+// messageListCmp implements MessageListCmp, providing a virtualized list
43+// of chat messages with support for tool calls, real-time updates, and
45+type messageListCmp struct {
48+ session session.Session
49+ listCmp list.ListModel
51+ lastUserMessageTime int64
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"),
63+ defaultKeymaps.NUp = key.NewBinding(
64+ key.WithKeys("ctrl+k"),
66+ defaultKeymaps.Home = key.NewBinding(
67+ key.WithKeys("ctrl+shift+up"),
69+ defaultKeymaps.End = key.NewBinding(
70+ key.WithKeys("ctrl+shift+down"),
72+ return &messageListCmp{
76+ list.WithReverse(true),
77+ list.WithKeyMap(defaultKeymaps),
82+// Init initializes the component (no initialization needed).
83+func (m *messageListCmp) Init() tea.Cmd {
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()
93+ case SessionSelectedMsg:
94+ if msg.ID != m.session.ID {
95+ cmd := m.SetSession(msg)
99+ case SessionClearedMsg:
100+ m.session = session.Session{}
101+ return m, m.listCmp.SetItems([]util.Model{})
103+ case pubsub.Event[message.Message]:
104+ cmd := m.handleMessageEvent(msg)
108+ u, cmd := m.listCmp.Update(msg)
109+ m.listCmp = u.(list.ListModel)
110+ cmds = append(cmds, cmd)
111+ return m, tea.Batch(cmds...)
28−func header() string {
115+// View renders the message list or an initial screen if empty.
116+func (m *messageListCmp) View() tea.View {
29− return lipgloss.JoinVertical(
118+ lipgloss.JoinVertical(
30− lipgloss.Top,
119+ lipgloss.Left,
31− logoBlock(),
120+ m.listCmp.View().String(),
125+// handleChildSession handles messages from child sessions (agent tools).
126+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
128+ if len(event.Payload.ToolCalls()) == 0 {
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 {
142+ if toolCallInx == NotFound {
145+ nestedToolCalls := toolCall.GetNestedToolCalls()
146+ for _, tc := range event.Payload.ToolCalls() {
148+ for existingInx, existingTC := range nestedToolCalls {
149+ if existingTC.GetToolCall().ID == tc.ID {
150+ nestedToolCalls[existingInx].SetToolCall(tc)
156+ nestedCall := messages.NewToolCallCmp(
159+ messages.WithToolCallNested(true),
161+ cmds = append(cmds, nestedCall.Init())
162+ nestedToolCalls = append(
168+ toolCall.SetNestedToolCalls(nestedToolCalls)
169+ m.listCmp.UpdateItem(
173+ return tea.Batch(cmds...)
176+// handleMessageEvent processes different types of message events (created/updated).
177+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
179+ case pubsub.CreatedEvent:
180+ if event.Payload.SessionID != m.session.ID {
181+ return m.handleChildSession(event)
183+ if m.messageExists(event.Payload.ID) {
186+ return m.handleNewMessage(event.Payload)
187+ case pubsub.UpdatedEvent:
188+ if event.Payload.SessionID != m.session.ID {
189+ return m.handleChildSession(event)
191+ return m.handleUpdateAssistantMessage(event.Payload)
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 {
208+// handleNewMessage routes new messages to appropriate handlers based on role.
209+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
212+ return m.handleNewUserMessage(msg)
213+ case message.Assistant:
214+ return m.handleNewAssistantMessage(msg)
216+ return m.handleToolMessage(msg)
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))
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)
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 {
38−func lspsConfigured() string {
252+// handleUpdateAssistantMessage processes updates to assistant messages,
39− cfg := config.Get()
253+// managing both message content and associated tool calls.
40− title := "LSP Configuration"
254+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
256+ items := m.listCmp.Items()
42− t := theme.CurrentTheme()
258+ // Find existing assistant message and tool calls for this message
43− baseStyle := styles.BaseStyle()
259+ assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
261+ // Handle assistant message content
262+ if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
263+ cmds = append(cmds, cmd)
266+ // Handle tool calls
267+ if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
268+ cmds = append(cmds, cmd)
271+ return tea.Batch(cmds...)
45− lsps := baseStyle.
274+// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
46− Foreground(t.Primary()).
275+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
47− Bold(true).
276+ assistantIndex := NotFound
48− Render(title)
277+ toolCalls := make(map[int]messages.ToolCallCmp)
50− // Get LSP names and sort them for consistent ordering
279+ // Search backwards as messages are more likely to be at the end
51− var lspNames []string
280+ for i := len(items) - 1; i >= 0; i-- {
52− for name := range cfg.LSP {
281+ item := items[i]
53− lspNames = append(lspNames, name)
282+ if asMsg, ok := item.(messages.MessageCmp); ok {
283+ if asMsg.GetMessage().ID == messageID {
286+ } else if tc, ok := item.(messages.ToolCallCmp); ok {
287+ if tc.ParentMessageId() == messageID {
55− sort.Strings(lspNames)
57− var lspViews []string
293+ return assistantIndex, toolCalls
58− for _, name := range lspNames {
294+}
60− lspName := baseStyle.
61− Foreground(t.Text()).
62− Render(fmt.Sprintf("• %s", name))
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 {
66− lspPath := baseStyle.
302+ shouldShowMessage := m.shouldShowAssistantMessage(msg)
67− Foreground(t.TextMuted()).
303+ hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
68− Render(fmt.Sprintf(" (%s)", cmd))
70− lspViews = append(lspViews,
305+ if shouldShowMessage {
71− baseStyle.
306+ m.listCmp.UpdateItem(
72− Render(
307+ assistantIndex,
73− lipgloss.JoinHorizontal(
308+ messages.NewMessageCmp(
74− lipgloss.Left,
309+ msg,
75− lspName,
310+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
313+ } else if hasToolCallsOnly {
314+ m.listCmp.DeleteItem(assistantIndex)
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()
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 {
329+ for _, tc := range msg.ToolCalls() {
330+ if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
331+ cmds = append(cmds, cmd)
335+ return tea.Batch(cmds...)
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)
82− return baseStyle.
349+ // Add new tool call if not found
83− Render(
350+ return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
84− lipgloss.JoinVertical(
351+}
86− lsps,
353+// handleNewAssistantMessage processes new assistant messages and their tool calls.
87− lipgloss.JoinVertical(
354+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
88− lipgloss.Left,
355+ var cmds []tea.Cmd
90− ),
357+ // Add assistant message if it should be displayed
358+ if m.shouldShowAssistantMessage(msg) {
359+ cmd := m.listCmp.AppendItem(
360+ messages.NewMessageCmp(
362+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
365+ cmds = append(cmds, cmd)
369+ for _, tc := range msg.ToolCalls() {
370+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
371+ cmds = append(cmds, cmd)
374+ return tea.Batch(cmds...)
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 {
384+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
386+ return util.ReportError(err)
389+ if len(sessionMessages) == 0 {
390+ return m.listCmp.SetItems([]util.Model{})
393+ // Initialize with first message timestamp
394+ m.lastUserMessageTime = sessionMessages[0].CreatedAt
396+ // Build tool result map for efficient lookup
397+ toolResultMap := m.buildToolResultMap(sessionMessages)
399+ // Convert messages to UI components
400+ uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
402+ return m.listCmp.SetItems(uiMessages)
95−func logoBlock() string {
405+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
96− t := theme.CurrentTheme()
406+func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
97− return logo.Render(version.Version, true, logo.Opts{
407+ toolResultMap := make(map[string]message.ToolResult)
98− FieldColor: t.Secondary(),
408+ for _, msg := range messages {
99− TitleColorA: t.Primary(),
409+ for _, tr := range msg.ToolResults() {
100− TitleColorB: t.Secondary(),
410+ toolResultMap[tr.ToolCallID] = tr
101− CharmColor: t.Primary(),
411+ }
102− VersionColor: t.Secondary(),
412+ }
103− })
413+ return toolResultMap
106−func repo() string {
416+// convertMessagesToUI converts database messages to UI components.
107− repo := "https://github.com/opencode-ai/opencode"
417+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
108− t := theme.CurrentTheme()
418+ uiMessages := make([]util.Model, 0)
420+ for _, msg := range sessionMessages {
423+ m.lastUserMessageTime = msg.CreatedAt
424+ uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
425+ case message.Assistant:
426+ uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
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
437+ // Add assistant message if it should be displayed
438+ if m.shouldShowAssistantMessage(msg) {
439+ uiMessages = append(
441+ messages.NewMessageCmp(
443+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
110− return styles.BaseStyle().
448+ // Add tool calls with their results and status
111− Foreground(t.TextMuted()).
449+ for _, tc := range msg.ToolCalls() {
112− Render(repo)
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)
463+ uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
115−func cwd() string {
470+// buildToolCallOptions creates options for tool call components based on results and status.
116− cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
471+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
117− t := theme.CurrentTheme()
472+ var options []messages.ToolCallOption
474+ // Add tool result if available
475+ if tr, ok := toolResultMap[tc.ID]; ok {
476+ options = append(options, messages.WithToolCallResult(tr))
119− return styles.BaseStyle().
479+ // Add cancelled status if applicable
120− Foreground(t.TextMuted()).
480+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
121− Render(cwd)
481+ options = append(options, messages.WithToolCallCancelled())
124−func initialScreen() string {
487+// GetSize returns the current width and height of the component.
125− baseStyle := styles.BaseStyle()
488+func (m *messageListCmp) GetSize() (int, int) {
489+ return m.width, m.height
127− return baseStyle.Render(
492+// SetSize updates the component dimensions and propagates to the list component.
128− lipgloss.JoinVertical(
493+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
129− lipgloss.Top,
494+ m.width = width
130− header(),
495+ m.height = height - 1
131− "",
496+ return m.listCmp.SetSize(width, height-1)