opencode/packages/tui/internal/components/chat/messages.go
2025-07-31 16:51:55 -04:00

1130 lines
29 KiB
Go

package chat
import (
"context"
"fmt"
"log/slog"
"slices"
"sort"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/internal/viewport"
)
type MessagesComponent interface {
tea.Model
tea.ViewModel
PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd)
HalfPageUp() (tea.Model, tea.Cmd)
HalfPageDown() (tea.Model, tea.Cmd)
ToolDetailsVisible() bool
GotoTop() (tea.Model, tea.Cmd)
GotoBottom() (tea.Model, tea.Cmd)
CopyLastMessage() (tea.Model, tea.Cmd)
UndoLastMessage() (tea.Model, tea.Cmd)
RedoLastMessage() (tea.Model, tea.Cmd)
}
type messagesComponent struct {
width, height int
app *app.App
header string
viewport viewport.Model
clipboard []string
cache *PartCache
loading bool
showToolDetails bool
rendering bool
dirty bool
tail bool
partCount int
lineCount int
selection *selection
}
type selection struct {
startX int
endX int
startY int
endY int
}
func (s selection) coords(offset int) *selection {
// selecting backwards
if s.startY > s.endY && s.endY >= 0 {
return &selection{
startX: max(0, s.endX-1),
startY: s.endY - offset,
endX: s.startX + 1,
endY: s.startY - offset,
}
}
// selecting backwards same line
if s.startY == s.endY && s.startX >= s.endX {
return &selection{
startY: s.startY - offset,
startX: max(0, s.endX-1),
endY: s.endY - offset,
endX: s.startX + 1,
}
}
return &selection{
startX: s.startX,
startY: s.startY - offset,
endX: s.endX,
endY: s.endY - offset,
}
}
type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init())
}
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.MouseClickMsg:
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
y := msg.Y + m.viewport.YOffset
if y > 0 {
m.selection = &selection{
startY: y,
startX: msg.X,
endY: -1,
endX: -1,
}
slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY))
return m, m.renderView()
}
case tea.MouseMotionMsg:
if m.selection != nil {
m.selection = &selection{
startX: m.selection.startX,
startY: m.selection.startY,
endX: msg.X + 1,
endY: msg.Y + m.viewport.YOffset,
}
return m, m.renderView()
}
case tea.MouseReleaseMsg:
if m.selection != nil && len(m.clipboard) > 0 {
content := strings.Join(m.clipboard, "\n")
m.selection = nil
m.clipboard = []string{}
return m, tea.Sequence(
m.renderView(),
app.SetClipboard(content),
toast.NewSuccessToast("Copied to clipboard"),
)
}
case tea.WindowSizeMsg:
effectiveWidth := msg.Width - 4
// Clear cache on resize since width affects rendering
if m.width != effectiveWidth {
m.cache.Clear()
}
m.width = effectiveWidth
m.height = msg.Height - 7
m.viewport.SetWidth(m.width)
m.loading = true
return m, m.renderView()
case app.SendPrompt:
m.viewport.GotoBottom()
m.tail = true
return m, nil
case dialog.ThemeSelectedMsg:
m.cache.Clear()
m.loading = true
return m, m.renderView()
case ToggleToolDetailsMsg:
m.showToolDetails = !m.showToolDetails
return m, m.renderView()
case app.SessionLoadedMsg, app.SessionClearedMsg:
m.cache.Clear()
m.tail = true
m.loading = true
return m, m.renderView()
case app.SessionUnrevertedMsg:
if msg.Session.ID == m.app.Session.ID {
m.cache.Clear()
m.tail = true
return m, m.renderView()
}
case app.MessageRevertedMsg:
if msg.Session.ID == m.app.Session.ID {
m.cache.Clear()
m.tail = true
return m, m.renderView()
}
case opencode.EventListResponseEventSessionUpdated:
if msg.Properties.Info.ID == m.app.Session.ID {
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.SessionID == m.app.Session.ID {
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessagePartUpdated:
if msg.Properties.Part.SessionID == m.app.Session.ID {
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessagePartRemoved:
if msg.Properties.SessionID == m.app.Session.ID {
// Clear the cache when a part is removed to ensure proper re-rendering
m.cache.Clear()
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventPermissionUpdated:
m.tail = true
return m, m.renderView()
case opencode.EventListResponseEventPermissionReplied:
m.tail = true
return m, m.renderView()
case renderCompleteMsg:
m.partCount = msg.partCount
m.lineCount = msg.lineCount
m.rendering = false
m.clipboard = msg.clipboard
m.loading = false
m.tail = m.viewport.AtBottom()
m.viewport = msg.viewport
m.header = msg.header
if m.dirty {
cmds = append(cmds, m.renderView())
}
}
m.tail = m.viewport.AtBottom()
viewport, cmd := m.viewport.Update(msg)
m.viewport = viewport
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
type renderCompleteMsg struct {
viewport viewport.Model
clipboard []string
header string
partCount int
lineCount int
}
func (m *messagesComponent) renderView() tea.Cmd {
if m.rendering {
slog.Debug("pending render, skipping")
m.dirty = true
return func() tea.Msg {
return nil
}
}
m.dirty = false
m.rendering = true
viewport := m.viewport
tail := m.tail
return func() tea.Msg {
header := m.renderHeader()
measure := util.Measure("messages.renderView")
defer measure()
t := theme.CurrentTheme()
blocks := make([]string, 0)
partCount := 0
lineCount := 0
orphanedToolCalls := make([]opencode.ToolPart, 0)
width := m.width // always use full width
reverted := false
revertedMessageCount := 0
revertedToolCount := 0
lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
for _, msg := range slices.Backward(m.app.Messages) {
if assistant, ok := msg.Info.(opencode.AssistantMessage); ok {
lastAssistantMessage = assistant.ID
break
}
}
for _, message := range m.app.Messages {
var content string
var cached bool
switch casted := message.Info.(type) {
case opencode.UserMessage:
if casted.ID == m.app.Session.Revert.MessageID {
reverted = true
revertedMessageCount = 1
revertedToolCount = 0
continue
}
if reverted {
revertedMessageCount++
continue
}
for partIndex, part := range message.Parts {
switch part := part.(type) {
case opencode.TextPart:
if part.Synthetic {
continue
}
if part.Text == "" {
continue
}
remainingParts := message.Parts[partIndex+1:]
fileParts := make([]opencode.FilePart, 0)
for _, part := range remainingParts {
switch part := part.(type) {
case opencode.FilePart:
fileParts = append(fileParts, part)
}
}
flexItems := []layout.FlexItem{}
if len(fileParts) > 0 {
fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
for _, filePart := range fileParts {
mediaType := ""
switch filePart.Mime {
case "text/plain":
mediaType = "txt"
case "image/png", "image/jpeg", "image/gif", "image/webp":
mediaType = "img"
mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
case "application/pdf":
mediaType = "pdf"
mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
}
flexItems = append(flexItems, layout.FlexItem{
View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
})
}
}
bgColor := t.BackgroundPanel()
files := layout.Render(
layout.FlexOptions{
Background: &bgColor,
Width: width - 6,
Direction: layout.Column,
},
flexItems...,
)
author := m.app.Config.Username
if casted.ID > lastAssistantMessage {
author += " [queued]"
}
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
m.app,
message.Info,
part.Text,
author,
m.showToolDetails,
width,
files,
fileParts,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
if content != "" {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
}
}
case opencode.AssistantMessage:
if casted.ID == m.app.Session.Revert.MessageID {
reverted = true
revertedMessageCount = 1
revertedToolCount = 0
}
hasTextPart := false
for partIndex, p := range message.Parts {
switch part := p.(type) {
case opencode.TextPart:
if reverted {
continue
}
if strings.TrimSpace(part.Text) == "" {
continue
}
hasTextPart = true
finished := part.Time.End > 0
remainingParts := message.Parts[partIndex+1:]
toolCallParts := make([]opencode.ToolPart, 0)
// sometimes tool calls happen without an assistant message
// these should be included in this assistant message as well
if len(orphanedToolCalls) > 0 {
toolCallParts = append(toolCallParts, orphanedToolCalls...)
orphanedToolCalls = make([]opencode.ToolPart, 0)
}
remaining := true
for _, part := range remainingParts {
if !remaining {
break
}
switch part := part.(type) {
case opencode.TextPart:
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
remaining = false
case opencode.ToolPart:
toolCallParts = append(toolCallParts, part)
if part.State.Status != opencode.ToolPartStateStatusCompleted && part.State.Status != opencode.ToolPartStateStatusError {
// 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(casted.ID, part.Text, width, m.showToolDetails)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
m.app,
message.Info,
part.Text,
casted.ModelID,
m.showToolDetails,
width,
"",
[]opencode.FilePart{},
toolCallParts...,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
} else {
content = renderText(
m.app,
message.Info,
part.Text,
casted.ModelID,
m.showToolDetails,
width,
"",
[]opencode.FilePart{},
toolCallParts...,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
}
if content != "" {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
case opencode.ToolPart:
if reverted {
revertedToolCount++
continue
}
permission := opencode.Permission{}
if m.app.CurrentPermission.CallID == part.CallID {
permission = m.app.CurrentPermission
}
if !m.showToolDetails && permission.ID == "" {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
}
continue
}
if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
key := m.cache.GenerateKey(casted.ID,
part.ID,
m.showToolDetails,
width,
permission.ID,
)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolDetails(
m.app,
part,
permission,
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, don't cache
content = renderToolDetails(
m.app,
part,
permission,
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
}
if content != "" {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
}
}
}
error := ""
if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
switch err := assistant.Error.AsUnion().(type) {
case nil:
case opencode.AssistantMessageErrorMessageOutputLengthError:
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.MessageAbortedError:
error = "Request was aborted"
case opencode.UnknownError:
error = err.Data.Message
}
}
if error != "" && !reverted {
error = styles.NewStyle().Width(width - 6).Render(error)
error = renderContentBlock(
m.app,
error,
width,
WithBorderColor(t.Error()),
)
error = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
error,
styles.WhitespaceStyle(t.Background()),
)
blocks = append(blocks, error)
lineCount += lipgloss.Height(error) + 1
}
}
if revertedMessageCount > 0 || revertedToolCount > 0 {
messagePlural := ""
toolPlural := ""
if revertedMessageCount != 1 {
messagePlural = "s"
}
if revertedToolCount != 1 {
toolPlural = "s"
}
revertedStyle := styles.NewStyle().
Background(t.BackgroundPanel()).
Foreground(t.TextMuted())
content := revertedStyle.Render(fmt.Sprintf(
"%d message%s reverted, %d tool call%s reverted",
revertedMessageCount,
messagePlural,
revertedToolCount,
toolPlural,
))
hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text())
hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand))
hint += revertedStyle.Render(" (or /redo) to restore")
content += "\n" + hint
if m.app.Session.Revert.Diff != "" {
t := theme.CurrentTheme()
s := styles.NewStyle().Background(t.BackgroundPanel())
green := s.Foreground(t.Success()).Render
red := s.Foreground(t.Error()).Render
content += "\n"
stats, err := diff.ParseStats(m.app.Session.Revert.Diff)
if err != nil {
slog.Error("Failed to parse diff stats", "error", err)
} else {
var files []string
for file := range stats {
files = append(files, file)
}
sort.Strings(files)
for _, file := range files {
fileStats := stats[file]
display := file
if fileStats.Added > 0 {
display += green(" +" + strconv.Itoa(int(fileStats.Added)))
}
if fileStats.Removed > 0 {
display += red(" -" + strconv.Itoa(int(fileStats.Removed)))
}
content += "\n" + display
}
}
}
content = styles.NewStyle().
Background(t.BackgroundPanel()).
Width(width - 6).
Render(content)
content = renderContentBlock(
m.app,
content,
width,
WithBorderColor(t.BackgroundPanel()),
)
blocks = append(blocks, content)
}
if m.app.CurrentPermission.ID != "" &&
m.app.CurrentPermission.SessionID != m.app.Session.ID {
response, err := m.app.Client.Session.Message(
context.Background(),
m.app.CurrentPermission.SessionID,
m.app.CurrentPermission.MessageID,
)
if err != nil || response == nil {
slog.Error("Failed to get message from child session", "error", err)
} else {
for _, part := range response.Parts {
if part.CallID == m.app.CurrentPermission.CallID {
content := renderToolDetails(
m.app,
part.AsUnion().(opencode.ToolPart),
m.app.CurrentPermission,
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
if content != "" {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
}
}
}
}
final := []string{}
clipboard := []string{}
var selection *selection
if m.selection != nil {
selection = m.selection.coords(lipgloss.Height(header) + 1)
}
for _, block := range blocks {
lines := strings.Split(block, "\n")
for index, line := range lines {
if selection == nil || index == 0 || index == len(lines)-1 {
final = append(final, line)
continue
}
y := len(final)
if y >= selection.startY && y <= selection.endY {
left := 3
if y == selection.startY {
left = selection.startX - 2
}
left = max(3, left)
width := ansi.StringWidth(line)
right := width - 1
if y == selection.endY {
right = min(selection.endX-2, right)
}
prefix := ansi.Cut(line, 0, left)
middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width)
clipboard = append(clipboard, middle)
line = prefix + styles.NewStyle().
Background(t.Accent()).
Foreground(t.BackgroundPanel()).
Render(ansi.Strip(middle)) +
suffix
}
final = append(final, line)
}
y := len(final)
if selection != nil && y >= selection.startY && y < selection.endY {
clipboard = append(clipboard, "")
}
final = append(final, "")
}
content := "\n" + strings.Join(final, "\n")
viewport.SetHeight(m.height - lipgloss.Height(header))
viewport.SetContent(content)
if tail {
viewport.GotoBottom()
}
return renderCompleteMsg{
header: header,
clipboard: clipboard,
viewport: viewport,
partCount: partCount,
lineCount: lineCount,
}
}
}
func (m *messagesComponent) renderHeader() string {
if m.app.Session.ID == "" {
return ""
}
headerWidth := m.width
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
sessionInfo := ""
tokens := float64(0)
cost := float64(0)
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
cost += assistant.Cost
usage := assistant.Tokens
if usage.Output > 0 {
if assistant.Summary {
tokens = usage.Output
continue
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}
}
// Check if current model is a subscription model (cost is 0 for both input and output)
isSubscriptionModel := m.app.Model != nil &&
m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
sessionInfo = styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.Background()).
Render(sessionInfoText)
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
headerTextWidth := headerWidth
if !shareEnabled {
// +1 is to ensure there is always at least one space between header and session info
headerTextWidth -= len(sessionInfoText) + 1
}
headerText := util.ToMarkdown(
"# "+m.app.Session.Title,
headerTextWidth,
t.Background(),
)
var items []layout.FlexItem
if shareEnabled {
share := base("/share") + muted(" to create a shareable link")
if m.app.Session.Share.URL != "" {
share = muted(m.app.Session.Share.URL + " /unshare")
}
items = []layout.FlexItem{{View: share}, {View: sessionInfo}}
} else {
items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
}
background := t.Background()
headerRow := layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Align: layout.AlignStretch,
Width: headerWidth - 6,
},
items...,
)
headerLines := []string{headerRow}
if shareEnabled {
headerLines = []string{headerText, headerRow}
}
header := strings.Join(headerLines, "\n")
header = styles.NewStyle().
Background(t.Background()).
Width(headerWidth).
PaddingLeft(2).
PaddingRight(2).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()).
Render(header)
header = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
header,
styles.WhitespaceStyle(t.Background()),
)
return "\n" + header + "\n"
}
func formatTokensAndCost(
tokens float64,
contextWindow float64,
cost float64,
isSubscriptionModel bool,
) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
percentage := 0.0
if contextWindow > 0 {
percentage = (float64(tokens) / float64(contextWindow)) * 100
}
if isSubscriptionModel {
return fmt.Sprintf(
"%s/%d%%",
formattedTokens,
int(percentage),
)
}
formattedCost := fmt.Sprintf("$%.2f", cost)
return fmt.Sprintf(
"%s/%d%% (%s)",
formattedTokens,
int(percentage),
formattedCost,
)
}
func (m *messagesComponent) View() string {
t := theme.CurrentTheme()
if m.loading {
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
styles.NewStyle().Background(t.Background()).Render(""),
styles.WhitespaceStyle(t.Background()),
)
}
viewport := m.viewport.View()
return styles.NewStyle().
Background(t.Background()).
Render(m.header + "\n" + viewport)
}
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) ToolDetailsVisible() bool {
return m.showToolDetails
}
func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) {
m.viewport.GotoTop()
return m, nil
}
func (m *messagesComponent) GotoBottom() (tea.Model, tea.Cmd) {
m.viewport.GotoBottom()
return m, nil
}
func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
if len(m.app.Messages) == 0 {
return m, nil
}
lastMessage := m.app.Messages[len(m.app.Messages)-1]
var lastTextPart *opencode.TextPart
for _, part := range lastMessage.Parts {
if p, ok := part.(opencode.TextPart); ok {
lastTextPart = &p
}
}
if lastTextPart == nil {
return m, nil
}
var cmds []tea.Cmd
cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
return m, tea.Batch(cmds...)
}
func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) {
after := float64(0)
var revertedMessage app.Message
reversedMessages := []app.Message{}
for i := len(m.app.Messages) - 1; i >= 0; i-- {
reversedMessages = append(reversedMessages, m.app.Messages[i])
switch casted := m.app.Messages[i].Info.(type) {
case opencode.UserMessage:
if casted.ID == m.app.Session.Revert.MessageID {
after = casted.Time.Created
}
case opencode.AssistantMessage:
if casted.ID == m.app.Session.Revert.MessageID {
after = casted.Time.Created
}
}
if m.app.Session.Revert.PartID != "" {
for _, part := range m.app.Messages[i].Parts {
switch casted := part.(type) {
case opencode.TextPart:
if casted.ID == m.app.Session.Revert.PartID {
after = casted.Time.Start
}
case opencode.ToolPart:
// TODO: handle tool parts
}
}
}
}
messageID := ""
for _, msg := range reversedMessages {
switch casted := msg.Info.(type) {
case opencode.UserMessage:
if after > 0 && casted.Time.Created >= after {
continue
}
messageID = casted.ID
revertedMessage = msg
}
if messageID != "" {
break
}
}
if messageID == "" {
return m, nil
}
return m, func() tea.Msg {
response, err := m.app.Client.Session.Revert(
context.Background(),
m.app.Session.ID,
opencode.SessionRevertParams{
MessageID: opencode.F(messageID),
},
)
if err != nil {
slog.Error("Failed to undo message", "error", err)
return toast.NewErrorToast("Failed to undo message")
}
if response == nil {
return toast.NewErrorToast("Failed to undo message")
}
return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
}
}
func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
// Check if there's a revert state to redo from
if m.app.Session.Revert.MessageID == "" {
return m, func() tea.Msg {
return toast.NewErrorToast("Nothing to redo")
}
}
before := float64(0)
var revertedMessage app.Message
for _, message := range m.app.Messages {
switch casted := message.Info.(type) {
case opencode.UserMessage:
if casted.ID == m.app.Session.Revert.MessageID {
before = casted.Time.Created
}
case opencode.AssistantMessage:
if casted.ID == m.app.Session.Revert.MessageID {
before = casted.Time.Created
}
}
if m.app.Session.Revert.PartID != "" {
for _, part := range message.Parts {
switch casted := part.(type) {
case opencode.TextPart:
if casted.ID == m.app.Session.Revert.PartID {
before = casted.Time.Start
}
case opencode.ToolPart:
// TODO: handle tool parts
}
}
}
}
messageID := ""
for _, msg := range m.app.Messages {
switch casted := msg.Info.(type) {
case opencode.UserMessage:
if casted.Time.Created <= before {
continue
}
messageID = casted.ID
revertedMessage = msg
}
if messageID != "" {
break
}
}
if messageID == "" {
return m, func() tea.Msg {
// unrevert back to original state
response, err := m.app.Client.Session.Unrevert(
context.Background(),
m.app.Session.ID,
)
if err != nil {
slog.Error("Failed to unrevert session", "error", err)
return toast.NewErrorToast("Failed to redo message")
}
if response == nil {
return toast.NewErrorToast("Failed to redo message")
}
return app.SessionUnrevertedMsg{Session: *response}
}
}
return m, func() tea.Msg {
// calling revert on a "later" message is like a redo
response, err := m.app.Client.Session.Revert(
context.Background(),
m.app.Session.ID,
opencode.SessionRevertParams{
MessageID: opencode.F(messageID),
},
)
if err != nil {
slog.Error("Failed to redo message", "error", err)
return toast.NewErrorToast("Failed to redo message")
}
if response == nil {
return toast.NewErrorToast("Failed to redo message")
}
return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
}
}
func NewMessagesComponent(app *app.App) MessagesComponent {
vp := viewport.New()
vp.KeyMap = viewport.KeyMap{}
if app.State.ScrollSpeed != nil && *app.State.ScrollSpeed > 0 {
vp.MouseWheelDelta = *app.State.ScrollSpeed
} else {
vp.MouseWheelDelta = 4
}
return &messagesComponent{
app: app,
viewport: vp,
showToolDetails: true,
cache: NewPartCache(),
tail: true,
}
}