mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 01:34:22 +01:00
This release has a bunch of minor breaking changes if you are using opencode plugins or sdk 1. storage events have been removed (we might bring this back but had some issues) 2. concept of `app` is gone - there is a new concept called `project` and endpoints to list projects and get the current project 3. plugin receives `directory` which is cwd and `worktree` which is where the root of the project is if it's a git repo 4. the session.chat function has been renamed to session.prompt in sdk. it no longer requires model to be passed in (model is now an object) 5. every endpoint takes an optional `directory` parameter to operate as though opencode is running in that directory
1318 lines
34 KiB
Go
1318 lines
34 KiB
Go
package chat
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
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
|
|
ThinkingBlocksVisible() 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)
|
|
ScrollToMessage(messageID string) (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
|
|
showThinkingBlocks bool
|
|
rendering bool
|
|
dirty bool
|
|
tail bool
|
|
partCount int
|
|
lineCount int
|
|
selection *selection
|
|
messagePositions map[string]int // map message ID to line position
|
|
animating bool
|
|
}
|
|
|
|
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{}
|
|
type ToggleThinkingBlocksMsg struct{}
|
|
type shimmerTickMsg 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 shimmerTickMsg:
|
|
if !m.app.HasAnimatingWork() {
|
|
m.animating = false
|
|
return m, nil
|
|
}
|
|
return m, tea.Sequence(
|
|
m.renderView(),
|
|
tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }),
|
|
)
|
|
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 {
|
|
m.selection = nil
|
|
if len(m.clipboard) > 0 {
|
|
content := strings.Join(m.clipboard, "\n")
|
|
m.clipboard = []string{}
|
|
return m, tea.Sequence(
|
|
m.renderView(),
|
|
app.SetClipboard(content),
|
|
toast.NewSuccessToast("Copied to clipboard"),
|
|
)
|
|
}
|
|
return m, m.renderView()
|
|
}
|
|
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 app.SendCommand:
|
|
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
|
|
m.app.State.ShowToolDetails = &m.showToolDetails
|
|
return m, tea.Batch(m.renderView(), m.app.SaveState())
|
|
case ToggleThinkingBlocksMsg:
|
|
m.showThinkingBlocks = !m.showThinkingBlocks
|
|
m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
|
|
return m, tea.Batch(m.renderView(), m.app.SaveState())
|
|
case app.SessionLoadedMsg:
|
|
m.tail = true
|
|
m.loading = true
|
|
return m, m.renderView()
|
|
case 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.SessionSelectedMsg:
|
|
currentParent := m.app.Session.ParentID
|
|
if currentParent == "" {
|
|
currentParent = m.app.Session.ID
|
|
}
|
|
|
|
targetParent := msg.ParentID
|
|
if targetParent == "" {
|
|
targetParent = msg.ID
|
|
}
|
|
|
|
// Clear cache only if switching between different session families
|
|
if currentParent != targetParent {
|
|
m.cache.Clear()
|
|
}
|
|
|
|
m.viewport.GotoBottom()
|
|
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.EventListResponseEventSessionError:
|
|
if msg.Properties.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.EventListResponseEventMessageRemoved:
|
|
if msg.Properties.SessionID == m.app.Session.ID {
|
|
m.cache.Clear()
|
|
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.messagePositions = msg.messagePositions
|
|
m.tail = m.viewport.AtBottom()
|
|
|
|
// Preserve scroll across reflow
|
|
// if the user was at bottom, keep following; otherwise restore the previous offset.
|
|
wasAtBottom := m.viewport.AtBottom()
|
|
prevYOffset := m.viewport.YOffset
|
|
m.viewport = msg.viewport
|
|
if wasAtBottom {
|
|
m.viewport.GotoBottom()
|
|
} else {
|
|
m.viewport.YOffset = prevYOffset
|
|
}
|
|
|
|
m.header = msg.header
|
|
if m.dirty {
|
|
cmds = append(cmds, m.renderView())
|
|
}
|
|
|
|
// Start shimmer ticks if any assistant/tool is in-flight
|
|
if !m.animating && m.app.HasAnimatingWork() {
|
|
m.animating = true
|
|
cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }))
|
|
}
|
|
}
|
|
|
|
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
|
|
messagePositions map[string]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
|
|
messagePositions := make(map[string]int) // Track message ID to line position
|
|
|
|
orphanedToolCalls := make([]opencode.ToolPart, 0)
|
|
|
|
width := m.width // always use full width
|
|
|
|
// Find the last streaming ReasoningPart to only shimmer that one
|
|
lastStreamingReasoningID := ""
|
|
if m.showThinkingBlocks {
|
|
for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- {
|
|
if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok {
|
|
continue
|
|
}
|
|
parts := m.app.Messages[mi].Parts
|
|
for pi := len(parts) - 1; pi >= 0; pi-- {
|
|
if rp, ok := parts[pi].(opencode.ReasoningPart); ok {
|
|
if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 {
|
|
lastStreamingReasoningID = rp.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
error := ""
|
|
|
|
switch casted := message.Info.(type) {
|
|
case opencode.UserMessage:
|
|
// Track the position of this user message
|
|
messagePositions[casted.ID] = lineCount
|
|
|
|
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)
|
|
agentParts := make([]opencode.AgentPart, 0)
|
|
for _, part := range remainingParts {
|
|
switch part := part.(type) {
|
|
case opencode.FilePart:
|
|
if part.Source.Text.Start >= 0 && part.Source.Text.End >= part.Source.Text.Start {
|
|
fileParts = append(fileParts, part)
|
|
}
|
|
case opencode.AgentPart:
|
|
if part.Source.Start >= 0 && part.Source.End >= part.Source.Start {
|
|
agentParts = append(agentParts, 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
|
|
isQueued := casted.ID > lastAssistantMessage
|
|
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued)
|
|
content, cached = m.cache.Get(key)
|
|
if !cached {
|
|
content = renderText(
|
|
m.app,
|
|
message.Info,
|
|
part.Text,
|
|
author,
|
|
m.showToolDetails,
|
|
width,
|
|
files,
|
|
false,
|
|
isQueued,
|
|
false,
|
|
fileParts,
|
|
agentParts,
|
|
)
|
|
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
|
|
hasContent := 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, toolCallParts)
|
|
content, cached = m.cache.Get(key)
|
|
if !cached {
|
|
content = renderText(
|
|
m.app,
|
|
message.Info,
|
|
part.Text,
|
|
casted.ModelID,
|
|
m.showToolDetails,
|
|
width,
|
|
"",
|
|
false,
|
|
false,
|
|
false,
|
|
[]opencode.FilePart{},
|
|
[]opencode.AgentPart{},
|
|
toolCallParts...,
|
|
)
|
|
m.cache.Set(key, content)
|
|
}
|
|
} else {
|
|
content = renderText(
|
|
m.app,
|
|
message.Info,
|
|
part.Text,
|
|
casted.ModelID,
|
|
m.showToolDetails,
|
|
width,
|
|
"",
|
|
false,
|
|
false,
|
|
false,
|
|
[]opencode.FilePart{},
|
|
[]opencode.AgentPart{},
|
|
toolCallParts...,
|
|
)
|
|
}
|
|
if content != "" {
|
|
partCount++
|
|
lineCount += lipgloss.Height(content) + 1
|
|
blocks = append(blocks, content)
|
|
hasContent = true
|
|
}
|
|
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,
|
|
)
|
|
m.cache.Set(key, content)
|
|
}
|
|
} else {
|
|
// if the tool call isn't finished, don't cache
|
|
content = renderToolDetails(
|
|
m.app,
|
|
part,
|
|
permission,
|
|
width,
|
|
)
|
|
}
|
|
if content != "" {
|
|
partCount++
|
|
lineCount += lipgloss.Height(content) + 1
|
|
blocks = append(blocks, content)
|
|
hasContent = true
|
|
}
|
|
case opencode.ReasoningPart:
|
|
if reverted {
|
|
continue
|
|
}
|
|
if !m.showThinkingBlocks {
|
|
continue
|
|
}
|
|
if part.Text != "" {
|
|
text := part.Text
|
|
shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID
|
|
content = renderText(
|
|
m.app,
|
|
message.Info,
|
|
text,
|
|
casted.ModelID,
|
|
m.showToolDetails,
|
|
width,
|
|
"",
|
|
true,
|
|
false,
|
|
shimmer,
|
|
[]opencode.FilePart{},
|
|
[]opencode.AgentPart{},
|
|
)
|
|
partCount++
|
|
lineCount += lipgloss.Height(content) + 1
|
|
blocks = append(blocks, content)
|
|
hasContent = true
|
|
}
|
|
}
|
|
}
|
|
|
|
switch err := casted.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 !hasContent && error == "" && !reverted {
|
|
content = renderText(
|
|
m.app,
|
|
message.Info,
|
|
"Generating...",
|
|
casted.ModelID,
|
|
m.showToolDetails,
|
|
width,
|
|
"",
|
|
false,
|
|
false,
|
|
false,
|
|
[]opencode.FilePart{},
|
|
[]opencode.AgentPart{},
|
|
)
|
|
partCount++
|
|
lineCount += lipgloss.Height(content) + 1
|
|
blocks = append(blocks, content)
|
|
}
|
|
}
|
|
|
|
if error != "" && !reverted {
|
|
error = styles.NewStyle().Width(width - 6).Render(error)
|
|
error = renderContentBlock(
|
|
m.app,
|
|
error,
|
|
width,
|
|
WithBorderColor(t.Error()),
|
|
)
|
|
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,
|
|
opencode.SessionMessageParams{},
|
|
)
|
|
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 {
|
|
if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok {
|
|
content := renderToolDetails(
|
|
m.app,
|
|
toolPart,
|
|
m.app.CurrentPermission,
|
|
width,
|
|
)
|
|
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,
|
|
messagePositions: messagePositions,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *messagesComponent) renderHeader() string {
|
|
if m.app.Session.ID == "" {
|
|
return ""
|
|
}
|
|
|
|
headerWidth := m.width
|
|
|
|
t := theme.CurrentTheme()
|
|
bgColor := t.Background()
|
|
borderColor := t.BackgroundElement()
|
|
|
|
isChildSession := m.app.Session.ParentID != ""
|
|
if isChildSession {
|
|
bgColor = t.BackgroundElement()
|
|
borderColor = t.Accent()
|
|
}
|
|
|
|
base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
|
|
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).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(bgColor).
|
|
Render(sessionInfoText)
|
|
|
|
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
|
|
|
|
navHint := ""
|
|
if isChildSession {
|
|
navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
|
|
}
|
|
|
|
headerTextWidth := headerWidth
|
|
if isChildSession {
|
|
headerTextWidth -= lipgloss.Width(navHint)
|
|
} else if !shareEnabled {
|
|
headerTextWidth -= lipgloss.Width(sessionInfoText)
|
|
}
|
|
headerText := util.ToMarkdown(
|
|
"# "+m.app.Session.Title,
|
|
headerTextWidth,
|
|
bgColor,
|
|
)
|
|
if isChildSession {
|
|
headerText = layout.Render(
|
|
layout.FlexOptions{
|
|
Background: &bgColor,
|
|
Direction: layout.Row,
|
|
Justify: layout.JustifySpaceBetween,
|
|
Align: layout.AlignStretch,
|
|
Width: headerTextWidth,
|
|
},
|
|
layout.FlexItem{
|
|
View: headerText,
|
|
},
|
|
layout.FlexItem{
|
|
View: navHint,
|
|
},
|
|
)
|
|
}
|
|
|
|
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}}
|
|
}
|
|
|
|
headerRow := layout.Render(
|
|
layout.FlexOptions{
|
|
Background: &bgColor,
|
|
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(bgColor).
|
|
Width(headerWidth).
|
|
PaddingLeft(2).
|
|
PaddingRight(2).
|
|
BorderLeft(true).
|
|
BorderRight(true).
|
|
BorderBackground(t.Background()).
|
|
BorderForeground(borderColor).
|
|
BorderStyle(lipgloss.ThickBorder()).
|
|
Render(header)
|
|
|
|
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()
|
|
bgColor := t.Background()
|
|
|
|
if m.loading {
|
|
return lipgloss.Place(
|
|
m.width,
|
|
m.height,
|
|
lipgloss.Center,
|
|
lipgloss.Center,
|
|
styles.NewStyle().Background(bgColor).Render(""),
|
|
styles.WhitespaceStyle(bgColor),
|
|
)
|
|
}
|
|
|
|
viewport := m.viewport.View()
|
|
return styles.NewStyle().
|
|
Background(bgColor).
|
|
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) ThinkingBlocksVisible() bool {
|
|
return m.showThinkingBlocks
|
|
}
|
|
|
|
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,
|
|
opencode.SessionUnrevertParams{},
|
|
)
|
|
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 (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
|
|
if m.messagePositions == nil {
|
|
return m, nil
|
|
}
|
|
|
|
if position, exists := m.messagePositions[messageID]; exists {
|
|
m.viewport.SetYOffset(position)
|
|
m.tail = false // Stop auto-scrolling to bottom when manually navigating
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func NewMessagesComponent(app *app.App) MessagesComponent {
|
|
vp := viewport.New()
|
|
vp.KeyMap = viewport.KeyMap{}
|
|
|
|
if app.ScrollSpeed > 0 {
|
|
vp.MouseWheelDelta = app.ScrollSpeed
|
|
} else {
|
|
vp.MouseWheelDelta = 2
|
|
}
|
|
|
|
// Default to showing tool details, hidden thinking blocks
|
|
showToolDetails := true
|
|
if app.State.ShowToolDetails != nil {
|
|
showToolDetails = *app.State.ShowToolDetails
|
|
}
|
|
|
|
showThinkingBlocks := false
|
|
if app.State.ShowThinkingBlocks != nil {
|
|
showThinkingBlocks = *app.State.ShowThinkingBlocks
|
|
}
|
|
|
|
return &messagesComponent{
|
|
app: app,
|
|
viewport: vp,
|
|
showToolDetails: showToolDetails,
|
|
showThinkingBlocks: showThinkingBlocks,
|
|
cache: NewPartCache(),
|
|
tail: true,
|
|
messagePositions: make(map[string]int),
|
|
}
|
|
}
|