package chat import ( "strings" "github.com/charmbracelet/bubbles/v2/viewport" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" ) type MessagesComponent interface { tea.Model View(width, height int) string SetWidth(width int) tea.Cmd PageUp() (tea.Model, tea.Cmd) PageDown() (tea.Model, tea.Cmd) HalfPageUp() (tea.Model, tea.Cmd) HalfPageDown() (tea.Model, tea.Cmd) First() (tea.Model, tea.Cmd) Last() (tea.Model, tea.Cmd) Previous() (tea.Model, tea.Cmd) Next() (tea.Model, tea.Cmd) ToolDetailsVisible() bool Selected() string } type messagesComponent struct { width int app *app.App viewport viewport.Model cache *MessageCache rendering bool showToolDetails bool tail bool partCount int lineCount int selectedPart int selectedText string } type renderFinishedMsg struct{} type selectedMessagePartChangedMsg struct { part int } type ToggleToolDetailsMsg struct{} func (m *messagesComponent) Init() tea.Cmd { return tea.Batch(m.viewport.Init()) } func (m *messagesComponent) Selected() string { return m.selectedText } func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case app.SendMsg: m.viewport.GotoBottom() m.tail = true m.selectedPart = -1 return m, nil case app.OptimisticMessageAddedMsg: m.renderView(m.width) if m.tail { m.viewport.GotoBottom() } return m, nil case dialog.ThemeSelectedMsg: m.cache.Clear() m.rendering = true return m, m.Reload() case ToggleToolDetailsMsg: m.showToolDetails = !m.showToolDetails m.rendering = true return m, m.Reload() case app.SessionLoadedMsg: m.cache.Clear() m.tail = true m.rendering = true return m, m.Reload() case app.SessionClearedMsg: m.cache.Clear() m.rendering = true return m, m.Reload() case renderFinishedMsg: m.rendering = false if m.tail { m.viewport.GotoBottom() } case selectedMessagePartChangedMsg: return m, m.Reload() case opencode.EventListResponseEventSessionUpdated: if msg.Properties.Info.ID == m.app.Session.ID { m.renderView(m.width) if m.tail { m.viewport.GotoBottom() } } case opencode.EventListResponseEventMessageUpdated: if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID { m.renderView(m.width) if m.tail { m.viewport.GotoBottom() } } } viewport, cmd := m.viewport.Update(msg) m.viewport = viewport m.tail = m.viewport.AtBottom() cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m *messagesComponent) renderView(width int) { measure := util.Measure("messages.renderView") defer measure("messageCount", len(m.app.Messages)) t := theme.CurrentTheme() blocks := make([]string, 0) m.partCount = 0 m.lineCount = 0 for _, message := range m.app.Messages { var content string var cached bool switch message.Role { case opencode.MessageRoleUser: for _, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( m.app, message, part.Text, m.app.Info.User, m.showToolDetails, m.partCount == m.selectedPart, width, ) m.cache.Set(key, content) } if content != "" { if m.selectedPart == m.partCount { m.viewport.SetYOffset(m.lineCount - 4) m.selectedText = part.Text } blocks = append(blocks, content) m.partCount++ m.lineCount += lipgloss.Height(content) + 1 } } } case opencode.MessageRoleAssistant: for i, p := range message.Parts { switch part := p.AsUnion().(type) { case opencode.TextPart: finished := message.Metadata.Time.Completed > 0 remainingParts := message.Parts[i+1:] toolCallParts := make([]opencode.ToolInvocationPart, 0) for _, part := range remainingParts { switch part := part.AsUnion().(type) { case opencode.TextPart: // we only want tool calls associated with the current text part. // if we hit another text part, we're done. break case opencode.ToolInvocationPart: toolCallParts = append(toolCallParts, part) if part.ToolInvocation.State != "result" { // i don't think there's a case where a tool call isn't in result state // and the message time is 0, but just in case finished = false } } } if finished { key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, m.partCount == m.selectedPart, width, toolCallParts..., ) m.cache.Set(key, content) } } else { content = renderText( m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, m.partCount == m.selectedPart, width, toolCallParts..., ) } if content != "" { if m.selectedPart == m.partCount { m.viewport.SetYOffset(m.lineCount - 4) m.selectedText = p.Text } blocks = append(blocks, content) m.partCount++ m.lineCount += lipgloss.Height(content) + 1 } case opencode.ToolInvocationPart: if !m.showToolDetails { continue } if part.ToolInvocation.State == "result" { key := m.cache.GenerateKey(message.ID, part.ToolInvocation.ToolCallID, m.showToolDetails, width, m.partCount == m.selectedPart, ) content, cached = m.cache.Get(key) if !cached { content = renderToolDetails( m.app, part, message.Metadata, m.partCount == m.selectedPart, width, ) m.cache.Set(key, content) } } else { // if the tool call isn't finished, don't cache content = renderToolDetails( m.app, part, message.Metadata, m.partCount == m.selectedPart, width, ) } if content != "" { if m.selectedPart == m.partCount { m.viewport.SetYOffset(m.lineCount - 4) m.selectedText = "" } blocks = append(blocks, content) m.partCount++ m.lineCount += lipgloss.Height(content) + 1 } } } } error := "" switch err := message.Metadata.Error.AsUnion().(type) { case nil: case opencode.MessageMetadataErrorMessageOutputLengthError: error = "Message output length exceeded" case opencode.ProviderAuthError: error = err.Data.Message case opencode.UnknownError: error = err.Data.Message } if error != "" { error = renderContentBlock( m.app, error, false, width, WithBorderColor(t.Error()), ) blocks = append(blocks, error) m.lineCount += lipgloss.Height(error) + 1 } } m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n")) if m.selectedPart == m.partCount-1 { m.viewport.GotoBottom() } } func (m *messagesComponent) header(width int) string { if m.app.Session.ID == "" { return "" } t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render headerLines := []string{} headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background())) if m.app.Session.Share.URL != "" { headerLines = append(headerLines, muted(m.app.Session.Share.URL)) } else { headerLines = append(headerLines, base("/share")+muted(" to create a shareable link")) } header := strings.Join(headerLines, "\n") header = styles.NewStyle(). Background(t.Background()). Width(width). PaddingLeft(2). PaddingRight(2). BorderLeft(true). BorderRight(true). BorderBackground(t.Background()). BorderForeground(t.BackgroundElement()). BorderStyle(lipgloss.ThickBorder()). Render(header) return "\n" + header + "\n" } func (m *messagesComponent) View(width, height int) string { t := theme.CurrentTheme() if m.rendering { return lipgloss.Place( width, height, lipgloss.Center, lipgloss.Center, styles.NewStyle().Background(t.Background()).Render("Loading session..."), styles.WhitespaceStyle(t.Background()), ) } header := m.header(width) m.viewport.SetWidth(width) m.viewport.SetHeight(height - lipgloss.Height(header)) return styles.NewStyle(). Background(t.Background()). Render(header + "\n" + m.viewport.View()) } func (m *messagesComponent) SetWidth(width int) tea.Cmd { if m.width == width { return nil } // Clear cache on resize since width affects rendering if m.width != width { m.cache.Clear() } m.width = width m.viewport.SetWidth(width) m.renderView(width) return nil } func (m *messagesComponent) Reload() tea.Cmd { return func() tea.Msg { m.renderView(m.width) return renderFinishedMsg{} } } func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) { m.viewport.ViewUp() return m, nil } func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) { m.viewport.ViewDown() return m, nil } func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) { m.viewport.HalfViewUp() return m, nil } func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) { m.viewport.HalfViewDown() return m, nil } func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) { m.tail = false if m.selectedPart < 0 { m.selectedPart = m.partCount } m.selectedPart-- if m.selectedPart < 0 { m.selectedPart = 0 } return m, util.CmdHandler(selectedMessagePartChangedMsg{ part: m.selectedPart, }) } func (m *messagesComponent) Next() (tea.Model, tea.Cmd) { m.tail = false m.selectedPart++ if m.selectedPart >= m.partCount { m.selectedPart = m.partCount } return m, util.CmdHandler(selectedMessagePartChangedMsg{ part: m.selectedPart, }) } func (m *messagesComponent) First() (tea.Model, tea.Cmd) { m.selectedPart = 0 m.tail = false return m, util.CmdHandler(selectedMessagePartChangedMsg{ part: m.selectedPart, }) } func (m *messagesComponent) Last() (tea.Model, tea.Cmd) { m.selectedPart = m.partCount - 1 m.tail = true return m, util.CmdHandler(selectedMessagePartChangedMsg{ part: m.selectedPart, }) } func (m *messagesComponent) ToolDetailsVisible() bool { return m.showToolDetails } func NewMessagesComponent(app *app.App) MessagesComponent { vp := viewport.New() vp.KeyMap = viewport.KeyMap{} return &messagesComponent{ app: app, viewport: vp, showToolDetails: true, cache: NewMessageCache(), tail: true, selectedPart: -1, } }