opencode/packages/tui/internal/components/chat/message.go
2025-06-05 12:38:25 -05:00

398 lines
11 KiB
Go

package chat
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const (
maxResultHeight = 10
)
func toMarkdown(content string, width int) string {
r := styles.GetMarkdownRenderer(width)
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
if len(lines) > 0 {
firstLine := lines[0]
cleaned := ansi.Strip(firstLine)
nospace := strings.ReplaceAll(cleaned, " ", "")
if nospace == "" {
lines = lines[1:]
}
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
cleaned = ansi.Strip(lastLine)
nospace = strings.ReplaceAll(cleaned, " ", "")
if nospace == "" {
lines = lines[:len(lines)-1]
}
}
}
return strings.TrimSuffix(strings.Join(lines, "\n"), "\n")
}
func renderUserMessage(user string, msg client.MessageInfo, width int) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
PaddingLeft(1).
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Secondary()).
BorderStyle(lipgloss.ThickBorder())
// var styledAttachments []string
// attachmentStyles := baseStyle.
// MarginLeft(1).
// Background(t.TextMuted()).
// Foreground(t.Text())
// for _, attachment := range msg.BinaryContent() {
// file := filepath.Base(attachment.Path)
// var filename string
// if len(file) > 10 {
// filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
// } else {
// filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
// }
// styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
// }
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
timestamp = timestamp[12:]
}
info := styles.BaseStyle().
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", user, timestamp))
content := ""
// if len(styledAttachments) > 0 {
// attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
// content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
// } else {
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
}
}
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func renderAssistantMessage(
msg client.MessageInfo,
width int,
showToolMessages bool,
appInfo client.AppInfo,
) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
PaddingLeft(1).
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Primary()).
BorderStyle(lipgloss.ThickBorder())
messages := []string{}
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
timestamp = timestamp[12:]
}
modelName := msg.Metadata.Assistant.ModelID
info := styles.BaseStyle().
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", modelName, timestamp))
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
// case client.MessagePartReasoning:
// reasoningPart := part.(client.MessagePartReasoning)
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
messages = append(messages, message)
case client.MessagePartToolInvocation:
if !showToolMessages {
continue
}
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
if resultError == nil {
result = &resultPart.Result
}
metadata := map[string]any{}
if _, ok := msg.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = msg.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
}
message := renderToolInvocation(toolCall, result, metadata, appInfo, width)
messages = append(messages, message)
}
}
return strings.Join(messages, "\n\n")
}
func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result *string, metadata map[string]any, appInfo client.AppInfo, width int) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
BorderLeft(true).
PaddingLeft(1).
Foreground(t.TextMuted()).
BorderForeground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder())
toolName := renderToolName(toolCall.ToolName)
toolArgs := ""
toolArgsMap := make(map[string]any)
if toolCall.Args != nil {
value := *toolCall.Args
m, ok := value.(map[string]any)
if ok {
toolArgsMap = m
firstKey := ""
for key := range toolArgsMap {
firstKey = key
break
}
toolArgs = renderArgs(&toolArgsMap, appInfo, firstKey)
}
}
title := fmt.Sprintf("%s: %s", toolName, toolArgs)
finished := result != nil
body := styles.BaseStyle().Render("In progress...")
if finished {
body = *result
}
footer := ""
if metadata["time"] != nil {
timeMap := metadata["time"].(map[string]any)
start := timeMap["start"].(float64)
end := timeMap["end"].(float64)
durationMs := end - start
duration := time.Duration(durationMs * float64(time.Millisecond))
roundedDuration := time.Duration(duration.Round(time.Millisecond))
if durationMs > 1000 {
roundedDuration = time.Duration(duration.Round(time.Second))
}
footer = styles.Muted().Render(fmt.Sprintf("%s", roundedDuration))
}
switch toolCall.ToolName {
case "opencode_edit":
filename := toolArgsMap["filePath"].(string)
filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
title = fmt.Sprintf("%s: %s", toolName, filename)
if finished && metadata["diff"] != nil {
patch := metadata["diff"].(string)
formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
body = strings.TrimSpace(formattedDiff)
return style.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
body,
styles.ForceReplaceBackgroundWithLipgloss(footer, t.Background()),
))
}
case "opencode_read":
toolArgs = renderArgs(&toolArgsMap, appInfo, "filePath")
title = fmt.Sprintf("%s: %s", toolName, toolArgs)
filename := toolArgsMap["filePath"].(string)
ext := filepath.Ext(filename)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
if finished {
if metadata["preview"] != nil {
body = metadata["preview"].(string)
}
body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(body, 10))
body = toMarkdown(body, width)
}
case "opencode_write":
filename := toolArgsMap["filePath"].(string)
filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
title = fmt.Sprintf("%s: %s", toolName, filename)
ext := filepath.Ext(filename)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
content := toolArgsMap["content"].(string)
body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(content, 10))
body = toMarkdown(body, width)
case "opencode_bash":
if finished && metadata["stdout"] != nil {
description := toolArgsMap["description"].(string)
title = fmt.Sprintf("%s: %s", toolName, description)
command := toolArgsMap["command"].(string)
stdout := metadata["stdout"].(string)
body = fmt.Sprintf("```console\n$ %s\n%s```", command, stdout)
body = toMarkdown(body, width)
}
case "opencode_todoread":
title = fmt.Sprintf("%s", toolName)
if finished && metadata["todos"] != nil {
body = ""
todos := metadata["todos"].([]any)
for _, todo := range todos {
t := todo.(map[string]any)
content := t["content"].(string)
switch t["status"].(string) {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] _%s_\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = toMarkdown(body, width)
}
case "opencode_todowrite":
title = fmt.Sprintf("%s", toolName)
if finished && metadata["todos"] != nil {
body = ""
todos := metadata["todos"].([]any)
for _, todo := range todos {
t := todo.(map[string]any)
content := t["content"].(string)
switch t["status"].(string) {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] _%s_\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = toMarkdown(body, width)
}
default:
body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10))
body = toMarkdown(body, width)
}
if metadata["error"] != nil && metadata["message"] != nil {
body = styles.BaseStyle().Foreground(t.Error()).Render(metadata["message"].(string))
}
content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
body,
footer,
))
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func renderToolName(name string) string {
switch name {
// case agent.AgentToolName:
// return "Task"
case "opencode_ls":
return "List"
case "opencode_webfetch":
return "Fetch"
case "opencode_todoread":
return "Read TODOs"
case "opencode_todowrite":
return "Update TODOs"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_")
}
return cases.Title(language.Und).String(normalizedName)
}
}
func renderToolAction(name string) string {
switch name {
// case agent.AgentToolName:
// return "Preparing prompt..."
case "opencode_bash":
return "Building command..."
case "opencode_edit":
return "Preparing edit..."
case "opencode_fetch":
return "Writing fetch..."
case "opencode_glob":
return "Finding files..."
case "opencode_grep":
return "Searching content..."
case "opencode_ls":
return "Listing directory..."
case "opencode_read":
return "Reading file..."
case "opencode_write":
return "Preparing write..."
case "opencode_patch":
return "Preparing patch..."
case "opencode_batch":
return "Running batch operations..."
}
return "Working..."
}
func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) string {
if args == nil || len(*args) == 0 {
return ""
}
title := ""
parts := []string{}
for key, value := range *args {
if key == "filePath" || key == "path" {
value = strings.TrimPrefix(value.(string), appInfo.Path.Root+"/")
}
if key == titleKey {
title = fmt.Sprintf("%s", value)
continue
}
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
}
if len(parts) == 0 {
return title
}
return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
}
func truncateHeight(content string, height int) string {
lines := strings.Split(content, "\n")
if len(lines) > height {
return strings.Join(lines[:height], "\n")
}
return content
}