| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466 |
- package chat
- import (
- "context"
- "fmt"
- "math"
- "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/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/message"
- "github.com/opencode-ai/opencode/internal/pubsub"
- "github.com/opencode-ai/opencode/internal/session"
- "github.com/opencode-ai/opencode/internal/tui/components/dialog"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
- "github.com/opencode-ai/opencode/internal/tui/util"
- )
- type cacheItem struct {
- width int
- content []uiMessage
- }
- type messagesCmp struct {
- app *app.App
- width, height int
- viewport viewport.Model
- session session.Session
- messages []message.Message
- uiMessages []uiMessage
- currentMsgID string
- cachedContent map[string]cacheItem
- spinner spinner.Model
- rendering bool
- }
- type renderFinishedMsg 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 SessionSelectedMsg:
- if msg.ID != m.session.ID {
- cmd := m.SetSession(msg)
- return m, cmd
- }
- return m, nil
- case SessionClearedMsg:
- m.session = session.Session{}
- 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 == pubsub.CreatedEvent {
- if msg.Payload.SessionID == m.session.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 == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.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 == pubsub.CreatedEvent) ||
- (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
- 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.CoderAgent.IsSessionBusy(m.session.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,
- )
- 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, 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.CoderAgent.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 exit cancel"),
- )
- } else {
- text += lipgloss.JoinHorizontal(
- lipgloss.Left,
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
- baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"),
- baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"),
- )
- }
- 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.rerender()
- return nil
- }
- func (m *messagesCmp) GetSize() (int, int) {
- return m.width, m.height
- }
- func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
- if m.session.ID == session.ID {
- return nil
- }
- m.session = session
- messages, err := m.app.Messages.List(context.Background(), session.ID)
- if err != nil {
- return util.ReportError(err)
- }
- m.messages = messages
- 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 {
- s := spinner.New()
- s.Spinner = spinner.Pulse
- vp := 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,
- }
- }
|