diff options
| author | Dax Raad <[email protected]> | 2025-05-18 22:30:41 -0400 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2025-05-26 12:40:17 -0400 |
| commit | 99af6146d5def31c59993636d60eb75a483a283b (patch) | |
| tree | eb57ed227c15cf9be54bc9f231c83895d812f881 /pkg/tui/components/chat/message.go | |
| parent | 020e0ca039287b73fa33041fbd1bb214e6ccb396 (diff) | |
| download | opencode-99af6146d5def31c59993636d60eb75a483a283b.tar.gz opencode-99af6146d5def31c59993636d60eb75a483a283b.zip | |
openapi
Diffstat (limited to 'pkg/tui/components/chat/message.go')
| -rw-r--r-- | pkg/tui/components/chat/message.go | 704 |
1 files changed, 704 insertions, 0 deletions
diff --git a/pkg/tui/components/chat/message.go b/pkg/tui/components/chat/message.go new file mode 100644 index 000000000..f887337ae --- /dev/null +++ b/pkg/tui/components/chat/message.go @@ -0,0 +1,704 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/diff" + "github.com/sst/opencode/internal/llm/agent" + "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/llm/tools" + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" +) + +type uiMessageType int + +const ( + userMessageType uiMessageType = iota + assistantMessageType + toolMessageType + + maxResultHeight = 10 +) + +type uiMessage struct { + ID string + messageType uiMessageType + position int + height int + content string +} + +func toMarkdown(content string, focused bool, width int) string { + r := styles.GetMarkdownRenderer(width) + rendered, _ := r.Render(content) + return rendered +} + +func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string { + t := theme.CurrentTheme() + + style := styles.BaseStyle(). + Width(width - 1). + BorderLeft(true). + Foreground(t.TextMuted()). + BorderForeground(t.Primary()). + BorderStyle(lipgloss.ThickBorder()) + + if isUser { + style = style.BorderForeground(t.Secondary()) + } + + // Apply markdown formatting and handle background color + parts := []string{ + styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()), + } + + // Remove newline at the end + parts[0] = strings.TrimSuffix(parts[0], "\n") + if len(info) > 0 { + parts = append(parts, info...) + } + + rendered := style.Render( + lipgloss.JoinVertical( + lipgloss.Left, + parts..., + ), + ) + + return rendered +} + +func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage { + var styledAttachments []string + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + 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)) + } + + // Add timestamp info + info := []string{} + timestamp := msg.CreatedAt.Local().Format("02 Jan 2006 03:04 PM") + username, _ := config.GetUsername() + info = append(info, baseStyle. + Width(width-1). + Foreground(t.TextMuted()). + Render(fmt.Sprintf(" %s (%s)", username, 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 { + content = renderMessage(msg.Content().String(), true, isFocused, width, info...) + } + userMsg := uiMessage{ + ID: msg.ID, + messageType: userMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + } + return userMsg +} + +// Returns multiple uiMessages because of the tool calls +func renderAssistantMessage( + msg message.Message, + msgIndex int, + allMessages []message.Message, // we need this to get tool results and the user message + messagesService message.Service, // We need this to get the task tool messages + focusedUIMessageId string, + width int, + position int, + showToolMessages bool, +) []uiMessage { + messages := []uiMessage{} + content := strings.TrimSpace(msg.Content().String()) + thinking := msg.IsThinking() + thinkingContent := msg.ReasoningContent().Thinking + finished := msg.IsFinished() + finishData := msg.FinishPart() + info := []string{} + + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + // Always add timestamp info + timestamp := msg.CreatedAt.Local().Format("02 Jan 2006 03:04 PM") + modelName := "Assistant" + if msg.Model != "" { + modelName = models.SupportedModels[msg.Model].Name + } + + info = append(info, baseStyle. + Width(width-1). + Foreground(t.TextMuted()). + Render(fmt.Sprintf(" %s (%s)", modelName, timestamp)), + ) + + if finished { + // Add finish info if available + switch finishData.Reason { + case message.FinishReasonCanceled: + info = append(info, baseStyle. + Width(width-1). + Foreground(t.Warning()). + Render("(canceled)"), + ) + case message.FinishReasonError: + info = append(info, baseStyle. + Width(width-1). + Foreground(t.Error()). + Render("(error)"), + ) + case message.FinishReasonPermissionDenied: + info = append(info, baseStyle. + Width(width-1). + Foreground(t.Info()). + Render("(permission denied)"), + ) + } + } + + if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) { + if content == "" { + content = "*Finished without output*" + } + + content = renderMessage(content, false, true, width, info...) + messages = append(messages, uiMessage{ + ID: msg.ID, + messageType: assistantMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + }) + position += messages[0].height + position++ // for the space + } else if thinking && thinkingContent != "" { + // Render the thinking content with timestamp + content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width, info...) + messages = append(messages, uiMessage{ + ID: msg.ID, + messageType: assistantMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + }) + position += lipgloss.Height(content) + position++ // for the space + } + + // Only render tool messages if they should be shown + if showToolMessages { + for i, toolCall := range msg.ToolCalls() { + toolCallContent := renderToolMessage( + toolCall, + allMessages, + messagesService, + focusedUIMessageId, + false, + width, + i+1, + ) + messages = append(messages, toolCallContent) + position += toolCallContent.height + position++ // for the space + } + } + return messages +} + +func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult { + for _, msg := range futureMessages { + for _, result := range msg.ToolResults() { + if result.ToolCallID == toolCallID { + return &result + } + } + } + return nil +} + +func toolName(name string) string { + switch name { + case agent.AgentToolName: + return "Task" + case tools.BashToolName: + return "Bash" + case tools.EditToolName: + return "Edit" + case tools.FetchToolName: + return "Fetch" + case tools.GlobToolName: + return "Glob" + case tools.GrepToolName: + return "Grep" + case tools.LSToolName: + return "List" + case tools.ViewToolName: + return "View" + case tools.WriteToolName: + return "Write" + case tools.PatchToolName: + return "Patch" + case tools.BatchToolName: + return "Batch" + } + return name +} + +func getToolAction(name string) string { + switch name { + case agent.AgentToolName: + return "Preparing prompt..." + case tools.BashToolName: + return "Building command..." + case tools.EditToolName: + return "Preparing edit..." + case tools.FetchToolName: + return "Writing fetch..." + case tools.GlobToolName: + return "Finding files..." + case tools.GrepToolName: + return "Searching content..." + case tools.LSToolName: + return "Listing directory..." + case tools.ViewToolName: + return "Reading file..." + case tools.WriteToolName: + return "Preparing write..." + case tools.PatchToolName: + return "Preparing patch..." + case tools.BatchToolName: + return "Running batch operations..." + } + return "Working..." +} + +// renders params, params[0] (params[1]=params[2] ....) +func renderParams(paramsWidth int, params ...string) string { + if len(params) == 0 { + return "" + } + mainParam := params[0] + if len(mainParam) > paramsWidth { + mainParam = mainParam[:paramsWidth-3] + "..." + } + + if len(params) == 1 { + return mainParam + } + otherParams := params[1:] + // create pairs of key/value + // if odd number of params, the last one is a key without value + if len(otherParams)%2 != 0 { + otherParams = append(otherParams, "") + } + parts := make([]string, 0, len(otherParams)/2) + for i := 0; i < len(otherParams); i += 2 { + key := otherParams[i] + value := otherParams[i+1] + if value == "" { + continue + } + parts = append(parts, fmt.Sprintf("%s=%s", key, value)) + } + + partsRendered := strings.Join(parts, ", ") + remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space + if remainingWidth < 30 { + // No space for the params, just show the main + return mainParam + } + + if len(parts) > 0 { + mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) + } + + return ansi.Truncate(mainParam, paramsWidth, "...") +} + +func removeWorkingDirPrefix(path string) string { + wd := config.WorkingDirectory() + if strings.HasPrefix(path, wd) { + path = strings.TrimPrefix(path, wd) + } + if strings.HasPrefix(path, "/") { + path = strings.TrimPrefix(path, "/") + } + if strings.HasPrefix(path, "./") { + path = strings.TrimPrefix(path, "./") + } + if strings.HasPrefix(path, "../") { + path = strings.TrimPrefix(path, "../") + } + return path +} + +func renderToolParams(paramWidth int, toolCall message.ToolCall) string { + params := "" + switch toolCall.Name { + case agent.AgentToolName: + var params agent.AgentParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + prompt := strings.ReplaceAll(params.Prompt, "\n", " ") + return renderParams(paramWidth, prompt) + case tools.BashToolName: + var params tools.BashParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + command := strings.ReplaceAll(params.Command, "\n", " ") + return renderParams(paramWidth, command) + case tools.EditToolName: + var params tools.EditParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + filePath := removeWorkingDirPrefix(params.FilePath) + return renderParams(paramWidth, filePath) + case tools.FetchToolName: + var params tools.FetchParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + url := params.URL + toolParams := []string{ + url, + } + if params.Format != "" { + toolParams = append(toolParams, "format", params.Format) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String()) + } + return renderParams(paramWidth, toolParams...) + case tools.GlobToolName: + var params tools.GlobParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + pattern := params.Pattern + toolParams := []string{ + pattern, + } + if params.Path != "" { + toolParams = append(toolParams, "path", params.Path) + } + return renderParams(paramWidth, toolParams...) + case tools.GrepToolName: + var params tools.GrepParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + pattern := params.Pattern + toolParams := []string{ + pattern, + } + if params.Path != "" { + toolParams = append(toolParams, "path", params.Path) + } + if params.Include != "" { + toolParams = append(toolParams, "include", params.Include) + } + if params.LiteralText { + toolParams = append(toolParams, "literal", "true") + } + return renderParams(paramWidth, toolParams...) + case tools.LSToolName: + var params tools.LSParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + path := params.Path + if path == "" { + path = "." + } + return renderParams(paramWidth, path) + case tools.ViewToolName: + var params tools.ViewParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + filePath := removeWorkingDirPrefix(params.FilePath) + toolParams := []string{ + filePath, + } + if params.Limit != 0 { + toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit)) + } + if params.Offset != 0 { + toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) + } + return renderParams(paramWidth, toolParams...) + case tools.WriteToolName: + var params tools.WriteParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + filePath := removeWorkingDirPrefix(params.FilePath) + return renderParams(paramWidth, filePath) + case tools.BatchToolName: + var params tools.BatchParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls))) + default: + input := strings.ReplaceAll(toolCall.Input, "\n", " ") + params = renderParams(paramWidth, input) + } + return params +} + +func truncateHeight(content string, height int) string { + lines := strings.Split(content, "\n") + if len(lines) > height { + return strings.Join(lines[:height], "\n") + } + return content +} + +func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + if response.IsError { + errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " ")) + errContent = ansi.Truncate(errContent, width-1, "...") + return baseStyle. + Width(width). + Foreground(t.Error()). + Render(errContent) + } + + resultContent := truncateHeight(response.Content, maxResultHeight) + switch toolCall.Name { + case agent.AgentToolName: + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, false, width), + t.Background(), + ) + case tools.BashToolName: + resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent) + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + t.Background(), + ) + case tools.EditToolName: + metadata := tools.EditResponseMetadata{} + json.Unmarshal([]byte(response.Metadata), &metadata) + formattedDiff, _ := diff.FormatDiff(metadata.Diff, diff.WithTotalWidth(width)) + return formattedDiff + case tools.FetchToolName: + var params tools.FetchParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + mdFormat := "markdown" + switch params.Format { + case "text": + mdFormat = "text" + case "html": + mdFormat = "html" + } + resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent) + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + t.Background(), + ) + case tools.GlobToolName: + return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) + case tools.GrepToolName: + return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) + case tools.LSToolName: + return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) + case tools.ViewToolName: + metadata := tools.ViewResponseMetadata{} + json.Unmarshal([]byte(response.Metadata), &metadata) + ext := filepath.Ext(metadata.FilePath) + if ext == "" { + ext = "" + } else { + ext = strings.ToLower(ext[1:]) + } + resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight)) + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + t.Background(), + ) + case tools.WriteToolName: + params := tools.WriteParams{} + json.Unmarshal([]byte(toolCall.Input), ¶ms) + metadata := tools.WriteResponseMetadata{} + json.Unmarshal([]byte(response.Metadata), &metadata) + ext := filepath.Ext(params.FilePath) + if ext == "" { + ext = "" + } else { + ext = strings.ToLower(ext[1:]) + } + resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight)) + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + t.Background(), + ) + case tools.BatchToolName: + var batchResult tools.BatchResult + if err := json.Unmarshal([]byte(resultContent), &batchResult); err != nil { + return baseStyle.Width(width).Foreground(t.Error()).Render(fmt.Sprintf("Error parsing batch result: %s", err)) + } + + var toolCalls []string + for i, result := range batchResult.Results { + toolName := toolName(result.ToolName) + + // Format the tool input as a string + inputStr := string(result.ToolInput) + + // Format the result + var resultStr string + if result.Error != "" { + resultStr = fmt.Sprintf("Error: %s", result.Error) + } else { + var toolResponse tools.ToolResponse + if err := json.Unmarshal(result.Result, &toolResponse); err != nil { + resultStr = "Error parsing tool response" + } else { + resultStr = truncateHeight(toolResponse.Content, 3) + } + } + + // Format the tool call + toolCall := fmt.Sprintf("%d. %s: %s\n %s", i+1, toolName, inputStr, resultStr) + toolCalls = append(toolCalls, toolCall) + } + + return baseStyle.Width(width).Foreground(t.TextMuted()).Render(strings.Join(toolCalls, "\n\n")) + default: + resultContent = fmt.Sprintf("```text\n%s\n```", resultContent) + return styles.ForceReplaceBackgroundWithLipgloss( + toMarkdown(resultContent, true, width), + t.Background(), + ) + } +} + +func renderToolMessage( + toolCall message.ToolCall, + allMessages []message.Message, + messagesService message.Service, + focusedUIMessageId string, + nested bool, + width int, + position int, +) uiMessage { + if nested { + width = width - 3 + } + + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + style := baseStyle. + Width(width - 1). + BorderLeft(true). + BorderStyle(lipgloss.ThickBorder()). + PaddingLeft(1). + BorderForeground(t.TextMuted()) + + response := findToolResponse(toolCall.ID, allMessages) + toolNameText := baseStyle.Foreground(t.TextMuted()). + Render(fmt.Sprintf("%s: ", toolName(toolCall.Name))) + + if !toolCall.Finished { + // Get a brief description of what the tool is doing + toolAction := getToolAction(toolCall.Name) + + progressText := baseStyle. + Width(width - 2 - lipgloss.Width(toolNameText)). + Foreground(t.TextMuted()). + Render(fmt.Sprintf("%s", toolAction)) + + content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText)) + toolMsg := uiMessage{ + messageType: toolMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + } + return toolMsg + } + + params := renderToolParams(width-1-lipgloss.Width(toolNameText), toolCall) + responseContent := "" + if response != nil { + responseContent = renderToolResponse(toolCall, *response, width-2) + responseContent = strings.TrimSuffix(responseContent, "\n") + } else { + responseContent = baseStyle. + Italic(true). + Width(width - 2). + Foreground(t.TextMuted()). + Render("Waiting for response...") + } + + parts := []string{} + if !nested { + formattedParams := baseStyle. + Width(width - 2 - lipgloss.Width(toolNameText)). + Foreground(t.TextMuted()). + Render(params) + + parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams)) + } else { + prefix := baseStyle. + Foreground(t.TextMuted()). + Render(" └ ") + formattedParams := baseStyle. + Width(width - 2 - lipgloss.Width(toolNameText)). + Foreground(t.TextMuted()). + Render(params) + parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams)) + } + + if toolCall.Name == agent.AgentToolName { + taskMessages, _ := messagesService.List(context.Background(), toolCall.ID) + toolCalls := []message.ToolCall{} + for _, v := range taskMessages { + toolCalls = append(toolCalls, v.ToolCalls()...) + } + for _, call := range toolCalls { + rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0) + parts = append(parts, rendered.content) + } + } + if responseContent != "" && !nested { + parts = append(parts, responseContent) + } + + content := style.Render( + lipgloss.JoinVertical( + lipgloss.Left, + parts..., + ), + ) + if nested { + content = lipgloss.JoinVertical( + lipgloss.Left, + parts..., + ) + } + toolMsg := uiMessage{ + messageType: toolMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + } + return toolMsg +} |
