mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
454 lines
11 KiB
Go
454 lines
11 KiB
Go
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,
|
|
}
|
|
}
|