mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
364 lines
8.6 KiB
Go
364 lines
8.6 KiB
Go
package chat
|
|
|
|
import (
|
|
"fmt"
|
|
"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/components/dialog"
|
|
"github.com/sst/opencode/internal/state"
|
|
"github.com/sst/opencode/internal/styles"
|
|
"github.com/sst/opencode/internal/theme"
|
|
"github.com/sst/opencode/pkg/client"
|
|
)
|
|
|
|
type messagesCmp struct {
|
|
app *app.App
|
|
width, height int
|
|
viewport viewport.Model
|
|
spinner spinner.Model
|
|
rendering bool
|
|
attachments viewport.Model
|
|
showToolMessages bool
|
|
cache *MessageCache
|
|
}
|
|
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.cache.Clear()
|
|
m.renderView()
|
|
return m, nil
|
|
case ToggleToolMessagesMsg:
|
|
m.showToolMessages = !m.showToolMessages
|
|
m.renderView()
|
|
return m, nil
|
|
case state.SessionSelectedMsg:
|
|
// Clear cache when switching sessions
|
|
m.cache.Clear()
|
|
cmd := m.Reload()
|
|
return m, cmd
|
|
case state.SessionClearedMsg:
|
|
// Clear cache when session is cleared
|
|
m.cache.Clear()
|
|
cmd := m.Reload()
|
|
return m, cmd
|
|
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 state.StateUpdatedMsg:
|
|
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) renderView() {
|
|
if m.width == 0 {
|
|
return
|
|
}
|
|
|
|
messages := make([]string, 0)
|
|
for _, msg := range m.app.Messages {
|
|
var content string
|
|
var cached bool
|
|
|
|
switch msg.Role {
|
|
case client.User:
|
|
content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
|
|
if !cached {
|
|
content = renderUserMessage(m.app.Info.User, msg, m.width)
|
|
m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
|
|
}
|
|
messages = append(messages, content+"\n")
|
|
case client.Assistant:
|
|
content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
|
|
if !cached {
|
|
content = renderAssistantMessage(msg, m.width, m.showToolMessages, *m.app.Info)
|
|
m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
|
|
}
|
|
messages = append(messages, content+"\n")
|
|
}
|
|
}
|
|
|
|
m.viewport.SetContent(
|
|
styles.BaseStyle().
|
|
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.app.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 len(m.app.Messages) > 0 {
|
|
t := theme.CurrentTheme()
|
|
baseStyle := styles.BaseStyle()
|
|
|
|
task := ""
|
|
if m.app.IsBusy() {
|
|
task = "Working..."
|
|
}
|
|
// lastMessage := m.app.Messages[len(m.app.Messages)-1]
|
|
// if hasToolsWithoutResponse(m.app.Messages) {
|
|
// task = "Waiting for tool response..."
|
|
// } else if hasUnfinishedToolCalls(m.app.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.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.app, m.width),
|
|
"",
|
|
lspsConfigured(m.width),
|
|
),
|
|
)
|
|
}
|
|
|
|
func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
|
|
if m.width == width && m.height == height {
|
|
return nil
|
|
}
|
|
// Clear cache on resize since width affects rendering
|
|
if m.width != width {
|
|
m.cache.Clear()
|
|
}
|
|
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.renderView()
|
|
return nil
|
|
}
|
|
|
|
func (m *messagesCmp) GetSize() (int, int) {
|
|
return m.width, m.height
|
|
}
|
|
|
|
func (m *messagesCmp) Reload() tea.Cmd {
|
|
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)
|
|
attachments := 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,
|
|
viewport: vp,
|
|
spinner: s,
|
|
attachments: attachments,
|
|
showToolMessages: true,
|
|
cache: NewMessageCache(),
|
|
}
|
|
}
|