package chat import ( "context" "fmt" "math" "strings" "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/pubsub" "github.com/sst/opencode/internal/session" "github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/tui/components/dialog" "github.com/sst/opencode/internal/tui/state" "github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/theme" ) type cacheItem struct { width int content []uiMessage } type messagesCmp struct { app *app.App width, height int viewport viewport.Model messages []message.Message uiMessages []uiMessage currentMsgID string cachedContent map[string]cacheItem spinner spinner.Model rendering bool attachments viewport.Model showToolMessages bool } type renderFinishedMsg struct{} type ToggleToolMessagesMsg struct{} type MessageKeys struct { PageDown key.Binding PageUp key.Binding HalfPageUp key.Binding HalfPageDown key.Binding } var messageKeys = MessageKeys{ PageDown: key.NewBinding( key.WithKeys("pgdown"), key.WithHelp("f/pgdn", "page down"), ), PageUp: key.NewBinding( key.WithKeys("pgup"), key.WithHelp("b/pgup", "page up"), ), HalfPageUp: key.NewBinding( key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up"), ), HalfPageDown: key.NewBinding( key.WithKeys("ctrl+d", "ctrl+d"), key.WithHelp("ctrl+d", "½ page down"), ), } func (m *messagesCmp) Init() tea.Cmd { return tea.Batch(m.viewport.Init(), m.spinner.Tick) } func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case dialog.ThemeChangedMsg: m.rerender() return m, nil case ToggleToolMessagesMsg: m.showToolMessages = !m.showToolMessages // Clear the cache to force re-rendering of all messages m.cachedContent = make(map[string]cacheItem) m.renderView() return m, nil case state.SessionSelectedMsg: cmd := m.Reload(msg) return m, cmd case state.SessionClearedMsg: m.messages = make([]message.Message, 0) m.currentMsgID = "" m.rendering = false return m, nil case tea.KeyMsg: if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) || key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) { u, cmd := m.viewport.Update(msg) m.viewport = u cmds = append(cmds, cmd) } case renderFinishedMsg: m.rendering = false m.viewport.GotoBottom() case pubsub.Event[message.Message]: needsRerender := false if msg.Type == message.EventMessageCreated { if msg.Payload.SessionID == m.app.CurrentSession.ID { messageExists := false for _, v := range m.messages { if v.ID == msg.Payload.ID { messageExists = true break } } if !messageExists { if len(m.messages) > 0 { lastMsgID := m.messages[len(m.messages)-1].ID delete(m.cachedContent, lastMsgID) } m.messages = append(m.messages, msg.Payload) delete(m.cachedContent, m.currentMsgID) m.currentMsgID = msg.Payload.ID needsRerender = true } } // There are tool calls from the child task for _, v := range m.messages { for _, c := range v.ToolCalls() { if c.ID == msg.Payload.SessionID { delete(m.cachedContent, v.ID) needsRerender = true } } } } else if msg.Type == message.EventMessageUpdated && msg.Payload.SessionID == m.app.CurrentSession.ID { for i, v := range m.messages { if v.ID == msg.Payload.ID { m.messages[i] = msg.Payload delete(m.cachedContent, msg.Payload.ID) needsRerender = true break } } } if needsRerender { m.renderView() if len(m.messages) > 0 { if (msg.Type == message.EventMessageCreated) || (msg.Type == message.EventMessageUpdated && msg.Payload.ID == m.messages[len(m.messages)-1].ID) { m.viewport.GotoBottom() } } } case app.StorageWriteMsg: // Handle storage write events from the TypeScript backend keyParts := strings.Split(msg.Key, "/") if len(keyParts) >= 4 && keyParts[0] == "session" && keyParts[1] == "message" { sessionID := keyParts[2] if sessionID == m.app.CurrentSession.ID { // Convert storage message to internal format convertedMsg, err := app.ConvertStorageMessage(msg.Content, sessionID) if err != nil { status.Error("Failed to convert message: " + err.Error()) return m, nil } // Check if message exists messageExists := false messageIndex := -1 for i, v := range m.messages { if v.ID == convertedMsg.ID { messageExists = true messageIndex = i break } } needsRerender := false if messageExists { // Update existing message m.messages[messageIndex] = *convertedMsg delete(m.cachedContent, convertedMsg.ID) needsRerender = true } else { // Add new message if len(m.messages) > 0 { lastMsgID := m.messages[len(m.messages)-1].ID delete(m.cachedContent, lastMsgID) } m.messages = append(m.messages, *convertedMsg) delete(m.cachedContent, m.currentMsgID) m.currentMsgID = convertedMsg.ID needsRerender = true } if needsRerender { m.renderView() m.viewport.GotoBottom() } } } } spinner, cmd := m.spinner.Update(msg) m.spinner = spinner cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m *messagesCmp) IsAgentWorking() bool { return m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID) } func formatTimeDifference(unixTime1, unixTime2 int64) string { diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1))) if diffSeconds < 60 { return fmt.Sprintf("%.1fs", diffSeconds) } minutes := int(diffSeconds / 60) seconds := int(diffSeconds) % 60 return fmt.Sprintf("%dm%ds", minutes, seconds) } func (m *messagesCmp) renderView() { m.uiMessages = make([]uiMessage, 0) pos := 0 baseStyle := styles.BaseStyle() if m.width == 0 { return } for inx, msg := range m.messages { switch msg.Role { case message.User: if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { m.uiMessages = append(m.uiMessages, cache.content...) continue } userMsg := renderUserMessage( msg, msg.ID == m.currentMsgID, m.width, pos, ) m.uiMessages = append(m.uiMessages, userMsg) m.cachedContent[msg.ID] = cacheItem{ width: m.width, content: []uiMessage{userMsg}, } pos += userMsg.height + 1 // + 1 for spacing case message.Assistant: if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { m.uiMessages = append(m.uiMessages, cache.content...) continue } assistantMessages := renderAssistantMessage( msg, inx, m.messages, m.app.Messages, m.currentMsgID, m.width, pos, m.showToolMessages, ) for _, msg := range assistantMessages { m.uiMessages = append(m.uiMessages, msg) pos += msg.height + 1 // + 1 for spacing } m.cachedContent[msg.ID] = cacheItem{ width: m.width, content: assistantMessages, } } } messages := make([]string, 0) for _, v := range m.uiMessages { messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content), baseStyle. Width(m.width). Render( "", ), ) } m.viewport.SetContent( baseStyle. Width(m.width). Render( lipgloss.JoinVertical( lipgloss.Top, messages..., ), ), ) } func (m *messagesCmp) View() string { baseStyle := styles.BaseStyle() if m.rendering { return baseStyle. Width(m.width). Render( lipgloss.JoinVertical( lipgloss.Top, "Loading...", m.working(), m.help(), ), ) } if len(m.messages) == 0 { content := baseStyle. Width(m.width). Height(m.height - 1). Render( m.initialScreen(), ) return baseStyle. Width(m.width). Render( lipgloss.JoinVertical( lipgloss.Top, content, "", m.help(), ), ) } return baseStyle. Width(m.width). Render( lipgloss.JoinVertical( lipgloss.Top, m.viewport.View(), m.working(), m.help(), ), ) } func hasToolsWithoutResponse(messages []message.Message) bool { toolCalls := make([]message.ToolCall, 0) toolResults := make([]message.ToolResult, 0) for _, m := range messages { toolCalls = append(toolCalls, m.ToolCalls()...) toolResults = append(toolResults, m.ToolResults()...) } for _, v := range toolCalls { found := false for _, r := range toolResults { if v.ID == r.ToolCallID { found = true break } } if !found && v.Finished { return true } } return false } func hasUnfinishedToolCalls(messages []message.Message) bool { toolCalls := make([]message.ToolCall, 0) for _, m := range messages { toolCalls = append(toolCalls, m.ToolCalls()...) } for _, v := range toolCalls { if !v.Finished { return true } } return false } func (m *messagesCmp) working() string { text := "" if m.IsAgentWorking() && len(m.messages) > 0 { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() task := "Thinking..." lastMessage := m.messages[len(m.messages)-1] if hasToolsWithoutResponse(m.messages) { task = "Waiting for tool response..." } else if hasUnfinishedToolCalls(m.messages) { task = "Building tool call..." } else if !lastMessage.IsFinished() { task = "Generating..." } if task != "" { text += baseStyle. Width(m.width). Foreground(t.Primary()). Bold(true). Render(fmt.Sprintf("%s %s ", m.spinner.View(), task)) } } return text } func (m *messagesCmp) help() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() text := "" if m.app.PrimaryAgent.IsBusy() { text += lipgloss.JoinHorizontal( lipgloss.Left, baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "), baseStyle.Foreground(t.Text()).Bold(true).Render("esc"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"), ) } else { text += lipgloss.JoinHorizontal( lipgloss.Left, baseStyle.Foreground(t.Text()).Bold(true).Render("enter"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"), baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"), baseStyle.Foreground(t.Text()).Bold(true).Render("enter"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"), baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"), baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"), baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"), ) } return baseStyle. Width(m.width). Render(text) } func (m *messagesCmp) initialScreen() string { baseStyle := styles.BaseStyle() return baseStyle.Width(m.width).Render( lipgloss.JoinVertical( lipgloss.Top, header(m.width), "", lspsConfigured(m.width), ), ) } func (m *messagesCmp) rerender() { for _, msg := range m.messages { delete(m.cachedContent, msg.ID) } m.renderView() } func (m *messagesCmp) SetSize(width, height int) tea.Cmd { if m.width == width && m.height == height { return nil } m.width = width m.height = height m.viewport.Width = width m.viewport.Height = height - 2 m.attachments.Width = width + 40 m.attachments.Height = 3 m.rerender() return nil } func (m *messagesCmp) GetSize() (int, int) { return m.width, m.height } func (m *messagesCmp) Reload(session *session.Session) tea.Cmd { messages, err := m.app.Messages.List(context.Background(), session.ID) if err != nil { status.Error(err.Error()) return nil } m.messages = messages if len(m.messages) > 0 { m.currentMsgID = m.messages[len(m.messages)-1].ID } delete(m.cachedContent, m.currentMsgID) m.rendering = true return func() tea.Msg { m.renderView() return renderFinishedMsg{} } } func (m *messagesCmp) BindingKeys() []key.Binding { return []key.Binding{ m.viewport.KeyMap.PageDown, m.viewport.KeyMap.PageUp, m.viewport.KeyMap.HalfPageUp, m.viewport.KeyMap.HalfPageDown, } } func NewMessagesCmp(app *app.App) tea.Model { customSpinner := spinner.Spinner{ Frames: []string{" ", "┃", "┃"}, FPS: time.Second / 3, } s := spinner.New(spinner.WithSpinner(customSpinner)) vp := viewport.New(0, 0) attachmets := viewport.New(0, 0) vp.KeyMap.PageUp = messageKeys.PageUp vp.KeyMap.PageDown = messageKeys.PageDown vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown return &messagesCmp{ app: app, cachedContent: make(map[string]cacheItem), viewport: vp, spinner: s, attachments: attachmets, showToolMessages: true, } }