mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 05:28:16 +00:00
410 lines
11 KiB
Go
410 lines
11 KiB
Go
package chat
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/v2/key"
|
|
"github.com/charmbracelet/bubbles/v2/spinner"
|
|
"github.com/charmbracelet/bubbles/v2/viewport"
|
|
tea "github.com/charmbracelet/bubbletea/v2"
|
|
"github.com/charmbracelet/lipgloss/v2"
|
|
"github.com/sst/opencode/internal/app"
|
|
"github.com/sst/opencode/internal/components/dialog"
|
|
"github.com/sst/opencode/internal/layout"
|
|
"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 messagesComponent struct {
|
|
app *app.App
|
|
width, height int
|
|
viewport viewport.Model
|
|
spinner spinner.Model
|
|
rendering bool
|
|
attachments viewport.Model
|
|
showToolResults bool
|
|
cache *MessageCache
|
|
tail 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 *messagesComponent) Init() tea.Cmd {
|
|
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
|
|
}
|
|
|
|
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
switch msg := msg.(type) {
|
|
case SendMsg:
|
|
m.viewport.GotoBottom()
|
|
m.tail = true
|
|
return m, nil
|
|
case dialog.ThemeChangedMsg:
|
|
m.cache.Clear()
|
|
m.renderView()
|
|
return m, nil
|
|
case ToggleToolMessagesMsg:
|
|
m.showToolResults = !m.showToolResults
|
|
m.renderView()
|
|
return m, nil
|
|
case state.SessionSelectedMsg:
|
|
m.cache.Clear()
|
|
cmd := m.Reload()
|
|
m.viewport.GotoBottom()
|
|
return m, cmd
|
|
case state.SessionClearedMsg:
|
|
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
|
|
m.tail = m.viewport.AtBottom()
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
case renderFinishedMsg:
|
|
m.rendering = false
|
|
if m.tail {
|
|
m.viewport.GotoBottom()
|
|
}
|
|
case state.StateUpdatedMsg:
|
|
m.renderView()
|
|
if m.tail {
|
|
m.viewport.GotoBottom()
|
|
}
|
|
}
|
|
|
|
spinner, cmd := m.spinner.Update(msg)
|
|
m.spinner = spinner
|
|
cmds = append(cmds, cmd)
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
type blockType int
|
|
|
|
const (
|
|
none blockType = iota
|
|
userTextBlock
|
|
assistantTextBlock
|
|
toolInvocationBlock
|
|
errorBlock
|
|
)
|
|
|
|
func (m *messagesComponent) renderView() {
|
|
if m.width == 0 {
|
|
return
|
|
}
|
|
|
|
t := theme.CurrentTheme()
|
|
blocks := make([]string, 0)
|
|
previousBlockType := none
|
|
for _, message := range m.app.Messages {
|
|
var content string
|
|
var cached bool
|
|
|
|
author := ""
|
|
switch message.Role {
|
|
case client.User:
|
|
author = app.Info.User
|
|
case client.Assistant:
|
|
author = message.Metadata.Assistant.ModelID
|
|
}
|
|
|
|
for _, p := range message.Parts {
|
|
part, err := p.ValueByDiscriminator()
|
|
if err != nil {
|
|
continue //TODO: handle error?
|
|
}
|
|
|
|
switch part.(type) {
|
|
// case client.MessagePartStepStart:
|
|
// messages = append(messages, "")
|
|
case client.MessagePartText:
|
|
text := part.(client.MessagePartText)
|
|
key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
|
|
content, cached = m.cache.Get(key)
|
|
if !cached {
|
|
content = renderText(message, text.Text, author)
|
|
m.cache.Set(key, content)
|
|
}
|
|
if previousBlockType != none {
|
|
blocks = append(blocks, "")
|
|
}
|
|
blocks = append(blocks, content)
|
|
if message.Role == client.User {
|
|
previousBlockType = userTextBlock
|
|
} else if message.Role == client.Assistant {
|
|
previousBlockType = assistantTextBlock
|
|
}
|
|
case client.MessagePartToolInvocation:
|
|
toolInvocationPart := part.(client.MessagePartToolInvocation)
|
|
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
|
|
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
|
|
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
|
|
metadata = message.Metadata.Tool[toolCall.ToolCallId]
|
|
}
|
|
var result *string
|
|
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
|
|
if resultError == nil {
|
|
result = &resultPart.Result
|
|
}
|
|
|
|
if toolCall.State == "result" {
|
|
key := m.cache.GenerateKey(message.Id,
|
|
toolCall.ToolCallId,
|
|
m.showToolResults,
|
|
layout.Current.Viewport.Width,
|
|
)
|
|
content, cached = m.cache.Get(key)
|
|
if !cached {
|
|
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
|
|
m.cache.Set(key, content)
|
|
}
|
|
} else {
|
|
// if the tool call isn't finished, never cache
|
|
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
|
|
}
|
|
|
|
if previousBlockType != toolInvocationBlock {
|
|
blocks = append(blocks, "")
|
|
}
|
|
blocks = append(blocks, content)
|
|
previousBlockType = toolInvocationBlock
|
|
}
|
|
}
|
|
|
|
error := ""
|
|
if message.Metadata.Error != nil {
|
|
errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
|
|
switch errorValue.(type) {
|
|
case client.UnknownError:
|
|
clientError := errorValue.(client.UnknownError)
|
|
error = clientError.Data.Message
|
|
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
|
|
blocks = append(blocks, error)
|
|
previousBlockType = errorBlock
|
|
}
|
|
}
|
|
}
|
|
|
|
centered := []string{}
|
|
for _, block := range blocks {
|
|
centered = append(centered, lipgloss.PlaceHorizontal(
|
|
m.width,
|
|
lipgloss.Center,
|
|
block,
|
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
|
))
|
|
}
|
|
|
|
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()))
|
|
m.viewport.SetContent("\n" + strings.Join(centered, "\n") + "\n")
|
|
}
|
|
|
|
func (m *messagesComponent) header() string {
|
|
if m.app.Session.Id == "" {
|
|
return ""
|
|
}
|
|
|
|
t := theme.CurrentTheme()
|
|
width := layout.Current.Container.Width
|
|
base := styles.BaseStyle().Render
|
|
muted := styles.Muted().Render
|
|
headerLines := []string{}
|
|
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-4, t.BackgroundElement()))
|
|
if m.app.Session.Share != nil && 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.BaseStyle().
|
|
Width(width).
|
|
PaddingLeft(2).
|
|
Background(t.BackgroundElement()).
|
|
BorderLeft(true).
|
|
BorderRight(true).
|
|
BorderBackground(t.Background()).
|
|
BorderForeground(t.BackgroundSubtle()).
|
|
BorderStyle(lipgloss.ThickBorder()).
|
|
Render(header)
|
|
|
|
return "\n" + header + "\n"
|
|
}
|
|
|
|
func (m *messagesComponent) View() string {
|
|
if len(m.app.Messages) == 0 {
|
|
return m.home()
|
|
}
|
|
if m.rendering {
|
|
return m.viewport.View()
|
|
}
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
|
|
m.viewport.View(),
|
|
)
|
|
}
|
|
|
|
func (m *messagesComponent) home() string {
|
|
// t := theme.CurrentTheme()
|
|
baseStyle := styles.BaseStyle()
|
|
base := baseStyle.Render
|
|
muted := styles.Muted().Render
|
|
|
|
// mark := `
|
|
// ███▀▀█
|
|
// ███ █
|
|
// ▀▀▀▀▀▀ `
|
|
open := `
|
|
█▀▀█ █▀▀█ █▀▀ █▀▀▄
|
|
█░░█ █░░█ █▀▀ █░░█
|
|
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `
|
|
code := `
|
|
█▀▀ █▀▀█ █▀▀▄ █▀▀
|
|
█░░ █░░█ █░░█ █▀▀
|
|
▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`
|
|
|
|
logo := lipgloss.JoinHorizontal(
|
|
lipgloss.Top,
|
|
// styles.BaseStyle().Foreground(t.Primary()).Render(mark),
|
|
styles.Muted().Render(open),
|
|
styles.BaseStyle().Render(code),
|
|
)
|
|
// cwd := app.Info.Path.Cwd
|
|
// config := app.Info.Path.Config
|
|
|
|
commands := [][]string{
|
|
{"/help", "show help"},
|
|
{"/sessions", "list sessions"},
|
|
{"/new", "start a new session"},
|
|
{"/model", "switch model"},
|
|
{"/share", "share the current session"},
|
|
{"/exit", "exit the app"},
|
|
}
|
|
|
|
commandLines := []string{}
|
|
for _, command := range commands {
|
|
commandLines = append(commandLines, (base(command[0]) + " " + muted(command[1])))
|
|
}
|
|
|
|
logoAndVersion := lipgloss.JoinVertical(
|
|
lipgloss.Right,
|
|
logo,
|
|
muted(app.Info.Version),
|
|
)
|
|
|
|
lines := []string{}
|
|
lines = append(lines, "")
|
|
lines = append(lines, "")
|
|
lines = append(lines, logoAndVersion)
|
|
lines = append(lines, "")
|
|
// lines = append(lines, base("cwd ")+muted(cwd))
|
|
// lines = append(lines, base("config ")+muted(config))
|
|
// lines = append(lines, "")
|
|
lines = append(lines, commandLines...)
|
|
lines = append(lines, "")
|
|
if m.rendering {
|
|
lines = append(lines, styles.Muted().Render("Loading session..."))
|
|
} else {
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
|
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
|
|
lipgloss.JoinVertical(
|
|
lipgloss.Top,
|
|
lines...,
|
|
),
|
|
))
|
|
}
|
|
|
|
func (m *messagesComponent) 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.SetWidth(width)
|
|
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
|
|
m.attachments.SetWidth(width + 40)
|
|
m.attachments.SetHeight(3)
|
|
m.renderView()
|
|
return nil
|
|
}
|
|
|
|
func (m *messagesComponent) GetSize() (int, int) {
|
|
return m.width, m.height
|
|
}
|
|
|
|
func (m *messagesComponent) Reload() tea.Cmd {
|
|
m.rendering = true
|
|
return func() tea.Msg {
|
|
m.renderView()
|
|
return renderFinishedMsg{}
|
|
}
|
|
}
|
|
|
|
func NewMessagesComponent(app *app.App) layout.ModelWithView {
|
|
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 &messagesComponent{
|
|
app: app,
|
|
viewport: vp,
|
|
spinner: s,
|
|
attachments: attachments,
|
|
showToolResults: true,
|
|
cache: NewMessageCache(),
|
|
tail: true,
|
|
}
|
|
}
|