package chat import ( "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/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" "github.com/sst/opencode/pkg/client" ) type uiMessageType int const ( userMessageType uiMessageType = iota assistantMessageType toolMessageType maxResultHeight = 10 ) type uiMessage struct { ID string messageType uiMessageType 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 client.MessageInfo, isFocused bool, width 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)) // } info := []string{} // Add timestamp info timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).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 { 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) content = renderMessage(textPart.Text, true, isFocused, width, info...) } } // content = renderMessage(msg.Parts, true, isFocused, width, info...) userMsg := uiMessage{ ID: msg.Id, messageType: userMessageType, 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 }